Описание действий и редьюсеров одна из таких особенностей. Классическая реализация двух этих сущностей в коде довольно трудоемкое занятие.
Боль классической реализации
Простой пример:
// actionTypes.js
// описываем типы действий
export const POPUP_OPEN_START = 'POPUP_OPEN_START ';
export const POPUP_OPEN_PENDING = 'POPUP_OPEN_PENDING ';
export const POPUP_OPEN_SUCCESS = 'POPUP_OPEN_SUCCESS ';
export const POPUP_OPEN_FAIL = 'POPUP_OPEN_FAIL';
export const POPUP_CLOSE_START = 'POPUP_CLOSE_START ';
export const POPUP_CLOSE_PENDING = 'POPUP_CLOSE_PENDING ';
export const POPUP_CLOSE_SUCCESS = 'POPUP_CLOSE_SUCCESS ';
export const POPUP_CLOSE_FAIL = 'POPUP_CLOSE_FAIL';
// actions.js
// описываем сами действия
import {
POPUP_OPEN_START,
POPUP_OPEN_PENDING,
POPUP_OPEN_SUCCESS,
POPUP_OPEN_FAIL,
POPUP_CLOSE_START,
POPUP_CLOSE_PENDING,
POPUP_CLOSE_SUCCESS,
POPUP_CLOSE_FAIL
} from './actionTypes';
export function popupOpenStart(name) {
return {
type: POPUP_OPEN_START,
payload: {
name
},
}
}
export function popupOpenPending(name) {
return {
type: POPUP_OPEN_PENDING,
payload: {
name
},
}
}
export function popupOpenFail(error) {
return {
type: POPUP_OPEN_FAIL,
payload: {
error,
},
}
}
export function popupOpenSuccess(name, data) {
return {
type: POPUP_OPEN_SUCCESS,
payload: {
name,
data
},
}
}
export function popupCloseStart(name) {
return {
type: POPUP_CLOSE_START,
payload: {
name
},
}
}
export function popupClosePending(name) {
return {
type: POPUP_CLOSE_PENDING,
payload: {
name
},
}
}
export function popupCloseFail(error) {
return {
type: POPUP_CLOSE_FAIL,
payload: {
error,
},
}
}
export function popupCloseSuccess(name) {
return {
type: POPUP_CLOSE_SUCCESS,
payload: {
name
},
}
}
// reducers.js
// реализуем редьюсеры
import {
POPUP_OPEN_START,
POPUP_OPEN_PENDING,
POPUP_OPEN_SUCCESS,
POPUP_OPEN_FAIL,
POPUP_CLOSE_START,
POPUP_CLOSE_PENDING,
POPUP_CLOSE_SUCCESS,
POPUP_CLOSE_FAIL
} from './actionTypes';
const initialState = {
opened: []
};
export function popupReducer(state = initialState, action) {
switch (action.type) {
case POPUP_OPEN_START:
case POPUP_OPEN_PENDING:
case POPUP_CLOSE_START:
case POPUP_CLOSE_PENDING:
return {
...state,
error: null,
loading: true
};
case POPUP_OPEN_SUCCESS :
return {
...state,
loading: false,
opened: [
...(state.opened || []).filter(x => x.name !== action.payload.name),
{
...action.payload
}
]
};
case POPUP_OPEN_FAIL:
return {
...state,
loading: false,
error: action.payload.error
};
case POPUP_CLOSE_SUCCESS:
return {
...state,
loading: false,
opened: [
...state.opened.filter(x => x.name !== name)
]
};
case POPUP_CLOSE_FAIL:
return {
...state,
loading: false,
error: action.payload.error
};
}
return state;
}
На выходе имеем 3 файла и как минимум следующие проблемы:
- «раздувание» кода при простом добавлении новой цепочки действий
- избыточный импорт констант действий
- чтение имен констант действий (индивидуально)
Оптимизация
Данный пример можно улучшить с помощью redux-actions.
import { createActions, handleActions, combineActions } from 'redux-actions'
export const actions = createActions({
popups: {
open: {
start: () => ({ loading: true }),
pending: () => ({ loading: true }),
fail: (error) => ({ loading: false, error }),
success: (name, data) => ({ loading: false, name, data }),
},
close: {
start: () => ({ loading: true }),
pending: () => ({ loading: true }),
fail: (error) => ({ loading: false, error }),
success: (name) => ({ loading: false, name }),
},
},
}).popups
const initialState = {
opened: []
};
export const accountsReducer = handleActions({
[
combineActions(
actions.open.start,
actions.open.pending,
actions.open.success,
actions.open.fail,
actions.close.start,
actions.close.pending,
actions.close.success,
actions.close.fail
)
]: (state, { payload: { loading } }) => ({ ...state, loading }),
[combineActions(actions.open.fail, actions.close.fail)]: (state, { payload: { error } }) => ({ ...state, error }),
[actions.open.success]: (state, { payload: { name, data } }) => ({
...state,
error: null,
opened:
[
...(state.opened || []).filter(x => x.name !== name),
{
name, data
}
]
}),
[actions.close.success]: (state, { payload: { name } }) => ({
...state,
error: null,
opened:
[
...state.opened.filter(x => x.name !== name)
]
})
}, initialState)
Уже намного лучше, но совершенству нет предела.
Лечим боль
В поисках более оптимального решения наткнулись на комментарий LestaD habr.com/ru/post/350850/#comment_10706454 и решили попробовать redux-symbiote.
Это позволило убрать лишние сущности и уменьшить количество кода.
Пример выше стал выглядеть вот так:
// symbiotes/popups.js
import { createSymbiote } from 'redux-symbiote';
export const initState = {
opened: []
};
export const { actions, reducer } = createSymbiote(initialState, {
popups: {
open: {
start: state => ({ ...state, error: null }),
pending: state => ({ ...state }),
success: (state, { name, data } = {}) => ({
...state,
opened: [
...(state.opened || []).filter(x => x.name !== name),
{
name,
data
})
]
}),
fail: (state, { error } = {}) => ({ ...state, error })
},
close: {
start: state => ({ ...state, error: null }),
pending: state => ({ ...state }),
success: (state, { name } = {}) => ({
...state,
opened: [
...state.opened.filter(x => x.name !== name)
]
}),
fail: (state, { error } = {}) => ({ ...state, error })
}
}
});
// пример вызова
import {
actions
} from './symbiotes/popups';
// ...
export default connect(
mapStateToProps,
dispatch => ({
onClick: () => {
dispatch(actions.open.start({ name: PopupNames.Info }));
}
})
)(FooComponent);
Из плюсов имеем:
- все в одном файле
- меньше кода
- структурированное представление действий
Из минусов:
- IDE не всегда предлагает подсказки
- сложно искать действие в коде
- сложно переименовать действие
Не смотря на минусы данный модуль успешно используется в наших проектах.
Спасибо LestaD за хорошую работу.
Комментарии (14)
jakobz
03.03.2019 09:21Мы как-то сделали себе похожий велик на одном проекте на TypeScript. Получилось ну очень похоже:
const initialState: CounterState = { current: 0 } const next = ({ }) => (s: MyState) => ({ ...s, current: s.current + 1 }); const set = (a: { value: number}) => (s: MyState) => ({ ...s, current: s.current + a.value }); const { actionCreators, bindActions, reducer } = buildReducer( initialState, { next, set, }, { prefix: "COUNTER" } );
В отличии от велика из статьи, тут все строго типизировано. Например, типы actionCreator-ов и их параметров выводятся (мы ради этого карировали функции, action => state => state вместо (action, state) => state).
Плюс имена экшнов автоматом строятся, и никаких функций в action-ы не упаковывается. Т.е. в нашем случае actionCreators.set({ value: 10 }) => { type: 'COUNTER_SET', value: 10 }.
Вот тут исходники самой утилитки: gist.github.com/jakobz/ae3e5567e20fff3d66d9e8852a9a655a
rgs350
03.03.2019 11:36+3Я позволю себе скопипастить сюда свое сообщение из другой темы:
Итак имеем:
Я так и не нашел ответа на вопрос зачем нужны эти псевдоабстракции.
— Глобальный стор (обычно все глобальное — плохо, а тут вдруг — хорошо).
— Кучу экшенов где action.type уникален в пределах приложения.
— Кучу action creator-ов c названием, в большинстве случаев, таким же как action.type, записанным в другом регистре.
— reducer-ы — по сути обычный switch case.
Теоретически, для уменьшения бойлерплейта вы даже использовали какую-нибудь свистоперделку.
Вопрос:
Где в этом хозяйстве архитектура, абстракции и изоляции и чем оно координально лучше, чем обычные методы хранилища? ЗЫ: Методы хотя бы можно комбинировать в отличии от словоблудия внутри switch case.AlexeyAbretov Автор
03.03.2019 13:03+1Спасибо за вопросы.
Сразу оговорюсь, что статья не об архитектуре в целом, а только об инструменте, который позволяет уменьшить количество кода, повысить читабельность и структурировать часть архитектуры redux.
Не считаю, что глобальный стор или несколько распределенных, а также вызов напрямую методов или через дополнительную инфраструктуру это плохо или хорошо. Каждый использует при разработке, что ему удобнее и подходит для реализации.
Архитектура redux неплохо описана в документации и легко бьется на слои:
— стор отдельно, что и как сделать со стором отдельно,
— представление: глупые компоненты, контейнеры, селекторы данных,
— для перехвата действий и последующего комбинирования действий над стором (и не только), например, redux-saga.
Все это можно легко передавать между проектами и тестировать.
Идеального решения нет, как и ответа на все вопросы.rgs350
03.03.2019 13:18-1Вы не подумайте что это была претензия к вам и вашей статье. Сам использовал похожие решения и велосипедил ради интереса. Они вполне жизнеспособны, особенно если учесть, что react/redux — достаточно популярные библиотеки.
DarthVictor
03.03.2019 13:36+1Не понятно как на такие действия подписываться в том же redux-saga? У них можно имя получить, через какой-то условный name?
будет работать?yield takeEvery(actions.open.success.name, loadAdditional)
AlexeyAbretov Автор
03.03.2019 13:41yield takeEvery(actions.open.success.toString(), loadAdditional)
DarthVictor
03.03.2019 14:44+1Тогда ок. А с типизацией в TypeScript или Flow как обстоят дела, не в курсе?
Aries_ua
04.03.2019 18:29Как вижу подобный код и реализацию — честно, плакать хочется. Ну вот реально вьехать с пол пинка вряд ли получится. Далее все что только можно суем в глобальный стор. Туда жа запихавают логику приложения. Вот попробуйте потом такое приложение оптимизировать. Разбить на слои, где с каждым слоем работала бы команда.
Почему не вынести бизнес логику получения и обработки данных в отдельные слои? Зачем все держать в сторе, если данные нужны только в одном месте? Как сделать что-то не завязываясь на redux?
В большинстве туториалов пишут о том, что это серебряная пуля, вот берем и радость. Как сказал один девелопер у меня в команде — «чувак, ты не понимаешь?! Это же React-way! Надо только так писать, так в мануалах пишут». Над… В общем, на практике, это просто добавляет проблем, нежели профита.
Что делаю у себя в проектах, это в первую очередь разделяю приложение на слои: бизнес логика, API, компоненты, Store.
— API это набор классов, где реализована коммуникация с сервером
— бизнес логика, слой где обрабатываются и подготавливаются данные
— компоненты, по содержат логику только для отрисовки интерфейса, максимально стараемся делать их независимыми, маленькими и тупыми.
— Store. В сторе держим только те данные, которые нужны нескольким компонентам в одно и тоже время на странице. К примеру, профиль пользователя. Имя пользователя покажем в хедере сайта, в навигационном меню и на странице профиля пользователя. Изменили имя, поменялось в трех точках.
Как видно из описания, нет проблем разделить работы между людьми. Не жестких привязок. В любой момент можно заменить слой на другую технологию, не переписывая все остальное.LestaD
04.03.2019 21:21Как потом эти бизнес слои связывать вместе используя Flux?
Как это дебажить без вменяемых dev-tools?
redux-symbiote был призван решить только одну проблему: убрать бойлерплейт вокруг экшенов и редюссеров, ни больше, ни меньше.
symbiote — чисто апдейтеры стора, thunk/execute — бизнес-логика, components — отображение данных. Всё тоже самое разделение на слои, только более простое.
Разделять приложение между командами нужно полноценно разделяя приложение на micro-frontends, а не работать всем вместе в огромном монолите.
А вот по поводу качественного разделения: я постепенно перехожу на effector. Где есть и полноценный дебаг и статическое вычисление зависимых сторов, и красивое API, и отстутствие проблемы ромбовидных зависимостей.
Мб потом статью о нём напишу.
Finesse
Как правильно произвести декомпозицию редьюсеров, то есть создать несколько независимых редьюсеров, которые преобразуют независимые состояния, и затем объединить их в один редьюсер? Если создать несколько редьюсеров через redux-symbiote и объединить их через combineReducers, то возникнет риск коллизии названий типов действий (потому что они не записаны в коде явно).
LestaD
Спасибо за вопрос. `createSymbiote` имеет третий параметр namespace/options, с помощью которого можно задать префикс для всех экшен-типов сразу.
github.com/sergeysova/redux-symbiote#options
Некоторые примеры использования symbiote можно посмотреть здесь: github.com/howtocards/frontend/tree/dev/src/features/cards/symbiotes