Организация reduce через стандартный класс

в 13:13, , рубрики: javascript, ReactJS, redux

Приветствую, сегодня я собираюсь поговорить с вами о способе организации Reduce. И рассказать с чего я начал и к чему пришел.

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

export default function someReduce(state = initialState, action) {
    switch (action.type) {
    case 'SOME_REDUCE_LABEL':
    return action.data || {};
    default:
    return state;
    }
}

Тут все просто и понятно, но немного поработав с такими конструкциями я понял что данный метод имеет ряд сложностей.

  • Метки надо как то хранить, потому что они начали расползаться по проекту и уползать далеко за пределы контроллеров.
  • Метки надо было делать уникальными, потому что иначе могло быть пересечение с другими редьюсами
  • Большая часть времени при работе с такой структурой тратилась на организацию кода, нежели на обработку входящих данных
  • И когда меток в редьюсе набирается много — код становиться неряшливым и трудно читаемым, ну и общее пространство имен меня откровенно не радовало.
    Примерно в это же время, для обработки сайд эффектов мы стали применять саги. Это позволило нам значительно облегчить общение с серверной частью без использования колбеков.

Теперь нам надо было дать знать саге, какой редьюс надо было вызвать, после того, как отработает сайдэффект.

Самый разумный вариант, который я нашел, это сделать action creator.

И наш предидущий код стал выглядеть вот так:

    import { FetchSaga } from '../../helpers/sagasHelpers';

    const  SOME_REDUCE_LABEL = 'SOME_REDUCE_LABEL';

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

    export default function someReduce(state = initialState, action) {
        switch (action.type) {
        case SOME_REDUCE_LABEL:
        return action.data || {};
        default:
        return state;
        }
    }

FetchSaga — это функция-генератор action (далее action creator) для саги, которая запрашивает данные с сервера и диспатчит их в редьюс, метка которого была передана функции на этапе инициализации(SOME_REDUCE_LABEL).

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

import { FetchSaga } from '../../helpers/sagasHelpers';

const  SOME_REDUCE_LABEL1 = 'SOME_REDUCE_LABEL1';
const  SOME_REDUCE_LABEL2 = 'SOME_REDUCE_LABEL2';
const  SOME_REDUCE_LABEL3 = 'SOME_REDUCE_LABEL3';
const  SOME_REDUCE_LABEL4 = 'SOME_REDUCE_LABEL4';
const  SOME_REDUCE_LABEL5 = 'SOME_REDUCE_LABEL5';
....
const  SOME_REDUCE_LABEL10 = 'SOME_REDUCE_LABEL10';

export const  someReduceLabelActionCreator1 =  FetchSaga.bind(this, SOME_REDUCE_LABEL1);
export const  someReduceLabelActionCreator2 =  data => ({...data, SOME_REDUCE_LABEL2});
export const  someReduceLabelActionCreator3 =  data => ({...data, SOME_REDUCE_LABEL3});
export const  someReduceLabelActionCreator4 =  data => ({...data, SOME_REDUCE_LABEL4});
export const  someReduceLabelActionCreator5 =  data => ({...data, SOME_REDUCE_LABEL5});
.....
export const  someReduceLabelActionCreator10 =  FetchSaga.bind(this, SOME_REDUCE_LABEL10);

export default function someReduce(state = initialState, action) {
    switch (action.type) {
    case SOME_REDUCE_LABEL: return action.data || {};
    case SOME_REDUCE_LABEL1: return action.data || {};
    case SOME_REDUCE_LABEL2: return action.data || {};
    case SOME_REDUCE_LABEL3: return action.data || {};
    ....
    default:
    return state;
    }
}

При импорте всех этих actionов в контроллер тот тоже нехило так раздувался. И это мешало.

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

Тогда у меня родилась идея стандартизировать редьюс. Задачи перед ним стояли не сложные.

  1. Проверять входящий action и возвращать старое состояние, если action не для текущего редьюса или автоматически клонировать state и отдавать в метод-обработчик, который изменить состояние и отдаст в компонент.
  2. Следует перестать оперировать метками, вместо этого контроллер должен получать объект содержащий все action creators для интересующего нас редьюса.
    Таким образом импортировав такой набор один раз, я смогу прокидовать через него любое количество action creators для dispatch функции из редьюса в контроллер без необходимости повторного импорта
  3. вместо использование корявого switch-case с общим пространством имен, на который материться линтер, я хочу иметь отдельный метод, для каждого actionа, в который будет передано уже клонированное состояние редьюса и сам action
  4. неплохо бы иметь возможность наследовать от редьюса новый редьюс. На случай повторения логики, но например для другого набора меток.

Идея показалась мне жизнеспособной и я решил попробовать это реализовать.

Вот как стал выглядеть среднестатистический редьюс теперь

    // это наш стандартизированный класс, потомок которого будет управлять состоянием в данном редьюсе
    import stdReduceClass from '../../../helpers/reduce_helpers/stdReduce';

class SomeReduce extends stdReduceClass {
    constructor() {
        super();
        /**
            Уникальный идентифактор редьюса. По которому Редьюс будет узначать свои actionы, которые он же породил
        */
        this.prefix = 'SOME_REDUCE__'; 
    }

    /**
        декларация набора методов, которыми может оперировать данный редьюс
        - type - тип, он выполняет двойную функцию. Во-первых при соединении с префиксом мы получим конечную метку, которая будет передана в action creator, например  SOME_REDUCE__FETCH. 
        Так же type являться ключом по которому можно отыскать  нужный action creator в someReduceInstActions 
        - method - Метод, который примет измененное состояние и action, выполнить какие то действия над ним и вернет состояние в компонент
        - sagas - это не обязательный параметр, который указывает классу, какой тип сайд эффекта следует выполнить сначала. В случае представленном ниже, будет создан action creator для саги, куда будет автоматически добавлена метка SOME_REDUCE__FETCH, 
        После того, как сага отработает, она отправит полученные данные в редьюс используя переданную ранее метку.
    */
    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 someReduceInst = new SomeReduce();

someReduceInst.init(); // генерируем список action creators на основе config

// получаем список созданных action creator для дальнейшего использования в контроллерах
export const someReduceInstActions = someReduceInst.getActionCreators();

// вешаем проверку на состояния. Каждый раз checkActionForState будет проверять входящий Action и определять, относится ли он к данному редьюсу или нет
export default someReduceInst.checkActionForState; 

stdReduceClass изнутри выглядит следующим образом

import { cloneDeep } from 'lodash'; //для клонирования используется зависимость lodash

// так же я импортирую саги непосредственно в родителя, так как они типовые и нет смысла переопределять их каждый раз
import { FetchSaga } from '../helpers/sagasHelpers/actions';

export default class StdReduce {
    _actions = {};
    actionCreators = {};

    /** UNIQUE PREFIX BLOCK START */
    /**
        префикс мы храним в нижнем регистре, для единообразия. Как уже говорилось, это важный элемент, если него не указывать, 
        то редьюс не распознает свои actionы или все они будут ему родными 
    */
    uniquePrefix = '';

    set prefix(value) {
        const lowedValue = value ? value.toLowerCase() : '';
        this.uniquePrefix = lowedValue;
    }

    get prefix() {
        return this.uniquePrefix;
    }

    /** INITIAL STATE BLOCK START */

    /**
      используя сеттер initialState можно указать начальное состояние для редьюса. 
    */
    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 который будет ожидать на вход объект с произвольными свойствами
    */
    subscribeReduceOnActions = actionsConfig => actionsConfig.forEach(this._subscribeAction);

    /**
        Для каждой настройки вызывается метод  _subscribeAction, который создает два набора, где ключом является имя метки переданное в type. Таким образом, редьюсь будет определять, какой метод является обработчиком для текущего 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_REDUCE__, а тип в конфиге содержал FETCH, то в результате мы получим SOME_REDUCE__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, которые доступны для редьюса
    */
    getActionCreators = () => this.actionCreators;
}

Как же это выглядеть в контроллере? А вот так

import { someReduceInstActions } from  '../../../SomeReduce.js'
const mapDispatchToProps = dispatch => ({
        doSoAction: (params) => dispatch(someReduceInstActions.fetch(url, params)),
        doSoAction1: (value, block) => dispatch(someReduceInstActions.update({value, block})),
    });

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

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

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

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

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

Автор: Neffes

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js