Дима Шпак
Дима Шпак
0
Обложка: YAM#1: когда хочется написать свою Redux middleware

YAM#1: когда хочется написать свою Redux middleware

В своих статьях я собираюсь рассмотреть процесс эволюции ещё одной мидлвари для Redux, которая отвечает за сайд-эффекты. Едва ли её можно назвать идеальной, но она точно выстрадана и испытана на передовой. И, пока ребята, поддерживающие Redux, так и не решили, какая мидлварь лучше, я попробую описать здесь свои мысли и предпочтения на этот счёт.

Чтобы не пугать никого банальностью присутствующих далее идей, хочу предупредить, что начиналась эта история, когда я был ещё зеленым-презелёным джуном без других фронтендеров на проекте, поэтому некоторые инсайды могут показаться вам странными, даже смешными. Что ж, все мы проходили через это.

Нам нужна нормальная мидлварь!

В один прекрасный солнечный день я наткнулся на неподъёмную для меня проблему.

Приложение наше обеспечивало функциональность поиска для интернет-магазинов. Вот один из стандартных пользовательских сценариев:

  • выбери значение на фильтре;
  • отправь запрос на сервер;
  • отрисуй полученные результаты.

Вполне себе классическая история, для которой, однако, использовался совершенно нестандартный подход. Да, у нас были подключены thunkи, но в тот момент я даже не знал, что там есть getState аргумент (угу, всё было настолько печально). Видимо, разработчик, который писал это до меня, тоже не знал. Поэтому у нас была использована волшебная концепция Actorов: эта такая расчудесная псевдо-мидлварь, работающая на React.

Компонент, который подписан на состояние, но render не возвращает JSX, а вызывает необходимые сайд-эффекты. В общем, смотрелось странно, а реализовано было ещё страннее. И вот с таким очаровательным бэкграундом я начал смотреть, как ещё можно добиться нужного поведения.

Но Гугл всемогущий не ответил мне. В итоге было принято решение попросить помощи у опытного фронта с соседнего проекта. Он внимательно выслушал мои жалобы, сказал «смотри, чё могу», и вместе с ним мы сели писать это.

Да будет store next action!

Первая версия была категорически простой.

const createYam = (handlers) => (store) => (next) => (action) => {
  // let the action go to the reducer and change the state as it needs
  next(action);
  // pass the state after the change to the middleware
  handlers.forEach((handler) =>
    handler(store.getState(), store.dispatch, action)
  );
};

На самом деле, сложно, наверное, придумать что-то проще. Дайте мне массив функций, и обработаю я вам всё, что хочется. В том числе, и поиск:

function handleSearch(state, dispatch, action) {
  if (action.type === "filters/changed") {
    fetchSearchResult(getFilters(state)).then((result) =>
      dispatch(searchResultReceived(result))
    );
  }
}

const yam = createYam([handleSearch]); // just add it to the store

Можно было бы на этом и остановиться, так ведь? И всё-таки нет. Некоторое время попользовавшись этим вариантом, я заметил три вещи, которые можно улучшить.

Упрощение интерфейса

Во-первых, я понял, что не всегда я использую все три аргумента, которые передаю в обработчик. Иногда мне не нужно состояние, иногда мне неважно содержимое действия, а иногда я не хочу ничего диспатчить (просто пишу в localStorage, например). Но, так как сложно было определить, в каком порядке расположить аргументы для максимально частых юз-кейсов, я просто превратил три аргумента в один:

const createYam = (handlers) => (store) => (next) => (action) => {
  next(action);
  handlers.forEach((handler) =>
    // - replace
    // handler(store.getState(), store.dispatch, action)
    // + with
    handler({
      state: store.getState(),
      dispatch: store.dispatch,
      action,
    })
  );
};

Вуаля, бери всё, что хочешь, а что не хочешь, не бери. Удобно, правда ведь?

Повышение стабильности

Ещё одна важная вещь, которую я где-то прочитал: обработчики должны быть максимально изолированы и ни в коем случае не должны влиять на работу соседей. Уж не знаю, касалось ли это именно мидлварей, или же просто это была умная мысль, но я понял, что изолированности моей реализации не хватает. А вы уже видите, где?

Правильно, мы вызываем store.getState() при прогоне для каждого обработчика. И, строго говоря, каждый последующий обработчик получает состояние уже изменённым эффектами от предыдущего. Другими словами, порядок добавления обработчиков имеет значение. И я уже чувствую, как подгорают сидушки программистов, которые пытаются это дебажить. Что ж, давайте исправимся:

const createYam = (handlers) => (store) => (next) => (action) => {
  next(action);
  // get the state out of the cycle
  const state = store.getState();
  handlers.forEach((handler) =>
    handler({
      state,
      dispatch: store.dispatch,
      action,
    })
  );
};

Та-дааа! Теперь на каждом dispatch все обработчики будут получать одно и то же состояние. Где-то внутри было отправлено ещё одно действие? Отлично, там мы тоже прогоним все обработчики, но это будет отдельный цикл со своим состоянием, Джеком Блэком и вообще.

Масштабируем

А теперь представьте, что обработчик должен реагировать на несколько разных действий. Что-ж, можно добавить ещё несколько if, можно превратить функцию в один большой switch, но где-то мы это уже видели, так ведь? А хотелось бы написать что-нибудь элегантное, чтобы избавиться от всех этих бесконечных ifов да switchей:

const bigHandler = createYamHandler({
  first: firstHandler,
  second: secondHandler,
  // ...
  nth: nthHandler,
});

Что ж, если вы хотите бороться с подобной проблемой в редьюсерах, сразу рекомендую обратить внимание на Redux ToolKit, там не только switch-case лечится. Но так как тут мы всё пишем сами, придётся думать. Хотя думать-то особо нечего: вынь обработчик да вызови.

function createYamHandler(handlerMap) {
  return (arg) => handlerMap[arg.action.type]?.(arg);
}

Заключение

Вот у нас и вышла маленькая, аккуратная, но довольно мощная мидлварь:

const createYam = (handlers) => (store) => (next) => (action) => {
  next(action);
  const state = store.getState();
  handlers.forEach((handler) =>
    handler({
      state,
      dispatch: store.dispatch,
      action,
    })
  );
};

function createYamHandler(handlerMap) {
  return (arg) => handlerMap[arg.action.type]?.(arg);
}

И вполне готовая для продакшена, скажу я вам. Но вы не спешите. Во-первых, потому что не стоит бросаться на любую новую библиотеку, которую вы видите. Во-вторых, потому что библиотеку я ещё не завёл (хотя и планирую в ближайшее время). Ну а в-третьих, есть ещё пара интересных и важных моментов, которые можно улучшить. Их мы и обсудим в следующий раз.