Pull to refresh

Очередное руководство по уменьшению бойлерплейта в Redux (NGRX)

Reading time10 min
Views12K


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


Будем о говорить о нескольких (пяти, если быть конкретным) способах, трюках, кровавых жертвах богу энтерпрайза, которые вроде как должны помочь нам писать более лаконичный и выразительный код в наших 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 = (payload) => ({
  type: actionTypeCatsGetSuccess,
  payload,
})
const actionCatsGetError = (error) => ({
  type: actionTypeCatsGetError,
  payload: error,
})

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 не получится легко и красиво разбить на несколько.
  • А нам действительно надо хранить состояние ошибки для каждой сущности отдельно?
  • Селекторы — это круто. Мемоизованные селекторы — круто вдвойне. Они дают нам абстракцию над нашим стором, дабы потом нам не пришлось переделывать половину приложения при изменении формы оного. Мы просто поменяем сам селектор. Что тут не радует глаз, так это набор примитивных фабрик, которые нужны лишь из-за особенностей работы мемоизации в reselct.

Способ 1: Избавляемся от экшн типов


Ну, не совсем. Просто мы заставим 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 {
  constructor() {
    this.type = 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}`
  }
  constructor () {
    this.type = 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}`
  }
  constructor(payload) {
    this.type = this.constructor.type
    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
  }
}

На данном этапе этот код отлично работает с NGRX, но Redux такое прожевать не способен. Он ругается на то, что экшны должны быть простыми объектами. К счастью, JS позволяет нам возвращать почти что угодно из конструтора, а нам на самом деле не очень-то и нужна цепочка прототипов после создания экшна.


class ActionStandard {
  get static type () {
    return `prefix/${this.name}`
  }
  constructor(payload) {
    return {
      type: this.constructor.type,
      payload,
      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.


Способ 2: Не боимся использовать combineReducers


Идея проста до безобразия: использовать 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,
})

Способ 3: Избавимся от switch


И снова предельно простая идея: вместо 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.


Способ 4: Используем глобальный обработчик ошибок


Нам совершенно не обязательно держать ошибку для каждой сущности отдельно. В большинстве случаев мы просто хотим показать диаложку. Одну и ту же диаложку с динамическим текстом для всех сущностей.


Создадим глобальный обработчик ошибок. В самом простом случае он может выглядеть так:


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

Способ 5: Думаем перед мемоизацией


Посмотрим на нагромождение фабрик для селекторов еще раз.


Я выкинул 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

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

Tags:
Hubs:
Total votes 11: ↑6 and ↓5+1
Comments28

Articles