Redux: Реанимируем легаси проект

Всем привет.

Немного контекста. У нас есть легаси проект, который пишется уже на протяжении порядка пяти лет. Когда мы его стартовали, было принято решение использовать redux в качестве стэйт менеджера. Сейчас не вижу смысла рассуждать на тему того, было ли это решение правильным, имеем то, что имеем, а именно кучу кода, мигрировать который на что-то иное вряд ли получится за адекватное время одновременно с написанием новых фич. А в чем проблема, спросите вы, redux прекрасный инструмент, зачем от него отказываться? Проблема в том, что философия глобальности redux побудила команду писать код, который постепенно превратился в неподдерживаемое нечто. Вообще, конечно, странная штука — глобальные переменные испокон веков считались анти паттерном, но redux, который по сути является глобальным объектом, обрел такую популярность и повсеместное использование. Но это так, мысли вслух.

Вторая проблема redux, которую мы ощутили на себе — он из коробки плохо переиспользуется. Возможно, это следствие его глобальной природы.

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

Заглянем в код нашего легаси приложения

const slices = {
  slice1: slice1Reducer,
  slice2: slice2Reducer,
  slice3: slice3Reducer,
  slice4: slice4Reducer,
  slice5: slice5Reducer,
  slice6: slice6Reducer,
  slice7: slice7Reducer,
  slice8: slice8Reducer,
  slice9: slice9Reducer,
  slice10: slice10Reducer,
  …
};

…

 
const combinedReducer = combineReducers(slices);
const store = createStore(combinedReducer);

Мне всегда этот код доставлял сильную боль. Каждый раз, как мы добавляем в продукт новую фичу, с новым редьюсером, нам нужно идти в этот глобальный список редьюсеров и прописывать его. Новая фича становится чуть более размазанной.

А было бы круто вообще не иметь этот глобальный список редьюсеров вовсе, а при добавлении новой фичи в продукт каким-то образом динамически добавлять ее редьюсер в систему.  Идея не нова, уже есть ряд библиотек, которые решают подобную проблему. Но несложно раскрутить свое решение — в дальнейшем оно может чуть менее болезненно поддаваться кастомизации. Далее увидим, какой именно. Так мы и сделали. И у нас получилась библиотека под названием redux-attachable-reducer.

С вашего разрешения, позволю себе пару слов о том, как пользоваться этой библиотекой и как она устроена.

Когда мы пилим новую фичу, у которой есть свой редьюсер, не нужно идти его регистрировать в глобальном списке редьюсеров, достаточно сделать так (путь, куда аттачить редьюсер, задается в виде строки):

import { attachReducer } from "redux-attachable-reducer";
const Component = props => {
  ...
}

export default attachReducer({"path.to.store.key": reducer})( Component)

или так (путь, куда аттачить редьюсер, задается в виде объекта):

import { attachReducer } from "redux-attachable-reducer";
const Component = props => {
  ...
}

export default attachReducer({path: { to: { store: { key: reducer }} })( Component)

или в виде хука:

const Component = () => {	
    useAttachReducer(({"path.to.store.key": reducer})
   …
}

Как оно работает? Представим себе, что мы динамически аттачим несколько редьюсеров:

{"one": r1, "one.two": r2, "one.three": r3}

Внутри себя библиотека строит дерево, наподобие того, что показано на картинке ниже:

7c118de75105073a3c065d609e19d7ae.png

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

const reducer = combineReducers(
{
  one: reduceReducers(
    r1,
    combineReducers(
      {
        two: r2,
        three: r3
      }
    )
  )
})

Его мы комбинируем со статическими редьюсерами (теми редьюсерами, которые мы передаем в качестве первого аргумента в createStore).

Итоговый скомбинированный редьюсер затем передается в функцию replaceReducer, которая есть у redux-стора. (https://redux.js.org/api/store)

Все.  Ушли длинные списки редьюсеров, которые было необходимо комбинировать друг с другом. Redux стал чуть более модульным, каждая фича приложения — чуть более изолированной.

Теперь нам бы добавить чуть большей реюзабельности нашему любимому redux.

Давайте на простом примере рассмотрим, как нам может в этом помочь redux-attachable-reducer и всем известный паттерн проектирования под названием фабрика.

Классический пример — компонент инкрементирования счетчика с redux в качестве state менеджера. Понятно, что вы можете сказать, зачем тут вообще state менеджер, и будете правы.

const Counter = () => {
  const [value, setValue] = useState(0);
  const handleClick = () => {
    setValue(v => v + 1);
  };
  return (
    <>
      
{value}
); };

Добавим два компонента Counter на страницу — все работает, нет никаких проблем.

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

Заменим теперь локальный state на redux. В упрощенном виде это будет выглядеть примерно так:

const Counter = () => {
  const dispatch = useDispatch();
  const value = useSelector(state => state.features.counter.value);
  const handleClick = () => {
    dispatch({ type: "INCREMENT" });
  };

  return (
    <>
      
{value}
); };

К нему мы динамически аттачим редьюсер:

export default attachReducer({
  "features.counter": reducer
})(Counter);
const reducer = (state = { value: 0 }, action) => {
  switch (action.type) {
    case "INCREMENT":
      return {
        ...state,
        value: state.value + 1
      };
    default:
      return state;
  }
};

И все сломалось.

Если мы на страницу добавим два каунтера, то они не будут изолированными — клик на кнопке в одном компоненте будет приводить к обновлению значения не только в самом этом компоненте, а еще и в соседнем — оба они смотрят в одно место redux-стора.

Нам же нужно, чтобы в сторе было два отдельных изолированных слайса под оба компонента, и чтобы клик на кнопках этих компонентов приводил к генерации разных экшенов. 

С точки зрения клиентского кода нам показалось, что было бы естественно, если бы он выглядел как-то так:


За счет пропса namespace каким-то образом мы хотим изолировать наши компоненты — создать разные редьюсеры для них и заставить их диспатчить разные экшены.

attachReducer умеет такое.

export default attachReducer((attach, props) => {
  const { namespace } = props;
  const actionTypes = createActionTypes(namespace);
  const reducer = createReducer(actionTypes);
  attach({ [`features.${namespace}`]: reducer });
  const useHook = createUseHook({ actionTypes, namespace });
  return { useHook };
})(Counter);

Сигнатура attachReducer, которую мы видели ранее, предполагала, что мы знаем, в какое место стора аттачить редьюсер. В нашем же случае картинка несколько иная — мы должны динамически принять решение об этом в зависимости от того, какое значение нам пришло в качестве пропса namespace.

attachReducer может в качестве конфигурации принимать не только объект, но еще и функцию, в которой мы можем динамически решить, куда и что аттачить. Вот схема того, что происходит  внутри переданной в attachReducer функции

59e28c575a07a199690de3cf96c255c8.png

В этой функции мы:

1) Получаем namespace из пропсов

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

2)	const createActionTypes = namespace => {
3)	  return {
4)	    INCREMENT: `${namespace}.INCREMENT`
5)	  };
6)	};

3) Создаем редьюсер с помощью фабрики, передавая ей экшн тайпы из предыдущего шага

const createReducer = actions => {
  const reducer = (state = { value: 0 }, action) => {
    switch (action.type) {
      case actions.INCREMENT:
        return {
          ...state,
          value: state.value + 1
        };
      default:
        return state;
    }
  };
  return reducer;
};

4) Аттачим созданный редьюсер в нужное место стора, определяемое на базе все того же namespace. За счет этого мы изолируем данные двух фич друг от друга

5) Создаем хук, с помощью которого наш компонент будет взаимодействовать с нужным слайсом стора и генерить нужные экшены

const createUseHook = ({ actionTypes, namespace }) => {
  return () => {
    const dispatch = useDispatch();
    const value = useSelector(state => state.features[namespace].value);
    const onClick = () => {
      dispatch({ type: actionTypes.INCREMENT });
    };
    return { value, onClick };
  };
};

6) Возвращаем этот хук наружу. attachReducer передаст этот хук в качестве пропса в компонент Counter

Сам компонент так же претерпел изменения — он не смотрит в конкретное место стора и не генерит конкретные экшены. В него инъектится хук, абстрагируя эти вопросы:

const Counter = ({ useHook }) => {
  const { value, onClick } = useHook();
  return (
    <>
      
{value}
); };

Все починилось. Теперь у нас компоненты счетчиков работают изолированно, один не влияет на другой. Redux стал чуть более реюзабельным за счет redux-attachable-reducer и за счет применения паттерна фабрики

В последней версии кода компонента Counter кого-то может смутить наличие хука в качестве пропса. Это нормально. До тех пор, пока мы не нарушаем правила хуков.

Есть неплохая статья по этой теме — вот

Казалось бы, все так просто, но неожиданно это сильно улучшило developer experience в нашем продукте.

Помимо этого, мы реанимируем наш продукт, переписывая его с использованием FSD, что делает его еще более слабосвязанным и модульным, но это уже совсем другая история…

© Habrahabr.ru