Очередное руководство по уменьшению бойлерплейта в Redux (NGRX)
- вторник, 8 января 2019 г. в 00:19:21
Будем о говорить о нескольких (пяти, если быть конкретным) способах, трюках, кровавых жертвах богу энтерпрайза, которые вроде как должны помочь нам писать более лаконичный и выразительный код в наших Redux (и NGRX!) приложениях. Способы выстраданы потом и кофе. Просьба сильно пинать и критиковать. Будем учиться кодить лучше вместе.
Честно говоря, мне сначала просто хотелось рассказать миру о своей новой микро-библиотеке (35 строк кода!) flux-action-class, но, глядя на все возрастающее количество возгласов о том, что Хабр скоро станет Твиттером, да и по-большей части с ними соглашаясь, решил попробовать сделать несколько более емкое чтиво. Итак, встречаем 5 способов прокачать ваше Redux приложение!
Рассмотрим типичный пример того, как можно послать AJAX запрос в Redux. Давайте представим, что нам крайне необходим список котиков с сервера.
import { createSelector } from 'reselect'
const actionTypeCatsGetInit = 'CATS_GET_INIT'
const actionTypeCatsGetSuccess = 'CATS_GET_SUCCESS'
const actionTypeCatsGetError = 'CATS_GET_ERROR'
const actionCatsGetInit = () => ({ type: actionTypeCatsGetInit })
const actionCatsGetSuccess = () => ({ type: actionTypeCatsGetSuccess })
const actionCatsGetError = () => ({ type: actionTypeCatsGetError })
const reducerCatsInitialState = {
error: undefined,
data: undefined,
loading: false,
}
const reducerCats = (state = reducerCatsInitialState, action) => {
switch (action.type) {
case actionTypeCatsGetInit:
return {
...state,
loading: true,
}
case actionCatsGetSuccess:
return {
error: undefined,
data: action.payload,
loading: false,
}
case actionCatsGetError:
return {
...data,
error: action.payload,
loading: false,
}
default:
return state
}
}
const makeSelectorCatsData = () =>
createSelector(
(state) => state.cats.data,
(cats) => cats,
)
const makeSelectorCatsLoading = () =>
createSelector(
(state) => state.cats.loading,
(loading) => loading,
)
const makeSelectorCatsError = () =>
createSelector(
(state) => state.cats.error,
(error) => error,
)
Если вам не совсем понятно, зачем тут нужны фабрики для селекторов, то можете почитать об этом здесь
Я сознательно не рассматриваю здесь сайд эффекты. Это тема для отдельной статьи полной подросткового гнева и критики в адрес существующей экосистемы :D
У этого кода можно найти несколько слабых мест:
loading
. Данные, которые мы храним в data
, и их форма может существенно изменяться от запроса к запросу, но индикатор загрузки (флаг loading
) будет все тот же.switch
— O(n) (ну, почти). Сам по себе это не очень сильный аргумент, потому Redux, в принципе, не про производительность. Меня больше бесит, что на каждый case
надо писать пару лишних строк обслуживающего кода, и что один switch
не получится легко и красиво разбить на несколько.Ну, не совсем. Просто мы заставим JS создавать их за нас.
Задумаемся на секунду о том, зачем нам вообще нужны типы у экшнов. Что ж, очевидно, чтобы запускать нужную ветку логики в нашем редьюсере и соответствующим образом менять состояние приложения. Настоящий вопрос в том, обязательно ли тип должен быть строкой? А что если бы мы использовали классы и делали switch
по типу?
class CatsGetInit {}
class CatsGetSuccess {
constructor(responseData) {
this.payload = responseData
}
}
class CatsGetError {
constructor(error) {
this.payload = error
this.error = true
}
}
const reducerCatsInitialState = {
error: undefined,
data: undefined,
loading: false,
}
const reducerCats = (state = reducerCatsInitialState, action) => {
switch (action.constructor) {
case CatsGetInit:
return {
...state,
loading: true,
}
case CatsGetSuccess:
return {
error: undefined,
data: action.payload,
loading: false,
}
case CatsGetError:
return {
...data,
error: action.payload,
loading: false,
}
default:
return state
}
}
Все вроде здорово, но есть одна проблема: мы лишились сериализации наших экшнов. Это более не простые объекты, которые мы можем конвертировать в строку и обратно. Теперь мы полагаемся на то, что у каждого экшна есть свой уникальный прототип, что, собственно, и позволяет такой конструкции, как switch
по action.constructor
, работать. Знаете, а мне очень нравится идея сериализации моих экшнов в строку и отправка их вместе с баг репортом, и я от нее отказываться не готов.
Итак, у каждого экшна должно быть поле type
(здесь можно посмотреть, что еще должно быть у каждого уважающего себя экшна). К счастью, у каждого класса есть имя, которое вроде как строка. Давайте добавим каждому классу геттер type
, который будет возвращать имя этого класса.
class CatsGetInit {
get type() {
return this.constructor.name
}
}
const reducerCats = (state, action) => {
switch (action.type) {
case CatsGetInit.name:
return {
...state,
loading: true,
}
//...
}
}
Это даже работает, но хотелось бы еще каждому типу прилепить префикс, как предлагает мистер Эрик в ducks-modular-redux (рекомендую глянуть на форк re-ducks, который еще круче, как по мне). Для того, чтобы добавить префикс, нам придется перестать использовать имя класса напрямую, а добавить еще один геттер. Теперь уже статический.
class CatsGetInit {
get static type () {
return `prefix/${this.name}`
}
get type() {
return this.constructor.type
}
}
const reducerCats = (state, action) => {
switch (action.type) {
case CatsGetInit.type:
return {
...state,
loading: true,
}
//...
}
}
Давайте все это дело немного причешем. Сократим до минимума copy-paste и добавим еще одно условие: если экшн представляет ошибку, то его payload
должен быть типа Error
.
class ActionStandard {
get static type () {
return `prefix/${this.name}`
}
get type() {
return this.constructor.type
}
constructor(payload) {
this.payload = payload
this.error = payload instanceof Error
}
}
class CatsGetInit extends ActionStandard {}
class CatsGetSuccess extends ActionStandard {}
class CatsGetError extends ActionStandard {}
const reducerCatsInitialState = {
error: undefined,
data: undefined,
loading: false,
}
const reducerCats = (state = reducerCatsInitialState, action) => {
switch (action.type) {
case CatsGetInit.type:
return {
...state,
loading: true,
}
case CatsGetSuccess.type:
return {
error: undefined,
data: action.payload,
loading: false,
}
case CatsGetError.type:
return {
...data,
error: action.payload,
loading: false,
}
default:
return state
}
}
На основе вышеизложенных размышлений была написана микро-библиотека flux-action-class. Там есть тесты, 100% покрытие кода тестами и почти тот же класс ActionStandard
приправленный дженериками для нужд TypeScript. Работает как с TypeScript, так и с JavaScript.
Идея проста до безобразия: использовать combineReducers не только для редьюсеров верхнего уровня, но и для дальнейшего разбиения логики и создания отдельного редьюсера для loading
.
const reducerLoading = (actionInit, actionSuccess, actionError) => (
state = false,
action,
) => {
switch (action.type) {
case actionInit.type:
return true
case actionSuccess.type:
return false
case actionError.type:
return false
}
}
class CatsGetInit extends ActionStandard {}
class CatsGetSuccess extends ActionStandard {}
class CatsGetError extends ActionStandard {}
const reducerCatsData = (state = undefined, action) => {
switch (action.type) {
case CatsGetSuccess.type:
return action.payload
default:
return state
}
}
const reducerCatsError = (state = undefined, action) => {
switch (action.type) {
case CatsGetError.type:
return action.payload
default:
return state
}
}
const reducerCats = combineReducers({
data: reducerCatsData,
loading: reducerLoading(CatsGetInit, CatsGetSuccess, CatsGetError),
error: reducerCatsError,
})
И снова предельно простая идея: вместо switch-case
использовать объект, из которого выбирать нужное поле по ключу. Доступ к полю объекта по ключу — O(1), да и выглядит порядком чище по моему скромному мнению.
const createReducer = (initialState, reducerMap) => (
state = initialState,
action,
) => {
// Выбираем редьюсер из объекта по ключу
const reducer = state[action.type]
if (!reducer) {
return state
}
// Запускаем редьюсер, если он есть
return reducer(state, action)
}
const reducerLoading = (actionInit, actionSuccess, actionError) =>
createReducer(false, {
[actionInit.type]: () => true,
[actionSuccess.type]: () => false,
[actionError.type]: () => false,
})
class CatsGetInit extends ActionStandard {}
class CatsGetSuccess extends ActionStandard {}
class CatsGetError extends ActionStandard {}
const reducerCatsData = createReducer(undefined, {
[CatsGetSuccess.type]: () => action.payload,
})
const reducerCatsError = createReducer(undefined, {
[CatsGetError.type]: () => action.payload,
})
const reducerCats = combineReducers({
data: reducerCatsData,
loading: reducerLoading(CatsGetInit, CatsGetSuccess, CatsGetError),
error: reducerCatsError,
})
Давайте немного отрефакторим reducerLoading
. Теперь, зная про мапы (объекты) для редьюсеров, мы можем вернуть эту самую мапу из reducerLoading
, вместо того, чтобы вернуть целый редьюсер. Потенциально, это открывает неограниченный простор для расширения функционала.
const createReducer = (initialState, reducerMap) => (
state = initialState,
action,
) => {
// Выбираем редьюсер из объекта по ключу
const reducer = state[action.type]
if (!reducer) {
return state
}
// Запускаем редьюсер, если он есть
return reducer(state, action)
}
const reducerLoadingMap = (actionInit, actionSuccess, actionError) => ({
[actionInit.type]: () => true,
[actionSuccess.type]: () => false,
[actionError.type]: () => false,
})
class CatsGetInit extends ActionStandard {}
class CatsGetSuccess extends ActionStandard {}
class CatsGetError extends ActionStandard {}
const reducerCatsLoading = createReducer(
false,
reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError),
)
/* Теперь мы можем легко расширить логику reducerCatsLoading:
const reducerCatsLoading = createReducer(
false,
{
...reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError),
... some custom stuff
}
)
*/
const reducerCatsData = createReducer(undefined, {
[CatsGetSuccess.type]: () => action.payload,
})
const reducerCatsError = createReducer(undefined, {
[CatsGetError.type]: () => action.payload,
})
const reducerCats = combineReducers({
data: reducerCatsData,
loading: reducerCatsLoading),
error: reducerCatsError,
})
Официальная документация на Redux тоже рассказывает про этот подход, однако, по какой-то неведомой причине я продолжаю видеть множество проектов, использующих switch-case
. На основе кода из официальной документации мистер Моше запилил для нас библиотеку для createReducer
.
Нам совершенно не обязательно держать ошибку для каждой сущности отдельно. В большинстве случаев мы просто хотим показать диаложку. Одну и ту же диаложку с динамическим текстом для всех сущностей.
Создадим глобальный обработчик ошибок. В самом простом случае он может выглядеть так:
class GlobalErrorInit extends ActionStandard {}
class GlobalErrorClear extends ActionStandard {}
const reducerError = createReducer(undefined, {
[GlobalErrorInit.type]: (state, action) => action.payload,
[GlobalErrorClear.type]: (state, action) => undefined,
})
Затем в нашем сайд эффекте будем отправлять экшн ErrorInit
в блоке catch
. Это может выглядеть как-то так при использовании redux-thunk:
const catsGetAsync = async (dispatch) => {
dispatch(new CatsGetInit())
try {
const res = await fetch('https://cats.com/api/v1/cats')
const body = await res.json()
dispatch(new CatsGetSuccess(body))
} catch (error) {
dispatch(new CatsGetError(error))
dispatch(new GlobalErrorInit(error))
}
}
Теперь мы можем избавиться от поля error
в нашем сторе для котиков и использовать CatsGetError
лишь затем, чтобы переключать флаг loading
.
class CatsGetInit extends ActionStandard {}
class CatsGetSuccess extends ActionStandard {}
class CatsGetError extends ActionStandard {}
const reducerCatsLoading = createReducer(
false,
reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError),
)
const reducerCatsData = createReducer(undefined, {
[CatsGetSuccess.type]: () => action.payload,
})
const reducerCats = combineReducers({
data: reducerCatsData,
loading: reducerCatsLoading)
})
Посмотрим на нагромождение фабрик для селекторов еще раз.
Я выкинул makeSelectorCatsError
, потому что он больше не нужен, как мы выяснили в предыдущей главе.
const makeSelectorCatsData = () =>
createSelector(
(state) => state.cats.data,
(cats) => cats,
)
const makeSelectorCatsLoading = () =>
createSelector(
(state) => state.cats.loading,
(loading) => loading,
)
А зачем нам тут мемоизованные селекторы? Что конкретно мы пытаемся мемоизовать? Доступ к полю объекта по ключу, что здесь и происходит, — O(1). Мы можем использовать обычные немемоизованные функции. Используйте мемоизацию только тогда, когда вы хотите изменить данные из стора перед тем как отдать их компоненте.
const selectorCatsData = (state) => state.cats.data
const selectorCatsLoading = (state) => state.cats.loading
Мемоизация имеет смысл в случае вычисления результата на лету. Для примера ниже давайте представим, что каждый котик — это объект с полем name
, и мы хотим получить строку, содержащую имена всех котиков.
const makeSelectorCatNames = () =>
createSelector(
(state) => state.cats.data,
(cats) => cats.data.reduce((accum, { name }) => `${accum} ${name}`, ''),
)
Посмотрим еще раз с чего мы начали:
import { createSelector } from 'reselect'
const actionTypeCatsGetInit = 'CATS_GET_INIT'
const actionTypeCatsGetSuccess = 'CATS_GET_SUCCESS'
const actionTypeCatsGetError = 'CATS_GET_ERROR'
const actionCatsGetInit = () => ({ type: actionTypeCatsGetInit })
const actionCatsGetSuccess = () => ({ type: actionTypeCatsGetSuccess })
const actionCatsGetError = () => ({ type: actionTypeCatsGetError })
const reducerCatsInitialState = {
error: undefined,
data: undefined,
loading: false,
}
const reducerCats = (state = reducerCatsInitialState, action) => {
switch (action.type) {
case actionTypeCatsGetInit:
return {
...state,
loading: true,
}
case actionCatsGetSuccess:
return {
error: undefined,
data: action.payload,
loading: false,
}
case actionCatsGetError:
return {
...data,
error: action.payload,
loading: false,
}
default:
return state
}
}
const makeSelectorCatsData = () =>
createSelector(
(state) => state.cats.data,
(cats) => cats,
)
const makeSelectorCatsLoading = () =>
createSelector(
(state) => state.cats.loading,
(loading) => loading,
)
const makeSelectorCatsError = () =>
createSelector(
(state) => state.cats.error,
(error) => error,
)
И к чему пришли:
class CatsGetInit extends ActionStandard {}
class CatsGetSuccess extends ActionStandard {}
class CatsGetError extends ActionStandard {}
const reducerCatsLoading = createReducer(
false,
reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError),
)
const reducerCatsData = createReducer(undefined, {
[CatsGetSuccess.type]: () => action.payload,
})
const reducerCats = combineReducers({
data: reducerCatsData,
loading: reducerCatsLoading)
})
const selectorCatsData = (state) => state.cats.data
const selectorCatsLoading = (state) => state.cats.loading
Надеюсь, вы не потратили время зря, и статья была вам хоть чуточку полезна. Как я говорил в самом начале, просьба сильно пинать и критиковать. Будем учиться кодить лучше вместе.