[Перевод] Оверинжинирг 80 уровня или редьсюеры: путь от switch-case до классов

image


О чем пойдет речь?

Посмотрим на метаморфозы редьюсеров в моих Redux/NGRX приложениях за последние пару лет. Начиная с дубового switch-case, продолжая выбором из объекта по ключу и заканчивая классами с декораторами, блекджеком и TypeScript. Постараемся обозреть не только историю этого пути, но и найти какую-нибудь причинно-следственную связь.


Если вы так же как и я задаетесь вопросами избавления от бойлерплейта в Redux/NGRX, то вам может быть интересна эта статья.

Если вы уже используете подход к выбору редьюсера из объекта по ключу и сыты им по горло, то можете сразу листать до «Редьюсеры на основе классов».


Шоколадный switch-case


Обычно switch-case ванильный, но мне показалось, что это серьезно дискриминирует все остальные виды switch-case.

Итак, взглянем на типичную проблему асинхронного создания какой-то сущности, например, джедая.

const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'

const reducerJediInitialState = {
  loading: false,
  // Список джедаев
  data: [],
  error: undefined,
}
const reducerJedi = (state = reducerJediInitialState, action) => {
  switch (action.type) {
    case actionTypeJediCreateInit:
      return {
        ...state,
        loading: true,
      }
    case actionTypeJediCreateSuccess:
      return {
        loading: false,
        data: [...state.data, action.payload],
        error: undefined,
      }
    case actionTypeJediCreateError:
      return {
        ...state,
        loading: false,
        error: action.payload,
      }
    default:
      return state
  }
}

Я буду предельно откровенен и признаюсь, что никогда в своей практике не использовал switch-case. Хотелось бы верить, что у меня для этого даже есть некий список причин:


  • switch-case слишком легко поломать: можно забыть вставить break, можно забыть о default.
  • switch-case слишком многословен.
  • switch-case почти что O (n). Это не то, чтобы сильно важно само по себе, т.к. Redux не хвастается умопомрачительной производительностью сам по себе, но сей факт крайне бесит моего внутреннего ценителя прекрасного.

Логичный способ все это причесать предлагает официальная документация Redux — выбирать редьюсер из объекта по ключу.


Выбор редьюсера из объекта по ключу

Мысль проста — каждое изменения стейта можно описать функцией от стейта и экшна, и каждая такая функция имеет некий ключ (поле type в экшне), которая ей соответствует. Т.к. type — строка, нам ничто не мешает сообразить на все такие функции объект, где ключ — это type, а значение — это чистая функция преобразования стейта (редьюсер). В таком случае мы можем выбирать необходимый редьюсер по ключу (O (1)), когда в корневой редьюсер прилетает новый экшн.

const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'

const reducerJediInitialState = {
  loading: false,
  data: [],
  error: undefined,
}
const reducerJediMap = {
  [actionTypeJediCreateInit]: (state) => ({
    ...state,
    loading: true,
  }),
  [actionTypeJediCreateSuccess]: (state, action) => ({
    loading: false,
    data: [...state.data, action.payload],
    error: undefined,
  }),
  [actionTypeJediCreateError]: (state, action) => ({
    ...state,
    loading: false,
    error: action.payload,
  }),
}

const reducerJedi = (state = reducerJediInitialState, action) => {
  // Выбираем редьюсер по `type` экшна
  const reducer = reducerJediMap[action.type]
  if (!reducer) {
    // Возвращаем исходный стейт, если наш объект не содержит подходящего редьюсера
    return state
  }
  // Выполняем найденный редьюсер и возвращаем новый стейт
  return reducer(state, action)
}

Самое вкусное тут то, что логика внутри reducerJedi остается той же самой для любого редьюсера, и мы можем ее переиспользовать. Для этого даже есть нанобиблиотека redux-create-reducer.

import { createReducer } from 'redux-create-reducer'

const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'

const reducerJediInitialState = {
  loading: false,
  data: [],
  error: undefined,
}
const reducerJedi = createReducer(reducerJediInitialState, {
  [actionTypeJediCreateInit]: (state) => ({
    ...state,
    loading: true,
  }),
  [actionTypeJediCreateSuccess]: (state, action) => ({
    loading: false,
    data: [...state.data, action.payload],
    error: undefined,
  }),
  [actionTypeJediCreateError]: (state, action) => ({
    ...state,
    loading: false,
    error: action.payload,
  }),
})

Вроде бы ничего так получилось. Правда, ложка меда не без бочки дегтя:


  • Для сложный редьюсеров нам приходится оставлять комментарии, т.к. данный метод не предоставляет из коробки способа предоставить некую поясняющую мета-информацию.
  • Объекты с кучей редьюсеров и ключей не очень хорошо читаются.
  • Каждому редьюсеру соответствует только один ключ. А что если хочется запускать один и тот же редьюсер для нескольких экшнов?

Я чуть не расплакался от счастья, когда переехал на редьюсеры на основе классов, и ниже я расскажу почему.


Редьюсеры на основе классов

Плюшки:


  • Методы классов — это наши редьюсеры, а у методов есть имена. Как раз та самая мета-информация, которая расскажет, чем же этот редьюсер занимается.
  • Методы классов могут быть декорированы, что есть простой декларативный способ связать редьюсеры и соответствующие им экшны (именно экшны, а не один экшн!)
  • Под капотом можно использовать все те же объекты, чтобы получить O (1).

В итоге, хотелось бы получить что-то такое.

const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'

class ReducerJedi {
  // Смотрим на предложение о "Class field delcaratrions", которое нынче в Stage 3.
  // https://github.com/tc39/proposal-class-fields
  initialState = {
    loading: false,
    data: [],
    error: undefined,
  }

  @Action(actionTypeJediCreateInit)
  startLoading(state) {
    return {
      ...state,
      loading: true,
    }
  }

  @Action(actionTypeJediCreateSuccess)
  addNewJedi(state, action) {
    return {
      loading: false,
      data: [...state.data, action.payload],
      error: undefined,
    }
  }

  @Action(actionTypeJediCreateError)
  error(state, action) {
    return {
      ...state,
      loading: false,
      error: action.payload,
    }
  }
}

Вижу цель, не вижу препятствий.


Шаг 1. Декоратор @Action.

Нам надо, чтобы в этот декоратор мы могли запхать любое количество экшнов, и чтобы эти жкшны сохранились как некая мета-информация, к которой позже можно получить доступ. Для этого можем воспользоваться замечательным полифиллом reflect-metadata, который патчит Reflect.

const METADATA_KEY_ACTION = 'reducer-class-action-metadata'

export const Action = (...actionTypes) => (target, propertyKey, descriptor) => {
  Reflect.defineMetadata(METADATA_KEY_ACTION, actionTypes, target, propertyKey)
}


Шаг 2. Превращаем класс в, собственно, редьюсер.


Нарисовали кружок, нарисовали второй, а теперь немного магии и получаем сову!

Как мы знаем каждый редьюсер — это чистая функция, которая принимает текущий стейт и экшн и возвращает новый стейт. Класс — это, конечно, функция, но не совсем та, которая нам нужна, да и ES6 классы не могут быть вызваны без new. В общем надо его как-то преобразовать.

Итак, нам нужна функция, которая примет текущий класс, пройдет по каждому его методу, соберет мета-информация с типами экшнов, соберет объект с редьюсерами и создаст конечный редьюсер из этого объекта.

Начнем со сбора мета-информации.

const getReducerClassMethodsWthActionTypes = (instance) => {
  // Получаем названия методов из прототипа класса
  const proto = Object.getPrototypeOf(instance)
  const methodNames = Object.getOwnPropertyNames(proto).filter(
    (name) => name !== 'constructor',
  )

  // На выходе мы хотим получить коллекцию с типами экшнов и соответствующими редьюсерами
  const res = []
  methodNames.forEach((methodName) => {
    const actionTypes = Reflect.getMetadata(
      METADATA_KEY_ACTION,
      instance,
      methodName,
    )
    // Мы хотим привязать конекст `this` для каждого метода
    const method = instance[methodName].bind(instance)
    // Необходимо учесть, что каждому редьюсеру могут соответствовать несколько экшн типов
    actionTypes.forEach((actionType) =>
      res.push({
        actionType,
        method,
      }),
    )
  })
  return res
}

Теперь мы можем преобразовать полученную коллекцию в объект

const getReducerMap = (methodsWithActionTypes) =>
  methodsWithActionTypes.reduce((reducerMap, { method, actionType }) => {
    reducerMap[actionType] = method
    return reducerMap
  }, {})

Таким образом конечная функция может выглядеть так:

import { createReducer } from 'redux-create-reducer'

const createClassReducer = (ReducerClass) => {
  const reducerClass = new ReducerClass()
  const methodsWithActionTypes = getReducerClassMethodsWthActionTypes(
    reducerClass,
  )
  const reducerMap = getReducerMap(methodsWithActionTypes)
  const initialState = reducerClass.initialState
  const reducer = createReducer(initialState, reducerMap)
  return reducer
}

Далее мы можем применить ее к нашему классу ReducerJedi.

const reducerJedi = createClassReducer(ReducerJedi)


Шаг 3. Смотрим, что получилось в итоге.

// Переместим общий код в отдельный модуль
import { Action, createClassReducer } from 'utils/reducer-class'

const actionTypeJediCreateInit = 'jedi-app/jedi-create-init'
const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success'
const actionTypeJediCreateError = 'jedi-app/jedi-create-error'

class ReducerJedi {
  // Смотрим на предложение о "Class field delcaratrions", которое нынче в Stage 3.
  // https://github.com/tc39/proposal-class-fields
  initialState = {
    loading: false,
    data: [],
    error: undefined,
  }

  @Action(actionTypeJediCreateInit)
  startLoading(state) {
    return {
      ...state,
      loading: true,
    }
  }

  @Action(actionTypeJediCreateSuccess)
  addNewJedi(state, action) {
    return {
      loading: false,
      data: [...state.data, action.payload],
      error: undefined,
    }
  }

  @Action(actionTypeJediCreateError)
  error(state, action) {
    return {
      ...state,
      loading: false,
      error: action.payload,
    }
  }
}

export const reducerJedi = createClassReducer(ReducerJedi)


Как жить дальше?

Кое-что мы оставили за кадром:


  • Что если один и тот же экшн тип соответствует нескольким редьюсерам?
  • Было бы здорово добавить immer из коробки.
  • Что если мы хотим использовать классы для создания наших экшнов? Или функции (action creators)? Хотелось бы, чтобы декоратор мог принимать не только типы экшнов, то и actions creators.

Весь этот функционал с дополнительными примерами есть у небольшой библиотеки reducer-class.

Стоит заметить, что идея об использовании классов для редьюсеров не нова. @amcdnl некогда создал великолепную библиотеку ngrx-actions, но, кажется, сейчас он на нее забил и переключился на NGXS. К тому же мне хотелось более строгой типизации и сбросить балласт в виде специфичного для Angular функционала. Здесь можно ознакомиться со списком ключевых отличий между reducer-class и ngrx-actions.


Если вам понравилась идея с классами для редьюсеров, то вам также может понравиться использовать классы для ваших экшнов. Взгляните на flux-action-class.

Надеюсь, вы не потратили время зря, и статья была вам хоть чуточку полезна. Просьба пинать и критиковать. Будем учиться кодить лучше вместе.

© Habrahabr.ru