Приветствую, сегодня я собираюсь поговорить с вами о способе организации 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. Задачи перед ним стояли не сложные.


  1. Проверять входящий action и возвращать старое состояние, если action не для текущего reducer'а или автоматически клонировать state и отдавать в метод-обработчик, который изменить состояние и отдаст в компонент.
  2. Следует перестать оперировать метками, вместо этого контроллер должен получать объект содержащий все action creators для интересующего нас reducer'а.
    Таким образом импортировав такой набор один раз, я смогу прокидовать через него любое количество action creators для dispatch функции из reducer'а в контроллер без необходимости повторного импорта
  3. вместо использование корявого switch-case с общим пространством имен, на который материться линтер, я хочу иметь отдельный метод, для каждого actionа, в который будет передано уже клонированное состояние reducer'а и сам action
  4. неплохо бы иметь возможность наследовать от 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})),
    });

Итак, что мы имеем в итоге:


  1. избавились от нагромождения меток
  2. избавились от кучи импортов в контроллере
  3. убрали switch-case
  4. прибили саги один раз и теперь можем расширят их набор в одном месте, будучи уверенными что все наследники автоматически получат дополнительные обработчики сайд эффектов
  5. Получили возможность наследовать от reducer'ов, в случае если есть смежная логика( на данный момент это мне так и не пригодилось =) )
  6. Переложили ответственность по клонированию с разработчика на класс, который точно не забудет это сделать.
  7. стало меньше рутины при создании reducer'а
  8. Каждый метод имеет изолированное пространство имен

Я старался описать все как можно подробнее =) Извините, если путано, чукча не писатель. Надеюсь что кому нибудь будет полезен мой опыт.


> Действующий пример можно посмотреть тут


Спасибо, что дочитали!


UPD: поправил ошибки. Писал ночью, плохо вычитал. Спасибо что так деликатно на них указали=)

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


  1. staticlab
    10.02.2019 17:03
    +7

    Итак, есть некий стандарт по организации Reduce и выглядит он следующим образом:

    Дальше не читал. То, что вы привели, называется редьюсер! А reduce — это функция свёртки массива!


    1. xRevolveRx
      10.02.2019 18:15
      -5

      Про редакс тоже была хорошая статья. Столько кода и так мало смысла. Когда Вас с рынка выгонят всех, дармоеды?


    1. Apathetic
      10.02.2019 18:58

      Зря не читали! Дальше встречаются такие перлы, как, например этот кусок кода:


      export const  someReduceLabelActionCreator =  FetchSaga.bind(this, SOME_REDUCE_LABEL);


      1. staticlab
        10.02.2019 19:01

        И так 10 раз :)


  1. miooim
    10.02.2019 23:09

    Я использую вот такую конструкцию чтоб избавится от бардака:
    Акшен

    import { LocalesApi } from '../api/locales.api';
    
    class Actions {
      prefix = 'LOCALES_ACTIONS';
      REQUEST = `${this.prefix}_REQUEST`;
      SUCCESS = `${this.prefix}_SUCCESS`;
      ERROR = `${this.prefix}_ERROR`;
      CLEAR = `${this.prefix}_CLEAR`;
    
      request = () => ({
        type: this.REQUEST,
      })
      
      success = messages => ({
        type: this.SUCCESS,
        messages,
      })
    
      clear = () => ({
        type: this.CLEAR,
      })
    
      error = error => ({
        type: this.ERROR,
        error,
      })
    
      getByLocale = locale => async dispatch => {
        try {
          dispatch(this.request());
          const result = await LocalesApi.getByLocale(locale);
          dispatch(this.success(result));
        } catch (error) {
          dispatch(this.error(error.message));
        }
      }
    }
    
    export const LocalesActions = new Actions();й
    


    Редюсер
    import { LocalesActions } from '../actions/locales.actions';
    import { LOCALES } from '../constants/locales.constants';
    
    const initialState = {
      shouldInitialize: true,
      updating: false,
      locale: LOCALES.SUPPORTED[0],
      messages: {},
    };
    
    export default (state = initialState, action) => {
      switch (action.type) {
      case LocalesActions.REQUEST:
        return {
          ...state,
          shouldInitialize: false,
          updating: true,
        };
      case LocalesActions.SUCCESS:{ 
        const { messages } = action;
        return {
          ...state,
          updating: false,
          messages,
        };
      }
      case LocalesActions.ERROR:{ 
        const { error } = action;
        return {
          ...state,
          updating: false,
          error
        };
      }
      default:
        return state;
      }
    };
    

    Использование в контейнере
    import { connect } from 'react-redux';
    
    import { IntlProviderComponent } from './IntlProvider.component';
    import { STORES } from '../constants/stores.constants';
    import { LocalesActions } from '../actions/locales.actions';
    
    const mapStateToProps = state => ({
      locales: state[STORES.LOCALES],
    });
    
    const mapDispatchToProps = dispatch => ({
      getMessages: locale => dispatch(LocalesActions.getByLocale(locale)),
    });
    
    export const IntlProvider = 
      connect(mapStateToProps, mapDispatchToProps)(IntlProviderComponent);


    1. Neffes Автор
      10.02.2019 23:40

      Я знал, что не придумал велосипед. Очень боялся, что вся моя идея ошибочна. Но глядя на вашу реализацию, нахожу в ней много общего с моей и это дает надежду, что и моя идея не тупиковая =) Спасибо за коммент, вы очень помогли.


  1. xander27
    11.02.2019 06:43

    Я делал что-то подобное, но только еще селекторы туда добавил


    class TestModule extends DuckModule {
    
        constructor(prefix, rootSelector) {
            super(prefix, rootSelector);
            //define actions here
            this.INC = this.prefix + "INC"; // action name
        }
    
        /**
         * Create new action of this.INC type
         */
        inc(x) {
            return {type: this.INC, payload: x}
        }
    
        /**
         * Selector for X value
         * @param state
         */
        getX(state) {
            return this.getRoot(state).x;
        }
    
        reduce(state = {x: 0}, action) {
            switch (action.type) {
                case this.INC:
                    return {x: state.x + action.payload};
            }
            return super.reduce(state, action);
        }
    }
    
    test("Selectors and actions", () => {
        expect(module.getX(state)).toBe(0);
        //...
        dispatch(module.inc(1));
        //...
        expect(module.getX(state)).toBe(1);
    });

    Из минусов, reselect из коробки прицепить сложно (но впринципе можно самомму организовать альтернативу). Хотел сделать новую версию, но пока отошел от занятия фронтом.
    https://www.npmjs.com/package/simple-duck — кому интересно, больше примеров (combineModules, использование наследования)


    1. Neffes Автор
      11.02.2019 08:56

      Очень интересный подход. Я как-то зациклился на том, что один reducer обслуживает одну страницу. И есть еще несколько вспомогательных reducer'ов, для виджетов. В вашем случае каждый модуль несет единственную ответственность. Что позволяет их использовать повторно более эффективно. Эту идею стоит обдумать.


      1. xander27
        11.02.2019 10:35

        Про страницы — я использовал другой подход, были такие модули отдельно для GUI и для бизнес логики. И например если в каком-то модуле GUI нужно было вывести значение из бизнес-модуля, то грубо говоря так


        import logicModule from '../logic'
        ///....
        class GuiModule extends ViewModule {
        
            getSomeLabelText(state) {
                return doSomeGuiStuff(logicModule.getValue(state));
            }
        }


        1. Neffes Автор
          11.02.2019 13:05

          То есть редьюсер запоминает последние состояние и его можно получить в обход пропсов?


          1. xander27
            11.02.2019 19:07

            Сам редьюсер ничего не запоминает, он получает то что надо через селекторы. тут несколько другая концепацая (общую идею можно почитать тут https://github.com/erikras/ducks-modular-redux ). То есть есть модуль, который включает в себя описание экшенов, редьюсер и селекторы.


  1. Druu
    11.02.2019 08:59

    Вместо конфига можно использовать декораторы методов, тогда не надо указывать ни имя, ни функцию.


  1. kalyukdo
    11.02.2019 10:12

    Велосипеды это круто, они помогают учиться, автор взгляни на MobX, большинство велосипедов пытаются решить одну и туже проблему (булертплейт), там она уже решена


  1. keenondrums
    11.02.2019 10:45

    Одним из лучших вариантов организации редьюсеров через классы мне представляется ngrx-actions. Мне немного не хватало в нем типизации, и т.к. автор забил на проект, то я его переписал. Но, в целом, мысли похожие.