В этой статье я хотел бы поделиться своим опытом использования связки react-redux и redux-saga, а точнее, какой «велосипед» я использую, для уменьшения количества однотипного кода и упрощению его восприятия.
Библиотеки react-redux и redux-saga просты, гибки и удобны, однако имеют избыточность кода. Основные элементы это:
Давайте по порядку.
В целом, используя эти простые аннотации, саги стали понятнее и чище, уменьшилось количество кода, необходимое для добавление новой логики. Навигация по проекту стала проще.
Эти аннотация я вынес в пакет sweet-redux-saga. Если есть другие решения, буду рад, если поделитесь со мной.
Что меня не устраивало
Библиотеки react-redux и redux-saga просты, гибки и удобны, однако имеют избыточность кода. Основные элементы это:
Фабрики событий
const actionCreatorFactory = type => payload => ({ type, payload }); export const INITIALIZE = 'INITIALIZE'; export const ON_FIELD_CHANGE = 'ON_FIELD_CHANGE'; export const onFieldChange = actionCreatorFactory(ON_FIELD_CHANGE); export const HANDLE_FIELD = 'HANDLE_FIELD'; export const handleField = actionCreatorFactory(HANDLE_FIELD); export const GO_TO_NEXT_STEP = 'GO_TO_NEXT_STEP'; export const goToNextStep = actionCreatorFactory(GO_TO_NEXT_STEP);
В таком виде меня смущает несколько вещей:
— описание типов событий. В этом примере можно конечно обойтись и без констант, но все равно придется передавать его тип в фабрику, которое будет идентично имени созданного события в верблюжей(camelCase) нотификации.
— если вы забыли структуру пайлоада(payload), что бы его вспомнить, надо перейти к reducer/saga, где используется это событие, и посмотреть что там нужно передавать
Редьюсеры
import getInitialState, { formDataInitialState as initialState, } from '../helpers/initialState'; import { HANDLE_FIELD_DONE, ON_FIELD_CHANGE, RESET } from '../actionCreators'; export default (state = initialState, { type, payload }) => { switch (type) { case RESET: { return getInitialState().formDataInitialState; } case ON_FIELD_CHANGE: { const { name } = payload; return { ...state, [name]: '', } } case HANDLE_FIELD_DONE: { const { name, value } = payload; return { ...state, [name]: value, } } } return state; };
Тут в целом напрягает только использование конструкции switchСаги
import { all, put, select, fork, takeEvery } from 'redux-saga/effects'; import { runServerSideValidation } from '../actionCreators'; import { HANDLE_FIELD } from '../actionCreators'; function* takeHandleFieldAction() { yield takeEvery(HANDLE_FIELD, function*({ payload }) { const { validation, formData } = yield select( ({ validation, formData }) => ({ validation: validation[payload.name], formData, }) ); const valueFromState = formData[payload.name]; if (payload.value !== valueFromState) { const { name, value } = payload; const { validator } = validation.serverValidator; yield put( runServerSideValidation({ name, value, validator, formData, }) ); } }); } export default function* rootSaga() { yield all([fork(takeHandleFieldAction())]); }
В сагах самая большая из проблем, сложность чтения. Просто беглым взглядом понять, что делает сага сложно. Одна из причин этому — глубокая вложенность. Можно конечно вынести воркеры, что бы уменьшить глубину, однако тогда увеличивается избыточность.
import { all, put, select, fork, takeEvery } from 'redux-saga/effects'; import { runServerSideValidation } from '../actionCreators'; import { HANDLE_FIELD } from '../actionCreators'; function* takeHandleFieldWorker({ payload }) { const { validation, formData } = yield select( ({ validation, formData }) => ({ validation: validation[payload.name], formData, }) ); const valueFromState = formData[payload.name]; if (payload.value !== valueFromState) { const { name, value } = payload; const { validator } = validation.serverValidator; yield put( runServerSideValidation({ name, value, validator, formData, }) ); } } function* takeHandleFieldWatcher() { yield takeEvery(HANDLE_FIELD, takeHandleFieldWorker); } export default function* rootSaga() { yield all([fork(takeHandleFieldWatcher())]); }
Так же, есть проверки, которые или увеличат вложенность, или добавят точки выхода из саги, что ухудшает наш код.
Как я пытаюсь решить эти проблемы
Давайте по порядку.
Фабрики событий
import { actionsCreator, payload } from 'sweet-redux-saga'; @actionsCreator() class ActionsFactory { initialize; @payload('field', 'value') onFieldChange; @payload('field') handleField; @payload('nextStep') goToNextStep; }
создаем класс с аннотацией actionsCreator(). При создании экземпляра класса, полям не имеющим значения(initialize;/onFieldChange;/handleField;/gpToNextStep;) будет присвоен привычный нам action creator. Если событие содержит данные, имена полей передаем через аннотацию payload(...[fieldNames]). После преобразования предыдущий пример будет выглядеть вот так:
class ActionsFactory { initialize = () => ({ type: 'INITIALIZE', payload: undefined, }); onFieldChange = (field, value) => ({ type: 'ON_FIELD_CHANGE', payload: { field, value, }, }); handleField = field => ({ type: 'HANDLE_FIELD', payload: { field, }, }); goToNextStep = nextStep => ({ type: 'GO_TO_NEXT_STEP', payload: { nextStep, }, }); }
так же у полей будут переопределены методы toString, toPrimitive, valueOf. Они будут возвращать строковое представление типа события:
const actionsFactory = new ActionsFactory(); console.log(String(actionsFactory.onFieldChange)); //Вернет 'ON_FIELD_CHANGE'
Редьюсеры
import getInitialState, { formDataInitialState as initialState, } from '../helpers/initialState'; import { HANDLE_FIELD_DONE, ON_FIELD_CHANGE, RESET } from '../actionCreators'; import { reducer } from '../../../leadforms-gen-v2/src/decorators/ReducerDecorator'; @reducer(initialState) export class FormDataReducer { [RESET]() { return getInitialState().formDataInitialState; } [ON_FIELD_CHANGE](state, payload) { const { name } = payload; return { ...state, [name]: '', }; } [HANDLE_FIELD_DONE](state, payload) { const { name, value } = payload; return { ...state, [name]: value, }; } }
создаем класс с аннотацией reducer([initialState]). При создании экземпляра класса, на выходе получится функция принимающая состояние и экшен, и возвращающая результат обработки экшена.
function reducer(state = initialState, action) { if (!action) { return state; } const reducer = instance[action.type]; if (reducer && typeof reducer === 'function') { return reducer(state, action.payload); } return state; }
Саги
import { all, put, select, } from 'redux-saga/effects'; import { runServerSideValidation } from '../actionCreators'; import { HANDLE_FIELD } from '../actionCreators'; import { sagas, takeEvery, filterActions } from 'sweet-redux-saga'; @sagas() class MySagas { @takeEvery([HANDLE_FIELD]) @filterActions( ({state, payload }) => state.formData[payload.name] === payload.value ) * takeHandleFieldAction({ payload }) { const { validation, formData } = yield select(({ validation, formData }) => ({ validation: validation[payload.name], formData, })); const { name, value } = payload; const { validator } = validation.serverValidator; yield put( runServerSideValidation({ name, value, validator, formData, }) ); } } export default function* rootSaga() { yield all([ new MySagas(), ]); }
создаем класс с аннотацией sagas(). При создании экземпляра класса получаем генератор функций, вызывающий все поля класс помеченных аннотацией takeEvery([...[actionTypes]]) или takeLatest([...[actionTypes]]) в отдельном потоке:
function* mySagas() { yield all([fork(mySagas.takeHandleFieldAction())]); }
так же с полями можно использовать аннотацию filterActions({ state, type, payload }), в этом случае сага будут вызвана только если функция вернет true.
Заключение
В целом, используя эти простые аннотации, саги стали понятнее и чище, уменьшилось количество кода, необходимое для добавление новой логики. Навигация по проекту стала проще.
Эти аннотация я вынес в пакет sweet-redux-saga. Если есть другие решения, буду рад, если поделитесь со мной.