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



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.

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

Вы можете помочь и перевести немного средств на развитие сайта

Теги:



Комментарии (10):

  1. serf
    /#19764802

    Чего только не придумают чтобы не использовать простую библиотеку github.com/pelotom/unionize Там вам и бандлы экшенов, и матчеры и другие плюшки, и все при этом хорошо дружит с TypeScript.

    • keenondrums
      /#19766114

      Как по мне, то либа более многословная в плане создания экшнов по сравнению с flux-action-class.
      Если же говорить о редьюсерах, то ее функционал ничем не отличается от кейса "Выбор редьюсера из объекта по ключу" разобранного в этой статье. В начале главы "Редьюсеры на основе классов" я расписал те преимущества, которые я вижу для себя. Безусловно это очень субъективный набор. Для себя все же пока остановился на классовых редьюсерах в силу читаемости и, как следствие, поддерживаемости.

      • serf
        /#19766254 / +2

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

        Это не так. В показанном выборе редьюсера из объекта по ключу очень много недостатков которые отсутствуют в unionize подходе:
        — unionize поддерживает не только удобное объявление экшенов но и удобную дальнейшую работу с ними (матчинг, фильтеринг и тд)
        — Добавить TypeScript типизацию указанному способу так просто не получится потому что объект «оторван» от объявления самих экшенов в которых указаны типы пэйлоадов. Это мега большой недостаток. Нетипизированный JavaScript в наше время вообще вредно использовать для проектов сложнее todo app или hello world.
        — Нужно отдельно где-то объявлять и потом отдельно импортировать константы на экшены. Это ведет к неоправданному росту бойлерплейта. В сочетании с отсутствием типизации очень легко сделать ошибку сопоставляя ридьюсер с определенным экшеном. Нетипизированный JavaScript в наше время вообще вредно использовать для проектов сложнее todo app или hello world.
        — В случае unionize при матчинге нужно либо опиcать обработчики для всех объявленный экшенов или добавить default обработчик и это подкреплено на уровне TypeScript. Получается что забыть объявить обработчик для экшена не так просто и это хорошо.

        Перед выбором unionize я просмотрел множество самых разных библиотек хелперов, хотел свое написать, но unionize оказалась достатоной для моих нужд штукой. Там есть некоторые недостатки, но они решаемы (как например добавление префикса для экшенов).

        • keenondrums
          /#19766406

          Да, вы правы. Плюсанул. unionize лучше выбора по ключу как минимум в плане типизации.
          В случае классов с декораторами мы можем добиться не худшей типизации если на один экшн приходится один редьюсер и мы используем классы для экшнов. В статье я не стал описывать этот функционал, дабы не ее совсем не раздуло, но в библиотеке он есть.
          Посмотрите на пример. В частности, на метод addEnergy. Экшн по которому выолнится редьюсер заинферится из типа.

  2. symbix
    /#19765460

    О, новая выставка велосипедов!


    У меня вот так еще с тех времен, когда Angular был только 2 и ngrx еще не был platform.


    Типичный reducer.ts при этом выглядит как-то так:


    export const reducer = new ReducersMap<State, Actions.All>(initialState)
        .when(Actions.FOO, (state: State, action: Actions.FooAction) => ({ ... })
        ...
        .reducer;

  3. mrigi
    /#19766264 / +2

    Теперь понятно для кого все эти гигагерцы и гигабайты памяти. Похоже девиз: на каждого воробья по персональной царь-пушке и взводу артиллеристов!

    • keenondrums
      /#19766392

      Надо же двигать экономику? Кто, если не мы?)

  4. Eternalko
    /#19766580 / +1

    Да вы практически переделали Redux на Mobx (: