
О чем пойдет речь?
Посмотрим на метаморфозы редьюсеров в моих 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.
Надеюсь, вы не потратили время зря, и статья была вам хоть чуточку полезна. Просьба пинать и критиковать. Будем учиться кодить лучше вместе.
