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.

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