Приветствую, сегодня я собираюсь поговорить с вами о способе организации 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: поправил ошибки. Писал ночью, плохо вычитал. Спасибо что так деликатно на них указали=)
