Если мы хотим переиспользовать нашу функцию-редьюсер для нескольких экземпляров конечного редьюсера, то мы сталкиваемся с проблемой.



Концепция мультиредьюсера 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 initial counter reducer, and we use combineReducers 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. Because combineReducers 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-класс модуля списка


src/redux/modules/list/List.js
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;
    }
  }
}

src/redux/modules/list/List.devtools.ready.js
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-компонент для отображения списков


src/components/ListView.js
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-компонента для отображения списков


src/App.jsx
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 репозитория этой документации.

А как вы делаете мультиредьюсеры?

Проголосовало 44 человека. Воздержалось 63 человека.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Поделиться с друзьями
-->

Комментарии (18)


  1. hose314
    03.03.2017 18:44

    1. Voronar
      03.03.2017 18:56

      Cогласно предостережению Дэна, мы только проигрываем в потере дополнительной информации в логгере и дев-тузлах.


      1. hose314
        03.03.2017 20:17
        +2

        Не буду говорить за остальных, но лично в моем флоу дебага это crucial


  1. kanstantsin
    03.03.2017 20:00
    +5

    Мне кажется, нет смысла пытаться натянуть на redux ООП — если библиотека задумана в функциональном стиле, то надо ему и следовать. Из опыта (в js) попытки «натянуть» функциональное на императивное скорее всего приведут к сложностям с поддержкой и снизят скорость разработки. Если ООП ближе, стоит посмотреть, к примеру, на mobX.


    1. Voronar
      03.03.2017 20:47

      По-моему, сочетание двух парадигм это не плохо. Здесь от ООП мы только используем класс для упорядочивания методов, а не JS-модуль.


      1. 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';


        1. Voronar
          03.03.2017 23:39

          Извиняюсь, выше я выразился не совсем корректно, поэтому у вас возникло неправильное понимание. Класс используется не только для организации методов, но и для того, чтобы этот подход вообще работал. Модуль (ECMAScript 2015) — это статическая единица кода, а нам нужны новые экземпляры редьюсеров и генераторов экшенов с уникальными типами для экшенов.


          1. 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() нет, зато не нужно делать никаких оговорок типа "Генераторы экшенов и редьюсер должны быть методами экземпляра класса", как у вас в статье.


            1. Voronar
              04.03.2017 00:24

              Ну, тут дело вкуса. Если вам нравится фабрика — пожалуйста. Мне класс (хотя он тоже является функцией) кажется более изящным.


  1. 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'), []
    
      


  1. comerc
    03.03.2017 21:05

    Прочитал с листа код из примера, а из статьи смог вынести полезного про уникальность возвращаемых значений символов.


    Поставьте себя на место тех, кто будет на поддержке кода. Пожалуйста!


  1. firstpasha
    03.03.2017 21:53

    а я тут эксперементирую на досуге с разделением управления данными, бизнес логики и отображением: https://www.npmjs.com/package/react-component-redux

    Ох, вообще печально это всё.


  1. 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;
        }
    }
    

    Так мы еще сможем сохранить информацию для дев-тулз, где четко будет видно какой именно экшн сработал.


    1. 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


      1. 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


  1. Voronar
    04.03.2017 10:32
    +1

    Не очень удачно назвал статью изначально. Больше интересует организация Redux-модуля для переиспользования, без дублирования редьюсеров и генераторов экшенов.


  1. Voronar
    04.03.2017 12:36
    +1

    Те, кому важны девтулзы, добавил реализацию без этих ограничений.
    List.devtools.ready.js


  1. ThisMan
    04.03.2017 16:53

    Я для решения проблемы дублирования редьюсеров использовал один редьюсер, который хранит в себе несколько стейтов, которые могут динамически создаваться и обращаться к ним можно по имени


    Пример c counter
    const 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})