Хотел бы поделиться своим опытом использования redux в enterprise приложении. Говоря о корпоративном ПО в рамках статьи, я акцентирую внимание на следующих особенностях:
- Во-первых, это объем функционала. Это системы, которые разрабатываются по много лет, продолжая наращивать новые модули, либо до бесконечности усложняя то, что уже есть.
- Во-вторых, зачастую, если мы рассматриваем не презентационный экран, а чье то рабочее место, то на одной странице может быть смонтировано огромное количество привязанных компонент.
- В-третьих, сложность бизнес-логики. Еcли мы хотим получить отзывчивое и приятное в использовании приложение, значительную часть логики придется делать клиентской.
Первые два пункта накладывают ограничения по запасу производительности. Об этом чуть позже. А сейчас, предлагаю обсудить проблемы, с которыми сталкиваешься, используя классический redux – workflow, разрабатывая что либо, сложнее чем TODO – list.
Классический redux
Для примера, рассмотрим следующее приложение:
Пользователь вбивает стишок – получает оценку своего таланта. Контрол с вводом стиха управляемый и пересчет оценки происходит по каждому изменению. Еще есть кнопка, по которой сбрасывается текст с результатом, и показывается пользователю сообщение, что он может начать с начала. Исходный код в этой ветке.
Организация кода:
Есть два модуля. Точнее, один непосредственно модуль – poemScoring. И корень приложения с общими для всей системы функциями — app. Там у нас есть информация о пользователе, отображение сообщений пользователю. У каждого модуля свои редьюсеры, экшены, контролы и т. д. По мере роста приложения множатся новые модули.
Каскадом редьюсеров, с помощью redux-immutable, формируется следующий полностью immutable стейт:
Как это работает:
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;
Что получилось:
А теперь, встает вопрос. Зачем нам нужен редьюсер, который, по значительной части действий, будет просто возвращать payload вместо нового стейта? Когда появятся другие действия, которые обрабатывают логику в экшене, надо будет регистрировать новый тип action-type? Или, может быть создать один общий SET_STATE? Наверное, нет, ведь тогда, в инспекторе будет неразбериха. Значит будем плодить однотипные case-ы?
Суть проблемы в следующем. Если обработка логики затрагивает работу с куском стейта, за который ответственны несколько редьюсеров, то приходится писать всякие извращения. Например, промежуточные результаты case-функций, которые необходимо потом с помощью нескольких действий, раскидывать по разным редьюсерам.
Аналогичная ситуация, если для работы case-функции нужно информации больше, чем есть в твоем редьюсере – приходится выносить ее вызов в экшен, где есть доступ к глобалстейту, с последующей отправкой нового стейта как payload. А дробить редьюсеры придется в любом случае, если логики в модуле много. И это создает большие неудобства.
Давайте, посмотрим на ситуацию еще с одной стороны. Мы в нашем экшене получаем кусок стейта из глобального. Это необходимо, чтобы провести его мутацию( globalState.get('poemScoring'); ). Получается, мы уже в экшене знаем, с каким куском стейта идет работа. У нас есть новый кусок стейта. Мы знаем куда его положить. Но вместо того, чтобы положить его в глобальный, мы запускаем его с какой-то текстовой константой по всему каскаду редьюсеров, чтобы он прошел по каждому switch-case, и подставился один единственный раз. Меня от осознания этого, корёбит. Я понимаю, что это сделано для простоты разработки и уменьшения связности. Но в нашем случае, это уже не имеет роли.
Теперь, перечислю все пункты, которые мне не нравятся в текущей реализации, если ее придется масштабировать вширь и вглубь неограниченно долгое время:
- Значительные неудобства при работе со стейтом, находящимся за пределами редьюсера.
- Проблема разделения кода. Каждый раз, когда мы диспатчим экшен, он проходит по каждому редьюсеру, проходит по каждому case-у. Это удобно, не заморачиваться, когда у вас приложение небольшое. Но, если у вас монстр, которого строили несколько лет с десятками редьюсеров и сотнями case-ов, то я начинаю задумываться о целесообразности подобного подхода. Возможно, даже с тысячами кейсов, это не сможет оказать существенного влияния на быстродействие. Но, понимая, что при печати текста, каждое нажатие, будет вызывать за собой проход по сотням case-ов, я не могу оставить это так как есть. Любой, самый маленький лаг, помноженный на бесконечность, стремится к бесконечности. Иными словами, если не думать о таких вещах, рано или поздно, проблемы появятся.
Какие есть варианты?
a. Изолированные приложения с собственными провайдерами. Придется дублировать в каждом модуле (подприложении) общие части стейта (аккаунт, сообщения, и т.п.).
b. Использовать подключаемые асинхронные редьюсеры. Это не рекомендуется самим Даном.
c. Использовать экшен-фильтры в редьюсерах. То есть, каждый диспатч сопровождать информацией о том, в какой модуль он направляется. И в корневых редьюсерах модулей, прописать соответствующие условия. Я пробовал. Такого количества непроизвольных ошибок не было ни до ни после. Постоянная путаница с тем куда отправляется экшен.
- Каждый раз при диспатче экшена, происходит не только прогон по каждому редьюсеру, но и сбор обратного состояния. Не важно, менялось ли состояние в редьюсере – оно будет заменено в combineReducers.
- Каждый диспатч заставляет обрабатывать mapStateToProps у каждого привязанного компонента, что смонтирован на странице. Если мы дробим редьюсеры, нам приходится дробить диспатчи. Критично ли это, что у нас по кнопке затирание текста и вывод сообщения происходит разными диспачтами? Наверное, нет. Но у меня есть опыт оптимизации, когда снижение количества диспатчей с 15 до 3 позволило существенно увеличить отзывчивость системы, при неизменном количестве обрабатываемой бизнес логики. Я знаю, что есть библиотеки, которые умеют объединять несколько диспатчей в один батч, но это же борьба со следствием с помощью костылей.
- При дроблении диспатчей, порой очень сложно посмотреть, что же все-таки происходит. Нет одного места, все разбросано по разным файлам. Искать, где реализована обработка, приходится через поиск констант по всем исходникам.
- В приведенном коде, компоненты и экшены обращаются напрямую к глобальному стейту:
const userName = globalState.getIn(['app', 'account', 'name']); … const text = state.getIn(['poemScoring', 'poemText']);
Это нехорошо по нескольким причинам:
a. Модули, в идеале, должны быть изолированными. Они не должны знать в каком месте стейта они живут.
b. Упоминание одних и тех же путей в разных местах многократно чревато не только ошибками/опечатками, но и крайне затрудняет рефакторинг в случае изменения конфигурации глобального стейта, либо изменения способа его хранения.
- Все чаще во время написания нового экшена, у меня возникало впечатление, что я пишу код ради кода. Допустим, мы хотим добавить на страницу чек бокс и отразить его булевое состояние в стейте. Если мы хотим единообразную организацию экшенов / редьюсеров, то нам придется:
— Зарегистрировать константу экшен-тайпа
— Написать экшен-крейтор
— В контроле импортировать его и прописать в mapDispatchToProps
— Прописать в PropTypes
— Создать в контроле handleCheckBoxClick и указать его в чек боксе
— Дописать свитч в редьюсере с вызовом case-функции
— Написать в логике case-функциюРади одного чек бокса!
- Стейт, который генерируется с помощью combineReducers — статичный. Не важно, заходили вы в модуль B уже или нет, данный кусок будет в стейте. Пустой, но будет. Не удобно пользоваться инспектором, когда в стейете куча неиспользуемых пустых узлов.
Как мы пытаемся решать часть из вышеописанных проблем
Итак, у нас получились бестолковые редьюсеры, а в экшен-крейторах / логике мы пишем портянки кода для работы с глубоко вложенным immutable – структурами. Чтобы избавить от этого, я использую механизм иерархических селекторов, которые позволяют произвести не только доступ к нужному куску стейта, но и провести его замену (удобный setIn). Я опубликовал это в пакете immutable-selectors.
Давайте, на нашем примере разберем как это работает (репозиторий):
Опишем в модуле poemScoring, объект селекторов. Мы описываем те поля из стейта, к которым мы хотим иметь непосредственный доступ на чтение/запись. Допускается любая вложенность и параметры для доступа к элементам коллекций. Не обязательно описывать все возможные поля в нашем стейте.
import extendSelectors from 'immutable-selectors';
const selectors = {
poemText:{},
score:{}
};
extendSelectors(selectors, [ 'poemScoring' ]);
export default selectors;
Далее, метод extendSelectors превращает каждое поле в нашем объекте в функцию- селектор. Вторым параметром указывает путь до той части стейта, которой управляет селектор. Мы не создаем новый объект, а изменяем текущий. Это дает нам бонус в виде рабочего интеллисенса:
Что из себя представляет наш объект – селектор после его расширения:
Функция 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);
}.
Что получается:
Обратим внимание, что у нас прошел один диспатч, поменялись данные в двух модулях.
Все это позволило нам избавиться от редьюсеров и констант экшен-тайпов, а также решить или обойти большинство из обозначенных выше узких мест.
Как еще это можно применять?
Данный подход удобно использовать, когда необходимо добиться, чтобы ваши контролы или модули обеспечивали работу с разными кусками стейта. Допустим, нам мало одной поэмы. Мы хотим, чтобы пользователь мог на двух параллельных вкладках сочинять поэмы по разным дисциплинам (детская, романтичная). В таком случае мы можем не импортировать селекторы в логике / контролах, а указать их параметром во внешнем контроле:
<Poem selectors = {selectors.сhildPoem}/>
<Poem selectors = {selectors.romanticPoem}/>
И, далее, передавать данный параметр в экшен-крейторы. Этого достаточно, чтобы сделать полностью замкнутой сложную комбинацию компонент и логики, что позволит легко ее использовать повторно.
Ограничения при использовании immutable-selectors:
Не получится использовать ключ в состоянии «name», т. к. для родительской функции будет попытка переопределить зарезервированное свойство.
Что в итоге
В итоге, получился довольно гибкий подход, исключены неявные связи кода по текстовым константам, уменьшены накладные расходы при сохранении удобства разработки. Так же остался полность функционирующий redux inspector с возможностью time travel. У меня желания возвращаться на стандартные редьюсеры нет никакого.
В общем-то, все. Благодарю за уделенное время. Может быть, кому-то будет интересно опробовать!
Автор: dmitrii-khr