Приветствую, сегодня я собираюсь поговорить с вами о способе организации Reducer'a. И рассказать с чего я начал и к чему пришел.
Итак, есть некий стандарт по организации Reducer и выглядит он следующим образом:
export default function someReducer(state = initialState, action) {
switch (action.type) {
case 'SOME_REDUCER_LABEL':
return action.data || {};
default:
return state;
}
}
Тут все просто и понятно, но немного поработав с такими конструкциями я понял что данный метод имеет ряд сложностей.
- Метки надо как то хранить, потому что они начали расползаться по проекту и уползать далеко за пределы контроллеров.
- Метки надо было делать уникальными, потому что иначе могло быть пересечение с другими reducer'ами
- Большая часть времени при работе с такой структурой тратилась на организацию кода, нежели на обработку входящих данных
- И когда меток в reducer набирается много — код становиться неряшливым и трудно читаемым, ну и общее пространство имен меня откровенно не радовало.
Примерно в это же время, для обработки сайд эффектов мы стали применять саги. Это позволило нам значительно облегчить общение с серверной частью без использования колбеков.
Теперь нам надо было дать знать саге, какой reducer надо было вызвать, после того, как отработает сайдэффект.
Самый разумный вариант, который я нашел, это сделать action creator.
И наш предидущий код стал выглядеть вот так:
import { FetchSaga } from '../../helpers/sagasHelpers';
const SOME_REDUCER_LABEL = 'SOME_REDUCER_LABEL';
export const someReducerLabelActionCreator = FetchSaga.bind(this, SOME_REDUCER_LABEL);
export default function someReducer(state = initialState, action) {
switch (action.type) {
case SOME_REDUCER_LABEL:
return action.data || {};
default:
return state;
}
}
FetchSaga — это функция-генератор action (далее action creator) для саги, которая запрашивает данные с сервера и диспатчит их в reducer, метка которого была передана функции на этапе инициализации(SOME_REDUCER_LABEL).
Теперь, метки reducer'а либо экспортировались из reducer'а, либо из reducer'а экспортировался action creator как для саги так и типовой. Причем такой обработчик создавался на каждую метку. Это лишь добавило головной боли, потому что однажды открыв reducer я насчитал 10 констант определяющих метки, потом несколько вызовов для различных action creator для саг и потом еще и функцию обработки состояния reducer'а, выглядело это примерно вот так
import { FetchSaga } from '../../helpers/sagasHelpers';
const SOME_REDUCER_LABEL1 = 'SOME_REDUCER_LABEL1';
итд
....
const SOME_REDUCER_LABEL10 = 'SOME_REDUCER_LABEL10';
export const someReducerLabelActionCreator1 = FetchSaga.bind(this, SOME_REDUCER_LABEL1);
и тд
.....
export const someReducerLabelActionCreator10 = FetchSaga.bind(this, SOME_REDUCER_LABEL10);
export default function someReducer(state = initialState, action) {
switch (action.type) {
case SOME_REDUCER_LABEL: return action.data || {};
case SOME_REDUCER_LABEL1: return action.data || {};
case SOME_REDUCER_LABEL2: return action.data || {};
case SOME_REDUCER_LABEL3: return action.data || {};
....
default:
return state;
}
}
При импорте всех этих actionов в контроллер тот тоже нехило так раздувался. И это мешало.
Просмотрев так несколько reducer'ов, я прикинул что мы пишем много служебного кода, который никогда не меняется. Плюс мы должны следить за тем, что отправляем в компонент клонированное состояние.
Тогда у меня родилась идея стандартизировать reducer. Задачи перед ним стояли не сложные.
- Проверять входящий action и возвращать старое состояние, если action не для текущего reducer'а или автоматически клонировать state и отдавать в метод-обработчик, который изменить состояние и отдаст в компонент.
- Следует перестать оперировать метками, вместо этого контроллер должен получать объект содержащий все action creators для интересующего нас reducer'а.
Таким образом импортировав такой набор один раз, я смогу прокидовать через него любое количество action creators для dispatch функции из reducer'а в контроллер без необходимости повторного импорта - вместо использование корявого switch-case с общим пространством имен, на который материться линтер, я хочу иметь отдельный метод, для каждого actionа, в который будет передано уже клонированное состояние reducer'а и сам action
- неплохо бы иметь возможность наследовать от reducer'а новый reducer. На случай повторения логики, но например для другого набора меток.
Идея показалась мне жизнеспособной и я решил попробовать это реализовать.
Вот как стал выглядеть среднестатистический reducer теперь
// это наш стандартизированный класс, потомок которого будет управлять состоянием в данном reducer'е
import stdReducerClass from '../../../helpers/reducer_helpers/stdReducer';
class SomeReducer extends stdReducerClass {
constructor() {
super();
/**
Уникальный идентифактор reducer'а. По которому reducer будет узначать свои actionы, которые он же породил
*/
this.prefix = 'SOME_REDUCER__';
}
/**
декларация набора методов, которыми может оперировать данный reducer
- type - тип, он выполняет двойную функцию. Во-первых при соединении с префиксом мы получим конечную метку, которая будет передана в action creator, например SOME_REDUCE__FETCH.
Так же type являться ключом по которому можно отыскать нужный action creator в someReduceInstActions
- method - Метод, который примет измененное состояние и action, выполнить какие то действия над ним и вернет состояние в компонент
- sagas - это не обязательный параметр, который указывает классу, какой тип сайд эффекта следует выполнить сначала. В случае представленном ниже, будет создан action creator для саги, куда будет автоматически добавлена метка SOME_REDUCE__FETCH,
После того, как сага отработает, она отправит полученные данные в reducer используя переданную ранее метку.
*/
config = () => [
{ type: 'fetch', method: this.fetch, saga: 'fetch' },
{ type: 'update', method: this.update },
];
// получаем конфигурацию методов и генерируем на их основе нужные нам action creators
init = () => this.subscribeReduceOnActions(this.config());
// реализация обработчика, которые примет данные от саги
fetch = (clone, action) => {
// какие то действия над клонированным состоянием
return clone;
};
// реализация обработчика, которые просто что то сделает с клонированным состоянием
update = (clone, action) => {
// какие то действия над клонированным состоянием
return clone;
};
}
const someReducerInst = new SomeReducer();
someReducerInst.init(); // генерируем список action creators на основе config
// получаем список созданных action creator для дальнейшего использования в контроллерах
export const someReducerInstActions = someReducerInst.getActionCreators();
// вешаем проверку на состояния. Каждый раз checkActionForState будет проверять входящий Action и определять, относится ли он к данному reducer'у или нет
export default someReducerInst.checkActionForState;
stdReducerClass изнутри выглядит следующим образом
import { cloneDeep } from 'lodash'; //для клонирования используется зависимость lodash
// так же я импортирую саги непосредственно в родителя, так как они типовые и нет смысла переопределять их каждый раз
import { FetchSaga } from '../helpers/sagasHelpers/actions';
export default class StdReducer {
_actions = {};
actionCreators = {};
/** UNIQUE PREFIX BLOCK START */
/**
префикс мы храним в нижнем регистре, для единообразия. Как уже говорилось, это важный элемент, если него не указывать,
то reducer не распознает свои actionы или все они будут ему родными
*/
uniquePrefix = '';
set prefix(value) {
const lowedValue = value ? value.toLowerCase() : '';
this.uniquePrefix = lowedValue;
}
get prefix() {
return this.uniquePrefix;
}
/** INITIAL STATE BLOCK START */
/**
используя сеттер initialState можно указать начальное состояние для reducer'а.
*/
initialStateValues = {};
set initialState(value) {
this.initialStateValues = value;
}
get initialState() {
return this.initialStateValues;
}
/** PUBLIC BLOCK START */
/**
* Тот самый метод который вызывается при в init() потомка. Данный метод создает, для каждой записи в массиве Config, action creator используя метод _subscribeAction
* actionsConfig - список настроек определенных в потомке, где каждая запись содержит {type, method, saga?}
если не указан параметр сага, то будет создан стандартный action creator который будет ожидать на вход объект с произвольными свойствами
*/
subscribeReducerOnActions = actionsConfig => actionsConfig.forEach(this._subscribeAction);
/**
Для каждой настройки вызывается метод _subscribeAction, который создает два набора, где ключом является имя метки переданное в type. Таким образом, reducer будет определять, какой метод является обработчиком для текущего actionа.
*/
_subscribeAction = (action) => {
const type = action.type.toLowerCase();
this._actions[type] = action.method; // добавляем метод в набор обработчиков состояний
this.actionCreators[type] = this._subscribeActionCreator(type, action.saga); // добавляем новый action creator в набор
}
/**
_subscribeActionCreator - данный метод определяет, action creator какого типа должен быть создан на основе полученной конфигурации
- если параметр saga не указан в конфигурации, то будет создан по умолчанию
- если указан fetch то будет вызвана сага для отправки и получения данных по сети, а результат вернется в обработчик по переданной метке
Метод соединяет переданный ему type из конфига с префиксом, и получает метку, которую передает в action creator, то есть, если префикс имел вид SOME_Reducer__, а тип в конфиге содержал FETCH, то в результате мы получим SOME_Reducer__FETCH, это и отправиться в action creator
*/
_subscribeActionCreator = (type, creatorType) => {
const label = (this.prefix + type).toUpperCase();
switch (creatorType) {
case 'fetch': return this._getFetchSaga(label);
default: return this._getActionCreator(label);
}
}
/**
_getFetchSaga - привязывает нашу метку к саге, чтобы она понимала по какому адресу отправлять конечные данные
*/
_getFetchSaga = label => FetchSaga.bind(this, label);
/**
_getActionCreator - стандартный action creator, с уже зашитой в него меткой, все что нужно, это передать полезную нагрузку.
*/
_getActionCreator = label => (params = {}) => ({
type: label,
...params
});
/**
Это самая главная функция, которая принимает входящее состояние и playload. Она же распознает свои actionы и клонирует состояние, для дальнейшей обработки
*/
checkActionForState = (state = this.initialState || {}, action) => {
if (!action.type) return state;
const type = action.type.toLowerCase();
const prefix = this.prefix;
Из входящего типа мы пытаемся удалить префикс, чтобы получить имя метода, который надо вызвать.
const internalType = type.replace(prefix, '');
// по полученному ключу ищем соответствие в обработчиках
if (this._actions[internalType]) {
// Если такой обработчик есть - создаем клон состояния
const clone = cloneDeep(state);
// запускаем обработчик, передаем ему клонированное состояние, входящий action как есть, а результат выбрасываем наружу
// так как мы обязаны что то вернуть
return this._actions[internalType](clone, action);
}
// если обработчика нет, то этот action не для нас. Можно вернуть старое состояние
return state;
}
/**
Это просто геттер для получения всех action creator, которые доступны для reducer
*/
getActionCreators = () => this.actionCreators;
}
Как же это выглядеть в контроллере? А вот так
import { someReducerInstActions } from '../../../SomeReducer.js'
const mapDispatchToProps = dispatch => ({
doSoAction: (params) => dispatch(someReducerInstActions.fetch(url, params)),
doSoAction1: (value, block) => dispatch(someReducerInstActions.update({value, block})),
});
Итак, что мы имеем в итоге:
- избавились от нагромождения меток
- избавились от кучи импортов в контроллере
- убрали switch-case
- прибили саги один раз и теперь можем расширят их набор в одном месте, будучи уверенными что все наследники автоматически получат дополнительные обработчики сайд эффектов
- Получили возможность наследовать от reducer'ов, в случае если есть смежная логика( на данный момент это мне так и не пригодилось =) )
- Переложили ответственность по клонированию с разработчика на класс, который точно не забудет это сделать.
- стало меньше рутины при создании reducer'а
- Каждый метод имеет изолированное пространство имен
Я старался описать все как можно подробнее =) Извините, если путано, чукча не писатель. Надеюсь что кому нибудь будет полезен мой опыт.
> Действующий пример можно посмотреть тут
Спасибо, что дочитали!
UPD: поправил ошибки. Писал ночью, плохо вычитал. Спасибо что так деликатно на них указали=)
Комментарии (15)
miooim
10.02.2019 23:09Я использую вот такую конструкцию чтоб избавится от бардака:
Акшен
import { LocalesApi } from '../api/locales.api'; class Actions { prefix = 'LOCALES_ACTIONS'; REQUEST = `${this.prefix}_REQUEST`; SUCCESS = `${this.prefix}_SUCCESS`; ERROR = `${this.prefix}_ERROR`; CLEAR = `${this.prefix}_CLEAR`; request = () => ({ type: this.REQUEST, }) success = messages => ({ type: this.SUCCESS, messages, }) clear = () => ({ type: this.CLEAR, }) error = error => ({ type: this.ERROR, error, }) getByLocale = locale => async dispatch => { try { dispatch(this.request()); const result = await LocalesApi.getByLocale(locale); dispatch(this.success(result)); } catch (error) { dispatch(this.error(error.message)); } } } export const LocalesActions = new Actions();й
Редюсер
import { LocalesActions } from '../actions/locales.actions'; import { LOCALES } from '../constants/locales.constants'; const initialState = { shouldInitialize: true, updating: false, locale: LOCALES.SUPPORTED[0], messages: {}, }; export default (state = initialState, action) => { switch (action.type) { case LocalesActions.REQUEST: return { ...state, shouldInitialize: false, updating: true, }; case LocalesActions.SUCCESS:{ const { messages } = action; return { ...state, updating: false, messages, }; } case LocalesActions.ERROR:{ const { error } = action; return { ...state, updating: false, error }; } default: return state; } };
Использование в контейнере
import { connect } from 'react-redux'; import { IntlProviderComponent } from './IntlProvider.component'; import { STORES } from '../constants/stores.constants'; import { LocalesActions } from '../actions/locales.actions'; const mapStateToProps = state => ({ locales: state[STORES.LOCALES], }); const mapDispatchToProps = dispatch => ({ getMessages: locale => dispatch(LocalesActions.getByLocale(locale)), }); export const IntlProvider = connect(mapStateToProps, mapDispatchToProps)(IntlProviderComponent);
Neffes Автор
10.02.2019 23:40Я знал, что не придумал велосипед. Очень боялся, что вся моя идея ошибочна. Но глядя на вашу реализацию, нахожу в ней много общего с моей и это дает надежду, что и моя идея не тупиковая =) Спасибо за коммент, вы очень помогли.
xander27
11.02.2019 06:43Я делал что-то подобное, но только еще селекторы туда добавил
class TestModule extends DuckModule { constructor(prefix, rootSelector) { super(prefix, rootSelector); //define actions here this.INC = this.prefix + "INC"; // action name } /** * Create new action of this.INC type */ inc(x) { return {type: this.INC, payload: x} } /** * Selector for X value * @param state */ getX(state) { return this.getRoot(state).x; } reduce(state = {x: 0}, action) { switch (action.type) { case this.INC: return {x: state.x + action.payload}; } return super.reduce(state, action); } } test("Selectors and actions", () => { expect(module.getX(state)).toBe(0); //... dispatch(module.inc(1)); //... expect(module.getX(state)).toBe(1); });
Из минусов, reselect из коробки прицепить сложно (но впринципе можно самомму организовать альтернативу). Хотел сделать новую версию, но пока отошел от занятия фронтом.
https://www.npmjs.com/package/simple-duck — кому интересно, больше примеров (combineModules, использование наследования)Neffes Автор
11.02.2019 08:56Очень интересный подход. Я как-то зациклился на том, что один reducer обслуживает одну страницу. И есть еще несколько вспомогательных reducer'ов, для виджетов. В вашем случае каждый модуль несет единственную ответственность. Что позволяет их использовать повторно более эффективно. Эту идею стоит обдумать.
xander27
11.02.2019 10:35Про страницы — я использовал другой подход, были такие модули отдельно для GUI и для бизнес логики. И например если в каком-то модуле GUI нужно было вывести значение из бизнес-модуля, то грубо говоря так
import logicModule from '../logic' ///.... class GuiModule extends ViewModule { getSomeLabelText(state) { return doSomeGuiStuff(logicModule.getValue(state)); } }
Neffes Автор
11.02.2019 13:05То есть редьюсер запоминает последние состояние и его можно получить в обход пропсов?
xander27
11.02.2019 19:07Сам редьюсер ничего не запоминает, он получает то что надо через селекторы. тут несколько другая концепацая (общую идею можно почитать тут https://github.com/erikras/ducks-modular-redux ). То есть есть модуль, который включает в себя описание экшенов, редьюсер и селекторы.
Druu
11.02.2019 08:59Вместо конфига можно использовать декораторы методов, тогда не надо указывать ни имя, ни функцию.
kalyukdo
11.02.2019 10:12Велосипеды это круто, они помогают учиться, автор взгляни на MobX, большинство велосипедов пытаются решить одну и туже проблему (булертплейт), там она уже решена
keenondrums
11.02.2019 10:45Одним из лучших вариантов организации редьюсеров через классы мне представляется ngrx-actions. Мне немного не хватало в нем типизации, и т.к. автор забил на проект, то я его переписал. Но, в целом, мысли похожие.
staticlab
Дальше не читал. То, что вы привели, называется редьюсер! А reduce — это функция свёртки массива!
xRevolveRx
Про редакс тоже была хорошая статья. Столько кода и так мало смысла. Когда Вас с рынка выгонят всех, дармоеды?
Apathetic
Зря не читали! Дальше встречаются такие перлы, как, например этот кусок кода:
staticlab
И так 10 раз :)