[Из песочницы] Опыт использования redux без редьюсеров

nnatcxzguqw8cg7b1nx315f3c8y.jpeg

Хотел бы поделиться своим опытом использования redux в enterprise приложении. Говоря о корпоративном ПО в рамках статьи, я акцентирую внимание на следующих особенностях:

  • Во-первых, это объем функционала. Это системы, которые разрабатываются по много лет, продолжая наращивать новые модули, либо до бесконечности усложняя то, что уже есть.
  • Во-вторых, зачастую, если мы рассматриваем не презентационный экран, а чье то рабочее место, то на одной странице может быть смонтировано огромное количество привязанных компонент.
  • В-третьих, сложность бизнес-логики. Еcли мы хотим получить отзывчивое и приятное в использовании приложение, значительную часть логики придется делать клиентской.


Первые два пункта накладывают ограничения по запасу производительности. Об этом чуть позже. А сейчас, предлагаю обсудить проблемы, с которыми сталкиваешься, используя классический redux — workflow, разрабатывая что либо, сложнее чем TODO — list.

Классический redux


Для примера, рассмотрим следующее приложение:

image

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

Организация кода:

image

Есть два модуля. Точнее, один непосредственно модуль — poemScoring. И корень приложения с общими для всей системы функциями — app. Там у нас есть информация о пользователе, отображение сообщений пользователю. У каждого модуля свои редьюсеры, экшены, контролы и т. д. По мере роста приложения множатся новые модули.

Каскадом редьюсеров, с помощью redux-immutable, формируется следующий полностью immutable стейт:

image

Как это работает:

1. Контрол диспатчит action-creator:

import at from '../constants/actionTypes';

export function poemTextChange(text) {
  return function (dispatch, getstate) {
    dispatch({
      type: at.POEM_TYPE,
      payload: text
    });
  };
}


Константы типов действий вынесены в отдельный файл. Во-первых, мы так страхуемся от опечаток. Во-вторых, нам будет доступен intellisense.

2. Затем это приходит в редьюсер.

import logic from '../logic/poem';

export default function poemScoringReducer(state = Immutable.Map(), action) {
  switch (action.type) {
    case at.POEM_TYPE:
      return logic.onType(state,  action.payload);
    default:
      return state;
  }
}


Обработка логики вынесена в отдельную case-функцию. Иначе, код редьюсера быстро станет нечитаемым.

3. Логика обработки нажатия, с использованием лексического анализа и искусственного интеллекта:

export default {
  onType(state, text) {
    return state
      .set('poemText', text)
      .set('score', this.calcScore(text));
  },

  calcScore(text) {
    const score = Math.floor(text.length / 10);
    return score > 5 ? 5 : score;
  }
};


В случае с кнопкой «New poem», мы имеем следующий action-creator:

export function newPoem() {
  return function (dispatch, getstate) {
    dispatch({
      type: at.POEM_TYPE,
      payload: ''
    }); 
    dispatch({
      type: appAt.SHOW_MESSAGE,
      payload: 'You can begin a new poem now!'
    }); 
  };
}


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

Проблемы:


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

Что будем делать:

  • во вводимом тексте необходимо заменять все некультурные слова на *censored*
  • помимо этого, если пользователь вбил грязное слово, нужно предупредить его сообщением о том, что он поступает неправильно.


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

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

export default {
  onType(state, text) {
    const { reductedText, censoredWords } = this.redactText(text);

    const newState = state
      .set('poemText', reductedText)
      .set('score', this.calcScore(reductedText));

    return {
      newState,
      censoredWords
    };
  },


  calcScore(text) {
    const score = Math.floor(text.length / 10);
    return score > 5 ? 5 : score;
  },

  redactText(text) {
    const result = { reductedText:text };
    const censoredWords  = [];
    obscenseWords.forEach((badWord) => {
      if (result.reductedText.indexOf(badWord) >= 0) {
        result.reductedText = result.reductedText.replace(badWord, '*censored*');
        censoredWords.push(badWord);
      }
    });
    if (censoredWords.length > 0) {
      result.censoredWords = censoredWords.join(' ,');
    }
    return result;
  }
};


Давайте теперь ее применим. Но как? В редьюсере нам ее вызывать больше смысла нет, т. к. текст и оценку мы в стейт положим, а что делать с сообщением? Чтобы отправить сообщение, нам, в любом случае, придется диспатчить соответствующее действие. Значит, дорабатываем action-creator.

export function poemTextChange(text) {
  return function (dispatch, getState) {
    const globalState = getState();
    const scoringStateOld =  globalState.get('poemScoring'); // Получаем из глобального стейта нужный нам участок
    const { newState, censoredWords }  = logic.onType(scoringStateOld, text);

    dispatch({  // отправляем в редьюсер на установку обновленного стейта
      type: at.POEM_TYPE,
      payload: newState
    });

    if (censoredWords) { // Если были цензурированы слова, то показываем сообщение
      const userName = globalState.getIn(['app', 'account', 'name']);
      const message = `${userName}, avoid of using word ${censoredWords}, please!`;
      dispatch({
        type: appAt.SHOW_MESSAGE,
        payload: message
      });
    }
  };
}


Еще требуется, доработать редьюсер, т. к. он функцию логики больше не вызывает:

  switch (action.type) {
    case at.POEM_TYPE:
      return action.payload; 
    default:
      return state;


Что получилось:

image

А теперь, встает вопрос. Зачем нам нужен редьюсер, который, по значительной части действий, будет просто возвращать payload вместо нового стейта? Когда появятся другие действия, которые обрабатывают логику в экшене, надо будет регистрировать новый тип action-type? Или, может быть создать один общий SET_STATE? Наверное, нет, ведь тогда, в инспекторе будет неразбериха. Значит будем плодить однотипные case-ы?

Суть проблемы в следующем. Если обработка логики затрагивает работу с куском стейта, за который ответственны несколько редьюсеров, то приходится писать всякие извращения. Например, промежуточные результаты case-функций, которые необходимо потом с помощью нескольких действий, раскидывать по разным редьюсерам.

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

Давайте, посмотрим на ситуацию еще с одной стороны. Мы в нашем экшене получаем кусок стейта из глобального. Это необходимо, чтобы провести его мутацию (globalState.get ('poemScoring'); ). Получается, мы уже в экшене знаем, с каким куском стейта идет работа. У нас есть новый кусок стейта. Мы знаем куда его положить. Но вместо того, чтобы положить его в глобальный, мы запускаем его с какой-то текстовой константой по всему каскаду редьюсеров, чтобы он прошел по каждому switch-case, и подставился один единственный раз. Меня от осознания этого, корёбит. Я понимаю, что это сделано для простоты разработки и уменьшения связности. Но в нашем случае, это уже не имеет роли.

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

  1. Значительные неудобства при работе со стейтом, находящимся за пределами редьюсера.
  2. Проблема разделения кода. Каждый раз, когда мы диспатчим экшен, он проходит по каждому редьюсеру, проходит по каждому case-у. Это удобно, не заморачиваться, когда у вас приложение небольшое. Но, если у вас монстр, которого строили несколько лет с десятками редьюсеров и сотнями case-ов, то я начинаю задумываться о целесообразности подобного подхода. Возможно, даже с тысячами кейсов, это не сможет оказать существенного влияния на быстродействие. Но, понимая, что при печати текста, каждое нажатие, будет вызывать за собой проход по сотням case-ов, я не могу оставить это так как есть. Любой, самый маленький лаг, помноженный на бесконечность, стремится к бесконечности. Иными словами, если не думать о таких вещах, рано или поздно, проблемы появятся.

    Какие есть варианты?

    a. Изолированные приложения с собственными провайдерами. Придется дублировать в каждом модуле (подприложении) общие части стейта (аккаунт, сообщения, и т.п.).

    b. Использовать подключаемые асинхронные редьюсеры. Это не рекомендуется самим Даном.

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

  3. Каждый раз при диспатче экшена, происходит не только прогон по каждому редьюсеру, но и сбор обратного состояния. Не важно, менялось ли состояние в редьюсере — оно будет заменено в combineReducers.
  4. Каждый диспатч заставляет обрабатывать mapStateToProps у каждого привязанного компонента, что смонтирован на странице. Если мы дробим редьюсеры, нам приходится дробить диспатчи. Критично ли это, что у нас по кнопке затирание текста и вывод сообщения происходит разными диспачтами? Наверное, нет. Но у меня есть опыт оптимизации, когда снижение количества диспатчей с 15 до 3 позволило существенно увеличить отзывчивость системы, при неизменном количестве обрабатываемой бизнес логики. Я знаю, что есть библиотеки, которые умеют объединять несколько диспатчей в один батч, но это же борьба со следствием с помощью костылей.
  5. При дроблении диспатчей, порой очень сложно посмотреть, что же все-таки происходит. Нет одного места, все разбросано по разным файлам. Искать, где реализована обработка, приходится через поиск констант по всем исходникам.
  6. В приведенном коде, компоненты и экшены обращаются напрямую к глобальному стейту:
    const userName = globalState.getIn(['app', 'account', 'name']);
    …
    const text = state.getIn(['poemScoring', 'poemText']);
    

    Это нехорошо по нескольким причинам:

    a. Модули, в идеале, должны быть изолированными. Они не должны знать в каком месте стейта они живут.

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

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

    — Зарегистрировать константу экшен-тайпа
    — Написать экшен-крейтор
    — В контроле импортировать его и прописать в mapDispatchToProps
    — Прописать в PropTypes
    — Создать в контроле handleCheckBoxClick и указать его в чек боксе
    — Дописать свитч в редьюсере с вызовом case-функции
    — Написать в логике case-функцию

    Ради одного чек бокса!

  8. Стейт, который генерируется с помощью combineReducers — статичный. Не важно, заходили вы в модуль B уже или нет, данный кусок будет в стейте. Пустой, но будет. Не удобно пользоваться инспектором, когда в стейете куча неиспользуемых пустых узлов.


Как мы пытаемся решать часть из вышеописанных проблем


Итак, у нас получились бестолковые редьюсеры, а в экшен-крейторах / логике мы пишем портянки кода для работы с глубоко вложенным immutable — структурами. Чтобы избавить от этого, я использую механизм иерархических селекторов, которые позволяют произвести не только доступ к нужному куску стейта, но и провести его замену (удобный setIn). Я опубликовал это в пакете immutable-selectors.

Давайте, на нашем примере разберем как это работает (репозиторий):
Опишем в модуле poemScoring, объект селекторов. Мы описываем те поля из стейта, к которым мы хотим иметь непосредственный доступ на чтение/запись. Допускается любая вложенность и параметры для доступа к элементам коллекций. Не обязательно описывать все возможные поля в нашем стейте.

import extendSelectors from 'immutable-selectors';

const selectors = {
  poemText:{},
  score:{}
};

extendSelectors(selectors, [ 'poemScoring' ]);

export default selectors;


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

image

Что из себя представляет наш объект — селектор после его расширения:

image

Функция selectors.poemText (state) просто выполняет state.getIn (['poemScoring', 'poemText']).

Функция root (state) — получает 'poemScoring'.

Каждый селектор имеет свою функцию replace (globalState, newPart), которая через setIn возвращает новый глобальный стейт с заменённой соответствующей части.

Так же, добавляется объект flat, в который дублируются все уникальные ключи селектора. То есть, если у нас будет использоваться глубокий стейт вида

selectors = {
  dive:{
    in:{
      to:{
        the:{
          deep:{}
}      }    }  }}


То получить deep можно как selectors.dive.in.to.the.deep (state) или как selectors.flat.deep (state).

Идем дальше. Нам нужно обновить получение данных в контролах:

Poem:

function mapStateToProps(state, ownprops) {
  return {
    text:selectors.poemText(state) || ''
  };
}

Score:

function mapStateToProps(state, ownprops) {
  const score = selectors.score(state);
  return {
    score
  };
}


Далее, меняем корневой редьюсер:

import initialState from './initialState';

function setStateReducer(state = initialState, action) {
  if (action.setState) {
    return action.setState;
  } else {
    return state;
    // return combinedReducers(state, action); //
  }
}

export default setStateReducer;


При желании можем комбинировать с использованием combineReducers.

Экшен-крейтор, на примере poemTextChange:

export function poemTextChange(text) {
  return function (dispatch, getState) {
    dispatch({
      type: 'Poem typing',
      setState: logic.onType(getState(), text),
      payload: text
    });
  };
}


Константы action-type можем больше не использовать, т. к. type у нас теперь используется только для визуализации в инспекторе. Мы в проекте пишем полнотекстовые описания действия на русском. От payload можно так же избавиться, но я стараюсь его сохранять, чтобы в инспекторе, при необходимости, понимать с какими параметрами вызывалось действие.

И, собственно, сама логика:

 onType(gState, text) {
    const { reductedText, censoredWords } = this.redactText(text);

    const poemState  = selectors.root(gState) || Immutable.Map(); // извлечение нужного куска стейта

    const newPoemState = poemState  // мутация
      .set('poemText', reductedText)
      .set('score', this.calcScore(reductedText));

    let newGState =  selectors.root.replace(gState, newPoemState);  // создание нового стейта

    if (censoredWords) {   // если требуется, стейт дополняем сообщением
      const userName = appSelectors.flat.userName(gState);
      const messageText = `${userName}, avoid of using word ${censoredWords}, please!`;
      newGState = message.showMessage(newGState, messageText);
    }

    return newGState;
  },


При этом, message.showMessage импортируется из логики соседнего модуля, в котором описаны свои селекторы:

  showMessage(gState, text) {
    return selectors.message.text.replace(gState, text);
  }.


Что получается:

image

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

Как еще это можно применять?


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

        
        


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

Ограничения при использовании immutable-selectors:

Не получится использовать ключ в состоянии «name», т. к. для родительской функции будет попытка переопределить зарезервированное свойство.

Что в итоге


В итоге, получился довольно гибкий подход, исключены неявные связи кода по текстовым константам, уменьшены накладные расходы при сохранении удобства разработки. Так же остался полность функционирующий redux inspector с возможностью time travel. У меня желания возвращаться на стандартные редьюсеры нет никакого.

В общем-то, все. Благодарю за уделенное время. Может быть, кому-то будет интересно опробовать!

© Habrahabr.ru