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