Обложка: YAM#2: больше фич богу фич!

YAM#2: больше фич богу фич!

В прошлой части мы сделали простейшую версию redux мидлвари, которая умеет вызывать различные побочные эффекты в ответ на определённые действия. Однако эволюция на этом не остановилась, и сегодня я хочу поделиться с вами дальнейшим развитием этой идеи.

Слушай внимательнее

В прошлой статье вы видели пример обработчика, реагирующего на изменение параметров поиска:

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

Долгое время наш код жил примерно в таком формате. Однако на практике оказалось, что стоит сделать иначе.

На панели с фильтрами было несколько разных виджетов: обычный с чекбоксами, виджет с ценами (и полями ввода для произвольных диапазонов), виджет для выбора цвета — перечислять можно долго. И у каждого из этих виджетов была своя логика обработки, которая, в том числе, могла привести к выбору значения, совпадающего с предыдущим (и зачем нам тогда ходить на сервер?).

В изначальной реализации был волшебный requestBuilder, который использовался в компонентах и создавал новый набор фильтров, попадавший потом в состояние. Но со временем я понял, что это не совсем redux way. Лучше завести набор действий типа filters/checkbox:changed, filters/ranged:changed, filters/color:changed и так далее. Я не буду углубляться в детали реализации, но набралось их порядка десятка. И код обработки изменений фильтров действительно стал гораздо более приятным, но мне очень не хотелось явно перечислять все эти действия в обработчике, ходящем к серверу.

Более того, перед отправкой запроса происходило сравнение нового запроса с предыдущим, который хранился… в URL! Ох уж этот легаси код, так я его и не вылечил. Но в этот момент стало ясно, что слушаю я не столько действия, сколько изменения состояния. Потом до меня дошло, что можно использовать эту концепцию ещё и для сохранения состояния, будь то location.search или localStorage. Поэтому, помимо реакции на определённые действия, я решил добавить возможность реагировать на изменение состояния. И выглядело это примерно так:

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

function handleSearch({ state, prevState, dispatch, action }) {
  const prevFilters = getFilters(prevState);
  const filters = getFilters(state);
  // we expect a piece of state to change, not an action to be dispatched
  if (prefFilters !== filters) {
    fetchSearchResult(filters).then((result) =>
      dispatch(searchResultReceived(result))
    );
  }
}

Сравнение фильтров делается по ссылке, поэтому важно обеспечить, чтобы селекторы правильно отслеживали неизменность. При грамотной организации состояния и использовании reselect (тут) это не так уж и сложно сделать. Я реализовывал подобную логику даже для сложных структур (массивы объектов, которые можно сравнить по ключу вместо глубокого равенства, например), но это лежит за пределами текущей темы. Поэтому предположим, что селекторы работают правильно и возвращают нам тот же самый объект, если он по сути своей не изменился.

Единственная проблема, которая остаётся — это мемоизация. Если вы не знакомы с reselectом, опишу в двух словах: создаваемые селекторы кэшируют предыдущее вычисленное значение, и если входные данные не изменились, то и создавать новый результат нам не придётся.

Но по умолчанию размер кэша у этих селекторов равен единице, то есть как только на вход поступают новые данные, предыдущий результат выбрасывается. И что тогда? А то, что если несколько обработчиков используют фильтры (например, один для отправки запросов, второй — для записи их в location.search), первый вызванный обработчик собьёт кэш, и второй уже не сможет корректно отработать.

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

Вместо явного вычисления и сравнения прошлого и текущего состояний можно предоставить разработчику функцию, которая будет делать это сама:

function handleSearch({ state, stateChangedBy, dispatch, action }) {
  if (stateChangedBy(getFilters)) {
    fetchSearchResult(getFilters(state)).then((result) =>
      dispatch(searchResultReceived(result))
    );
  }
}

Так давайте напишем свою собственную кэширующую магию, чтобы не сломать селекторы:

const createYam = (handlers) => (store) => (next) => (action) => {
  const prevState = store.getState();
  next(action);
  const state = store.getState();

  const stateChangedBy = createStateChangedBy(prevState, state);

  handlers.forEach((handler) =>
    handler({
      state,
      action,
      dispatch: store.dispatch,
      stateChangedBy,
    })
  );
};

function createStateChangedBy(prevState, nextState) {
  // per-dispatch cache for state comparison results
  const cache = new Map();
  return (selector) => {
    if (!cache.has(selector)) {
      cache.set(selector, selector(prevState) !== selector(nextState));
    }
    return cache.get(selector);
  };
}

Теперь на одном вызове dispatch мы вызываем каждый селектор только два раза: один раз со старым состоянием и один раз с новым.

Есть лишь один недостаток, который мне так и не удалось победить. Предположим, у нас есть два зависимых кэширующих селектора:

const selectFilters = createSelector(selectSearchState, (searchState) =>
  createFilters(searchState)
);
const selectRangeFilters = createSelector(selectFilters, (filters) =>
  filters.filter(isRangeFilter)
);

Если в обработчике будет использовано stateChangedBy(selectRangeFilters), это собьёт кэш для selectFilters, так как он неявно вызовется с новым состоянием. Это ломает изолированность обработчиков и всю логику сравнения, но я так и не смог понять, насколько это распространённый сценарий, поэтому чинить не стал. Однако это надо иметь в виду. И либо иначе организовывать селекторы (например, убирая мемоизацию там, где она не нужна), либо колдовать с множественным кэшированием для селекторов типа selectFilters.

Пиши чище

Работая с такой мидлварью, я понял не только как можно её использовать, но и как её использовать не стоит.

Один из обработчиков содержал в себе крайне много условий и разных по своей природе эффектов. Читать этот код было практически невозможно, а с тестами всё было на порядок хуже. Огромное количество проверяемых кейсов, куча expect(dispatch).toBeCalledWith(action) и страшных моков на всё, что только можно. Много времени было потрачено на обдумывание и обсуждение способов упрощения этого обработчика.

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

В моих обработчиках каждая ветка кода приводила к нескольким диспатчам, но, если делить их по зонам ответственности, в каждой из них был только один результат (и это правильно). И, в целом, это звучало как хорошая концепция: «В результате побочного эффекта мы можем получить одно дополнительное событие». Хочу обратить внимание, что не действие, а событие.

А потом я вспомнил, что часто проблемы с тестированием помогают решить чистые функции. И скажу я вам, что хоть мидлварь по природе своей и предназначена для побочных эффектов, это не значит, что нельзя сделать обработчик чистым. Ну а я человек простой: сказал — написал. Получилось красиво:

// this handler may lead either to a "search results recieved" event
// or to the "search cancelled" one
async function handleSearch({ state, stateChangedBy, action }) {
  if (stateChangedBy(getFilters)) {
    const result = await fetchSearchResult(getFilters(state));
    // - replace
    // dispatch(searchResultReceived(result));
    // + with
    return searchResultReceived(result);
  }
  // we should have this action to reset the `loading` state, for example
  return searchCancelled();
}

const createYam = (handlers) => (store) => (next) => (action) => {
  const prevState = store.getState();
  next(action);
  const state = store.getState();

  const stateChangedBy = createStateChangedBy(prevState, state);

  // the cycle must be async now
  handlers.forEach(async (handler) => {
    // we do not pass `dispatch` to handlers at all
    const nextAction = await handler({
      state,
      action,
      stateChangedBy,
    });
    // if anything has been returned from the handler,
    // we suppose it's an action and dispatch it
    if (nextAction) {
      store.dispatch(nextAction);
    }
  });
};

Таким образом мы не только добавили поддержку новомодных слов async/await, но и сделали обработчики проще в плане тестирования, а также ввели ограничение на написание чистых эффектов. Хотя бы касательно изменений состояния. Если эффекты касаются других хранилищ, увы, вызывать (и тестировать) эффекты придётся, как раньше: expect().toBeCalled() и всё в таком духе.

Делай прививки

Ещё одна фича, которой я никогда не пользовался, но очень часто натыкался на её упоминание в сети — это инъекция отдельных обработчиков в мидлвари при определённых условиях (чаще всего, на определённых страницах или при загрузке определённых компонентов).

Я считаю, что в маленьких приложениях совершенно необязательно так заморачиваться. Достаточно поставить проверку URL в обработчике и просто выходить из него. Опыт подсказывает, что десяток-другой === на вызов dispatch будет далеко не самой большой проблемой с производительностью. Поэтому в изначальной реализации я даже не думал о подобной логике.

Однако со временем я осознал, что такая фича полезна как минимум из соображений разбиения кода и уменьшения размера бандла, ведь мы не хотим тянуть в приложение код, который, возможно, даже и не понадобится некоторым пользователям. Ну и раз уж я планирую выложить эту библиотеку в общий доступ, я решил, что инъекции — это must have.

Наверное, можно придумать десятки вариантов, как добавить обработчик. Я решил опереться на то, что в идеальном приложении всяческие изменения происходят из-за некоторых событий. Значит, и новые обработчики должны добавляться тем же способом.

С именованием было сложно, так как многословно можно сказать, что нам стал нужен некоторый обработчик и обработчик перестал быть нужным, но это слишком «многабукафф». В итоге остановился на английских required и rejected. Если у вас есть лучшие названия для этих событий, буду рад увидеть их в комментариях.

С реализацией же всё оказалось не так сложно. Вдохновлялся я add/removeEventListener, которые принимают событие и обработчик и удаляют его по строгому равенству. Тогда внутри кода мидлвари можно хранить их в обычном массиве:

const handlerRequiredType = Symbol("handlerRequired");
const handlerRejectedType = Symbol("handlerRejected");
function handlerRequired(handler) {
  return { type: handlerRequiredType, payload: handler };
}
function handlerRejected(handler) {
  return { type: handlerRejectedType, payload: handler };
}

const createYam = (handlers) => (store) => {
  // store injected handlers per store
  let injectedHandlers = [];

  return (next) => (action) => {
    if (action.type === handlerRequiredType) {
      // avoid duplicates
      if (!injectedHandlers.includes(action.payload)) {
        injectedHandlers = [...injectedHandlers, action.payload];
      }
      // stop middleware chain
      return;
    }

    if (action.type === handlerRejectedType) {
      injectedHandlers = injectedHandlers.filter((h) => h !== action.payload);
      // stop middleware chain
      return;
    }

    const prevState = store.getState();
    next(action);
    const state = store.getState();

    const stateChangedBy = createStateChangedBy(prevState, state);

    // process both initial and injected handlers
    handlers.concat(injectedHandlers).forEach(async (handler) => {
      const nextAction = await handler({
        state,
        action,
        stateChangedBy,
      });
      if (nextAction) {
        store.dispatch(nextAction);
      }
    });
  };
};

Действия на добавление обработчиков волшебные, поэтому на них цепочка мидлварей прерывается. Типы объявлены через символы для пущей безопасности: вдруг кому-то понадобится завести handlerRequired действие. Или даже yam/handlerRequired — никто не застрахован. Ну и вставленные обработчики хранятся на уровне store, потому что это кажется концептуально верным, хотя если учесть, что redux строго не рекомендует использовать несколько экземпляров store на приложение, можно было бы и в корень модуля положить. В остальном же всё должно быть понятно.

Будь в контексте

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

// add context as an argument next to handlers
const createYam = (handlers, context) => (store) => {
  let injectedHandlers = [];

  return (next) => (action) => {
    if (action.type === handlerRequiredType) {
      if (!injectedHandlers.includes(action.payload)) {
        injectedHandlers = [...injectedHandlers, action.payload];
      }
      return;
    }

    if (action.type === handlerRejectedType) {
      injectedHandlers = injectedHandlers.filter((h) => h !== action.payload);
      return;
    }

    const prevState = store.getState();
    next(action);
    const state = store.getState();

    const stateChangedBy = createStateChangedBy(prevState, state);

    handlers.concat(injectedHandlers).forEach(async (handler) => {
      const nextAction = await handler({
        state,
        action,
        stateChangedBy,
        // pass the context to the handler
        context,
      });
      if (nextAction) {
        store.dispatch(nextAction);
      }
    });
  };
};

Его можно использовать, например, для передачи HTTPClient в обработчики (как дополнительный аргумент для redux-thunk), либо же для передачи dispatch, например, на период миграции, когда обработчики не могут просто так взять и отказаться от последовательных диспатчей. Дальше вы ограничиваетесь только вашей фантазией.

Заключение

На этом этапе yam стала гораздо ближе к варианту, готовому для испытаний в реальной жизни. Она даже стала прекраснее, чем то, что я оставил на своём старом проекте. Когда-нибудь здесь появится ссылка на npm пакет, а пока я предлагаю встретиться в следующий раз, чтобы обсудить последний, но отнюдь не по важности, момент: типизацию.

Что думаете?