Если мы хотим переиспользовать нашу функцию-редьюсер для нескольких экземпляров конечного редьюсера, то мы сталкиваемся с проблемой.
Концепция мультиредьюсера Redux (EN)
Проблема
Создатель Redux вот что пишет по этому поводу:
As an example, let's say that we want to track multiple counters in our application, named A, B, and C. We define our initialcounter
reducer, and we usecombineReducers
to set up our state:
function counter(state = 0, action) {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
}
const rootReducer = combineReducers({
counterA : counter,
counterB : counter,
counterC : counter
});
Unfortunately, this setup has a problem. BecausecombineReducers
will call each slice reducer with the same action, dispatching{type : 'INCREMENT'}
will actually cause all three counter values to be incremented, not just one of them.
Решение
Для решения этой проблемы нам нужны уникальные
типы экшенов для определенной версии нашей функции-редьюсера.
Решение с использованием ФП
Дэн предлагает решение из мира функционального программирования — редьюсер высшего порядка. Он оборачивает редьюсер с помощью функции высшего порядка (ФВП) и именует тип экшена с помощью дополнительного суффикса/префикса, пробрасываемого через ФВП. Похожий подход (он специализирует объект-экшен с помощью специального мета-ключа
) использует Erik Rasmussen в своей библиотеке.
Решение с использованием ООП
Я предлагаю более-менее схожий подход, но без обёрток, суффиксов/преффиксов, мета-ключей и тому подобного. В секции решения я выделил слово специфичные
не без причины. Что, если мы возьмём и сделаем тип экшена ДЕЙСТВИТЕЛЬНО уникальным? Встречайте, Symbol
. Вырезка с сайта MDN:
Every symbol value returned from Symbol() is unique. A symbol value may be used as an identifier for object properties; this is the data type's only purpose.
Идеальный вариант, не правда ли? А причём тут объектно-ориентированное программирование? ООП позволяет организовать наш код наиболее оптимальным образом и сделать наши типы экшенов уникальными. Способ организация Redux-ингридиентов (или Redux-модуля) был навеян модульным Redux всё того же Erik Rasmussen.
Давайте попробуем применить этот подход на примере React-приложения для отображения списков (рабочий пример c интеграцией с redux devtools chrome extension находится в репозитории этой документации, просто скопируйте репозиторий, и запустите пару комманд npm i
и npm run start
).
?? ПРЕДУПРЕЖДЕНИЕ ?? Symbol
константы накладывают некоторые ограничения на такие возможности Redux, как отладка с перемещением во времени, запись и воспроизведение экшенов. Больше информации читайте тут. Но эта проблема легко решается.
Пример (React-приложение для отображения списков)
Redux list
модуль
Redux list
модуль — директория в которой расположены Redux-класс модуля и требуемые экземпляры этого модуля.
src/redux/modules/list/List.js
— Redux-класс модуля списка
import * as services from './../../../api/services';
const initialState = {
list: [],
};
function getListReducer(state, action) {
return {
...state,
list: action.payload.list,
};
}
function removeItemReducer(state, action) {
const { payload } = action;
const list = state.list.filter((item, i) => i !== payload.index);
return {
...state,
list,
};
}
export default class List {
constructor() {
// action types constants
this.GET_LIST = Symbol('GET_LIST');
this.REMOVE_ITEM = Symbol('REMOVE_ITEM');
}
getList = (serviceName) => {
return async (dispatch) => {
const list = await services[serviceName].get();
dispatch({
type: this.GET_LIST,
payload: {
list,
serviceName,
},
});
};
}
removeItem = (index) => {
return (dispatch) => {
dispatch({
type: this.REMOVE_ITEM,
payload: {
index,
},
});
};
}
reducer = (state = initialState, action) => {
switch (action.type) {
case this.GET_LIST:
return getListReducer(state, action);
case this.REMOVE_ITEM:
return removeItemReducer(state, action);
default:
return state;
}
}
}
import * as services from './../../../api/services';
const initialState = {
list: [],
};
function getListReducer(state, action) {
return {
...state,
list: action.payload.list,
};
}
function removeItemReducer(state, action) {
const { payload } = action;
const list = state.list.filter((item, i) => i !== payload.index);
return {
...state,
list,
};
}
function actionType(name) {
return {
type: name,
metaType: Symbol(name),
};
}
export default class List {
constructor(prefix) {
this.GET_LIST = actionType(`${prefix}/GET_LIST`);
this.REMOVE_ITEM = actionType(`${prefix}/REMOVE_ITEM`);
}
getList = (serviceName) => {
return async (dispatch) => {
const list = await services[serviceName].get();
const { type, metaType } = this.GET_LIST;
dispatch({
payload: {
list,
serviceName,
},
type,
metaType,
});
};
}
removeItem = (index) => {
return (dispatch) => {
const { type, metaType } = this.REMOVE_ITEM;
dispatch({
payload: {
index,
},
type,
metaType,
});
};
}
reducer = (state = initialState, action) => {
switch (action.metaType) {
case this.GET_LIST.metaType:
return getListReducer(state, action);
case this.REMOVE_ITEM.metaType:
return removeItemReducer(state, action);
default:
return state;
}
}
}
?? ВАЖНО ?? Генераторы экшенов и редьюсер должны быть методами экземпляра класса, а не его прототипа, в противном случае вы потеряете this
.
src/redux/modules/list/index.js
— Экземпляры модуля Redux
// Redux list module class
import List from './List';
export default {
users: new List(),
posts: new List(),
};
Просто создаём класс Redux-модуля и переиспользуем его, делаю столько экземпляров, сколько требуется.
src/redux/modules/reducer.js
— Главный редьюсер
import { combineReducers } from 'redux';
// required Redux module instances
import list from './list/index';
export default combineReducers({
users: list.users.reducer,
posts: list.posts.reducer,
});
src/components/ListView.js
— React-компонент для отображения списков
import * as React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from "redux";
// Redux module instances
import list from './../redux/modules/list';
class ListView extends React.Component {
componentWillMount() {
this.props.getList(this.props.serviceName);
}
render() {
return (
<div>
<h1>{this.props.serviceName}</h1>
<ul>
{this.props.list.map((item, i) =>
<span key={i}>
<li style={{ width: 100 }}>
{item}
<button style={{ float: 'right' }} onClick={() => this.props.removeItem(i)}>x</button>
</li>
</span>)
}
</ul>
<button onClick={() => this.props.getList(this.props.serviceName)}>Update</button>
</div>
);
}
}
const mapStateToProps = (state, { serviceName }) => ({
...state[serviceName],
});
const mapDispatchToProps = (dispatch, { serviceName }) => ({
...bindActionCreators({ ...list[serviceName]}, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(ListView);
src/App.jsx
— Использование React-компонента для отображения списков
import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
import ListView from './components/ListView';
class App extends Component {
render() {
return (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Try to Redux multireducer</h2>
</div>
<ListView serviceName="users" />
<ListView serviceName="posts" />
</div>
);
}
}
export default App;
Заключение
Таким образом, используя современный JavaScript вы можете сделать ваш Redux-модуль более удобным для переиспользования. Буду рад услышать критику и предложения в секции Issue репозитория этой документации.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Комментарии (18)
kanstantsin
03.03.2017 20:00+5Мне кажется, нет смысла пытаться натянуть на redux ООП — если библиотека задумана в функциональном стиле, то надо ему и следовать. Из опыта (в js) попытки «натянуть» функциональное на императивное скорее всего приведут к сложностям с поддержкой и снизят скорость разработки. Если ООП ближе, стоит посмотреть, к примеру, на mobX.
Voronar
03.03.2017 20:47По-моему, сочетание двух парадигм это не плохо. Здесь от ООП мы только используем класс для упорядочивания методов, а не JS-модуль.
justboris
03.03.2017 22:30+1А чем вас не устраивает стандартный способ упорядочиваения методов с import/export?
Можно положить экшены и редюсер в один файл, такой подход называется Ducks.
export const actionA () => {/*action code*/} export const actionB () => {/*action code*/} export default function reducer(state, action) { // reducer code }
использование:
// если нужен редьюсер import reducer from './ducks'; // если нужны экшены import {actionA, actionB} from './ducks';
Voronar
03.03.2017 23:39Извиняюсь, выше я выразился не совсем корректно, поэтому у вас возникло неправильное понимание. Класс используется не только для организации методов, но и для того, чтобы этот подход вообще работал. Модуль (ECMAScript 2015) — это статическая единица кода, а нам нужны новые экземпляры редьюсеров и генераторов экшенов с уникальными типами для экшенов.
justboris
03.03.2017 23:59+1Можно завернуть символы в фабрику:
function createList() { const ACTION_A = Symbol(); const ACTION_В = Symbol(); return { actionA: (payload) => ({type: ACTION_A, payload}), actionB: (payload) => ({type: ACTION_B, payload}), reducer: (state, action) => { // reducer code } }; }
Большой разницы между
createList()
иnew List()
нет, зато не нужно делать никаких оговорок типа "Генераторы экшенов и редьюсер должны быть методами экземпляра класса", как у вас в статье.Voronar
04.03.2017 00:24Ну, тут дело вкуса. Если вам нравится фабрика — пожалуйста. Мне класс (хотя он тоже является функцией) кажется более изящным.
Eternalko
03.03.2017 20:25Не совсем напрямую, несколько иначе обходим эту проблему.
Стараемся сделать так, чтобы reducer's можно было экспортировать / импортировать
как обычную «библиотеку».
coffeescript### ------------------------------------------------ actions.coffee ### { createAction } = require 'redux-actions' ### DATA API ### module.exports = # PUBLIC create: createAction 'CREATE_MESSAGE', (body, header) -> action = body: body header: header action remove: createAction 'REMOVED_MESSAGE', (id) -> id: id # Private $: creating: createAction 'CREATING_MESSAGE', (message) -> _.assign {}, message, id: _.uniqueId('message-') synced: false created: createAction 'CREATED_MESSAGE' ### ------------------------------------------------ reducer.coffee ### _ = require 'lodash' { handleActions } = require 'redux-actions' ### STATE CHANGES ### actions = require './actions' actionsMap = [ action: actions.$.creating handle: (state, action) -> _.concat state, _.assign {}, action.payload , action: actions.$.created handle: (state, action) -> _.map _.cloneDeep(state), (item) -> if item.id is action.payload item.synced = true item , action: actions.remove handle: (state, action) -> _.reject _.cloneDeep(state), action.payload ] module.exports = messages: handleActions _.mapValues(_.keyBy(actionsMap, 'action'), 'handle'), []
comerc
03.03.2017 21:05Прочитал с листа код из примера, а из статьи смог вынести полезного про уникальность возвращаемых значений символов.
Поставьте себя на место тех, кто будет на поддержке кода. Пожалуйста!
firstpasha
03.03.2017 21:53а я тут эксперементирую на досуге с разделением управления данными, бизнес логики и отображением: https://www.npmjs.com/package/react-component-redux
Ох, вообще печально это всё.
anyxem
04.03.2017 02:03+2Поэтому не надо использовать строки в качестве типов экшнов. Нужно задать константы с некоим подобием неймспейса в файле с редьюсером. Так делается в иерархическом redux, т.н. ducks
const INCREMENT = "counter1/INCREMENT"; const DECREMENT= "counter1/DECREMENT"; ... function counter(state = 0, action) { switch (action.type) { case INCREMENT: return state + 1; case DECREMENT: return state - 1; default: return state; } }
Так мы еще сможем сохранить информацию для дев-тулз, где четко будет видно какой именно экшн сработал.comerc
04.03.2017 10:14+2Красивый способ избавиться от switch:
import { createAction } from 'redux-act' export const inputTitle = createAction('@@edit_post/INPUT_TITLE', title => ({ title })) export const submit = createAction('@@edit_post/SUBMIT', post => post)
import { createReducer } from 'redux-act' import * as EditPostActions from '../actions/EditPostActions' const initialState = { flow: '', title: '', content: '' } const reducer = createReducer({ [EditPostActions.inputTitle]: (state, { title }) => ({...state, title}), [EditPostActions.submit]: (state, post) => ({...state, ...post}) }, initialState) export default reducer
comerc
04.03.2017 17:24Спасибо за ducks, теперь вообще замечательно!
import { createAction, createReducer } from 'redux-act' export const actions = { inputTitle: createAction('@@edit_post/INPUT_TITLE', title => ({ title })), submit: createAction('@@edit_post/SUBMIT', post => post), } const initialState = { flow: '', title: '', content: '' } const reducer = createReducer({ [actions.inputTitle]: (state, { title }) => ({...state, title}), [actions.submit]: (state, post) => ({...state, ...post}) }, initialState) export default reducer
Voronar
04.03.2017 10:32+1Не очень удачно назвал статью изначально. Больше интересует организация Redux-модуля для переиспользования, без дублирования редьюсеров и генераторов экшенов.
Voronar
04.03.2017 12:36+1Те, кому важны девтулзы, добавил реализацию без этих ограничений.
List.devtools.ready.js
ThisMan
04.03.2017 16:53Я для решения проблемы дублирования редьюсеров использовал один редьюсер, который хранит в себе несколько стейтов, которые могут динамически создаваться и обращаться к ним можно по имени
Пример c counterconst COUNTER_INCREMENT = "counter increment" const COUNTER_DECREMENT = "counter decrement" const COUNTER_INIT = "counter init" const getInitialCounter = ({ counter: 0 }) const counter(initialState = {}, action) { const {payload, type} = action; switch(type) { case COUNTER_INIT: { const {name} = payload return { ...state, [name]: getInitialCounter() } }, case COUNTER_INCREMENT: { const {name} = payload const oldCounter = state[name] const updatedCounter = { counter: oldCounter.counter++ } return { ...state, [name]: updatedCounter } } case COUNTER_DECREMENT: { const {name} = payload const oldCounter = state[name] const updatedCounter = { counter: oldCounter.counter-- } return { ...state, [name]: updatedCounter } } default return state; } } const incrementCounter(name) => ({type: COUNTER_INCREMENT, name}) const decrementCounter(name) => ({type: COUNTER_DECREMENT, name}) const initCounter = (name) => ({type: COUNTER_INIT, name})
hose314
Symbols for constants #779
Voronar
Cогласно предостережению Дэна, мы только проигрываем в потере дополнительной информации в логгере и дев-тузлах.
hose314
Не буду говорить за остальных, но лично в моем флоу дебага это crucial