Хабрахабр

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

image

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

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

Если вы так же как и я задаетесь вопросами избавления от бойлерплейта в 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) => 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 в экшне), которая ей соответствует. В таком случае мы можем выбирать необходимый редьюсер по ключу (O(1)), когда в корневой редьюсер прилетает новый экшн. type — строка, нам ничто не мешает сообразить на все такие функции объект, где ключ — это type, а значение — это чистая функция преобразования стейта (редьюсер).

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)
}

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

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. Стоит заметить, что идея об использовании классов для редьюсеров не нова. Здесь можно ознакомиться со списком ключевых отличий между reducer-class и ngrx-actions. К тому же мне хотелось более строгой типизации и сбросить балласт в виде специфичного для Angular функционала.

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть