image


Скажите, люди, я один испытываю небольшой душевный зуд
от необходимости писать нечто вот эдакое? :


export const ADD_TODO = 'ADD_TODO'
export const DELETE_TODO = 'DELETE_TODO'
export const EDIT_TODO = 'EDIT_TODO'
export const COMPLETE_TODO = 'COMPLETE_TODO'
export const COMPLETE_ALL = 'COMPLETE_ALL'
export const CLEAR_COMPLETED = 'CLEAR_COMPLETED'

Я почему то думаю, что нет и иногда встречая в чьём то коде


if (action.type === ADD_TODO) {
  // ...
}

вместо ядрёного switch — case, я понимаю, что не единственный такой я на свете перфекционист, страдающий от этого "чуть-чуть не так как надо" в классическом Redux


Если Вам, уважаемый читатель, знакома эта боль, возрадуйтесь! под катом есть лекарство всего в две строчки кода :)


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


По сути дела, dispatch — это метод Store, аналогичный по смыслу методу emit старого доброго EventEmitter и в терминах классической событийной модели, у нас фактически Store подписан на события, имена которых называются типами экшенов и которые принято задавать в виде вышеупомянутых констант, в связи с чем у меня постоянно возникал вопрос, почему я должен хранить это где то отдельно, да к тому же повторно прибегая к такому нелепому дублированию кода? Исходная мысль то ясна, нам необходимо подстраховаться от конфликтов и обеспечить некоторую консистентность между экшенами и редюсерами, но не уже ли нельзя сделать это как то элегантней?


Я понимаю, что люди разные и если у кого то возникнет аргументированное возражение на этот мой лёгкий дискомфорт от работы с кодом Redux, буду рад выслушать любые мнения в комментариях, но тем, кто разделяет сие чувство, позвольте представить redux-refine


Идея в основе проста:


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


Так же, такой подход обеспечивает чистую связность кода, следующую логике one way binding и отражающему направление потока данных в приложении, а именно:
мы видим в компоненте, методы какого модуля с экшенами он использует, а в модуле с экшенами мы видим, каким редюсерам он отправляет экшены.


По просьбе tmnhy на наглядном примере поясню:


в экшенах мы делаем так:


import { actionTypes as types1 } from 'reducers/reducer1'
import { actionTypes as types2 } from 'reducers/reducer2'

const { ACTION_1_1, ACTION_1_2, ACTION_1_3 } = types1
const { ACTION_2_1, ACTION_2_2, ACTION_2_3 } = types2

в редюсерах так:


reducer1:


import { getActionTypes, connectReducers } from 'redux-refine'

export const initialState = {
  value1: 0,
  value2: '',
  value3: null,
}

const reducers = {
  ACTION_1_1: (state, {value1}) => ({...state, value1}), 
  ACTION_1_2: (state, {value2}) => ({...state, value2}), 
  ACTION_1_3: (state, {value3}) => ({...state, value3}), 
}

export const actionTypes = getActionTypes(reducers)
export default connectReducers(initialState, reducers)

reducer2:


import { getActionTypes, connectReducers } from 'redux-refine'

export const initialState = {
  value1: 0,
  value2: '',
  value3: null,
}

const reducers = {
  ACTION_2_1: (state, {value1}) => ({...state, value1}), 
  ACTION_2_2: (state, {value2}) => ({...state, value2}), 
  ACTION_2_3: (state, {value3}) => ({...state, value3}), 
}

export const actionTypes = getActionTypes(reducers)
export default connectReducers(initialState, reducers)

в том месте, где Вы предпочитаете комбинировать редюсеры всё по прежнему:


import { combineReducers } from 'redux'

import reducer1, { initialState as stateSection1 } from './reducer1'
import reducer2, { initialState as stateSection2 } from './reducer2'

export const intitialState = {
  stateSection1, stateSection2
}

export default combineReducers({
  stateSection1: reducer1,
  stateSection2: reducer2
})

Да, конечно я понимаю, что это весьма мелочное нововведение, но мне от такого стиля работать с кодом на много приятней :)


И пожалуйста, не судите строго, если что — это мой первый пост на хабре


UPD:


Выхватив множество комментариев, интереснейших, но высказанных с разной долей недопонимания о том, что это вообще такое — redux-refine, я решил добавить ещё более детальное разъяснение:


Вот что я сделал:


1 Заменил конструкцию switch-case на выбор по ключу в хэшэ:


это


function reducer(state, {type, data}){
  switch(type) {
    case 'one': return {...state, ...data};
    case 'two': return {...state, ...data};
    case 'three': return {...state, ...data};
    default: return state;
  }
}

заменил на это


function reducer(state, {type, data}){
  return ({
    one: {...state, ...data},
    two: {...state, ...data},
    three: {...state, ...data}
  })[type] || state;
}

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


const reducers = {
  one: (state, data) => ({...state, ...data}),
  two: (state, data) => ({...state, ...data}),
  three: (state, data) => ({...state, ...data})
}

function reducer(state, {type, data}){
  return (redusers[type] || (state => state))(state, data);
}

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


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


Это не какая то новая архитектура, не какой то там новый наворот — это просто улучшение кодестайла и обеспечение наглядности связей между модулями классического редакса.

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


  1. tmnhy
    21.11.2017 21:08

    Идея в основе проста:

    И где конкретика, примеры кода?


    1. elser Автор
      21.11.2017 21:13

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


      1. tmnhy
        21.11.2017 21:23

        Просто идея слишком проста

        Без примеров неполноценно, а лезть из статьи на сторонний ресурс, чтобы понять что да как, совсем плохо. В принципе, тогда после преамбулы можно было сразу ссылку на репозиторий дать и всё.


        1. elser Автор
          21.11.2017 22:12

          Добавил пояснение, спасибо за подсказку


  1. webdevium
    21.11.2017 21:11
    +1

    Эх. И на кой эта статья? Неужели кто-то, понимающий в языке, не знает, что можно использовать словари?


    1. elser Автор
      21.11.2017 22:33

      Поймите правильно, я не сомневаюсь в профессионализме кого-то понимающего, но всё равно ведь конкретно в этом случае практически везде используются подходы
      компонент -> экшены < — константы -> редьюсеры
      или
      компонент -> экшены < — редьюсеры
      вместо логически правильного
      компонент -> экшены -> редьюсеры
      где стрелкой показано, откуда что импортируется


      1. webdevium
        21.11.2017 22:37

        Понимаю правильно, естественно.
        Но проблема более фундаментальная — слепое наследование кода, который начинающие разработчики копируют у таких же начинающих разработчиков, которые прочли 2-3 страницы из книги и бегут писать статьи с громкими криками «так правильно. просто выключайте мозги. копируйте этот код».


        1. elser Автор
          21.11.2017 22:49

          Согласен, но смириться с положением вещей не в силах, уж простите )))


    1. flancer
      22.11.2017 08:44

      Действительно, а на кой нужна азбука? Неужели читающий слова не знает букв?!


      Кстати, дискуссия под этой "ненужной" статье вполне себе годная. Может для этого?


  1. keenondrums
    21.11.2017 21:12
    +1

    По-большому счету вы изобрели заново redux-actions, с той лишь разницей, то намертво связали имена констант с их значениями. Это плохо потому, что для констант обычно используются довольно короткие имена. Есть очень не маленькая вероятность, то при таком походе при подклюении очередной библиотеки ваши типы экшнов совпадут, и приехали. Решение было предложено в весьма популярном пропосале ducks-modular-redux


    1. elser Автор
      21.11.2017 21:32

      Ну, вообще то в классической концепции, разные редюсеры теоретически могут реагировать на один и тот же экшн, обновляя по нему разные участки стора. Если взглянуть на то, как это выглядит на уровне combineReducers, то в этом нет ничего страшного:

      // псевдо код для пояснения:
      
      combineReducers({
        storeSection1: reducer1,
        storeSection2: reducer2
      });
      
      // классический вариант:
      
      // reducer1
      // здесь state - это storeSection1
      function reducer1(state, {type, data}){ 
        switch(type){
          case SOMETHING_GLOBAL_HAPPENS: return {...state, newValue: data.newValue};
          case SOMETHING_FOR_REDUCER1: return {...state, newValue: data.newValue};
        }
      }
      
      // reducer2
      // здесь state - это storeSection2
      function reducer1(state, {type, data}){  
        switch(type){
          case SOMETHING_GLOBAL_HAPPENS: return {...state, newValue: data.newValue};
          case SOMETHING_FOR_REDUCER2: return {...state, newValue: data.newValue};
        }
      }
      
      // В моём варианте в точности то же самое, но в другом немного codestyle:
      
      // reducer1
      reducer = {
          SOMETHING_GLOBAL_HAPPENS: (state, {newValue}) => { ...state, newValue },
          SOMETHING_FOR_REDUCER1: (state, {newValue}) => { ...state, newValue }
      }
      
      // reducer2
      reducer = {
          SOMETHING_GLOBAL_HAPPENS: (state, {newValue}) => { ...state, newValue },
          SOMETHING_FOR_REDUCER2: (state, {newValue}) => { ...state, newValue }
      }
      


      1. keenondrums
        21.11.2017 23:10

        Это будет работать нормально ровно до тех ор, пока вы контролируете все эти редьюсеры и их логику. А когда используется сторонняя библиотека, то могу быть болшие проблемы. Развязывание экшн типов и констант им соотвествующих — это благо.

        // your reducer
        const initialState = { num: 1 }
        
        TYPE1: ( { num } ) => ({ num: num + 1 })
        
        // third-party reducer which source code you can not affect
        TYPE1: (state) => throw new Error('Blow up the world') 
        

        Еще веселее станет дебажить, если над проектом работает несколько человек, и вы назвали свои экшн типы одниаково


        1. elser Автор
          22.11.2017 00:32

          Давайте ещё раз проясним: разные редьюсеры аффектят разные разделы стора — так устроен combineReducers
          Один редьюсер может использовать redux-refine и тогда модули с экшенами должны импортировать типы экшенов из этого редьюсера, при этом одновременно другой редьюсер может использовать классический стиль и тогда Вам необходимо будет импортировать константы, там, где Вы диспатчите эти экшены
          Одно другому не мешает, а throw Error() из редьюсера свалит апликуху в любом случае, так что не мутите воду, а поймите суть


  1. x07
    21.11.2017 21:19

    Попробуй Mobx, там такого беспредела нет.


  1. elser Автор
    21.11.2017 21:37

    Поддерживаю и даже скажу больше, baobab — тоже вещь! Но корпорейт неумолим и во многих проектах всё таки redux


  1. debounce
    21.11.2017 22:21

    Только подсел на функциональщину, react и redux.

    Подскажите по архитектуре, пожалуйста.
    Переписываю приложение рисовалку — это мегатонны событий mousemove.
    В redux подходе на каждое такое событие нужно генерировать action, а в редюсере возвращать иммутабельный стейт.
    Правильно ли так использовать redux, когда не будет красивых логов ADD_TODO, EDIT_TODO и таймтревела, а будут километры одних только MOUSE_MOVE?


    1. webdevium
      21.11.2017 22:42

      Твой ник тебе в помощь. Или throttle.
      Каждый MOUSE_MOVE будет складировать в очередь свое действие, к примеру, точки движения или цвета.
      И только каждый N-й MOUSE_MOVE будет вызывать полное действие, типа записи в пользовательскую историю движений, как это примерно выглядит в photoshop.


      1. debounce
        21.11.2017 22:56

        дебаунсить — как-то не спортивно). это прощай hover эффект и вообще.


      1. TheShock
        22.11.2017 18:08

        Твой ник тебе в помощь. Или throttle

        Как вы себе представляете debounce в рисовалке? Вот я провел мышкой, нарисовал какую-нибудь линию кривую. И за счет дебаунса вместо кривой она станет прямой — вместо всех точек mousemove будет только первая и последняя.

        В Фотошоп записывается каждое движение мыши, естественно, а не так, как вы говорите. Просто при mouseup все эти движения отмечаются как единое действие.


    1. elser Автор
      21.11.2017 22:43

      В Store приложения Вы храните данные приложения, преимущественно относящиеся к бизнес логике. Разумеется, Вы можете использовать Store для хранения вообще всего чего угодно, но это не означает, что Вы совсем не должны использовать внутренний state компонентов.


      Просто следуйте простым правилам: если это простой переносимый компонент, не реализующий бизнес логику конкретного приложения, то он подучает данные через props и возвращает их в аргументах обработчиков событий, при этом не брезгуя хранением каких либо значений, использующихся только для собственных нужд, в this.state


      Ярким примером такого поведения могут служить controlled components


      1. debounce
        21.11.2017 23:01

        в том-то и дело, что это и есть основной функционал, все остальное будет прикручено react'ом, а вот что делать с событиями мыши?
        мой главный стейт выглядит примерно так

        {
        	"nodes": [{
        		"id": 1,
        		"x": 390,
        		"y": 99
        	}, {
        		"id": 2,
        		"x": 687,
        		"y": 85
        	}, {
        		"id": 3,
        		"x": 738,
        		"y": 321
        	}],
        	"edges": [{
        		"id": 1,
        		"from": 1,
        		"to": 2
        	}, {
        		"id": 2,
        		"from": 2,
        		"to": 3
        	}, {
        		"id": 3,
        		"from": 3,
        		"to": 4
        	}],
        }


        это граф, а само приложение — рисовалка помещений
        image


        1. elser Автор
          21.11.2017 23:37

          Так у Вас же не постоянно движение мыши мониторятся, а в момент выполнения какого то действия, так? на пример при перемещении ноды, или ребра.
          У Вас основная нагрузка приходится не на обновление стора, а на перерисовку UI при его обновлении, так как на пример при перемещении узла, со стороны стора у Вас меняется только объект с данными соответствующего узла, ссылка на массив узлов обновляется, так как в редюсере формируется новый массив (что да, при большом количестве узлов может быть немного тяжеловатым, но это надо ооочень много элементов, так как все, кроме изменяемого — просто копирование ссылок на объекты)
          И меняется сам стор (NextStore !== Store), но те ветки стора, которых изменения не коснулись просто переносятся в новое состояние как есть.
          Далее летит update компонентам и вот тут надо всё разрулить правильно, либо контролируя кто должен перерисоваться, при помощи shouldComponentUpdate, либо используя reselect, для тех же целей (подробнее тут) Но поверьте, самому стору такой поток событий не особо в тягость, особенно, если принять во внимание совет webdevium и всандалить на этом потоке лёгкий троттлинг на малых таймингах


    1. faiwer
      22.11.2017 07:26

      А где именно вы формируете action-"логи"? Если руками, то поддерживайте склейку определённых action-ов (например по маске для action.type). Если в redux-devtools — то так ли сильно оно вам надо?
      А вообще я обычно все подобные mouseHover вещи реализовываю через setState или ещё более обходными путями. Не в последнюю очередь из-за того, что react-redux будет вызывать все свои callback-и для connect-утых компонентов на каждый тик вашего mouseHover. Это, в случае большого приложения, легко может привести к тормозам.
      Вообще говоря, чем дальше в лес, тем больше кода я пишу в обход redux, оставляя там только то, что имеет прямое отношение к бизнес-логике (99% кода), а не к сию-миллисекундному интерактиву, анимациям (не всё удаётся сделать через css).


  1. comerc
    21.11.2017 23:20

    Погуглите ducks-pattern + redux-act. Никаких констант. Экшены с редюсерами живут в одном файле. И тестить это проще. Я опубликовал заметку недавно.


    1. elser Автор
      21.11.2017 23:59

      Экшены с редюсерами живут в одном файле

      ну ок, даже так, сравните это с этим
      код чище, консистентность типов действий согласована на ключах хэша редьюсеров
      Это применимо даже к duck-patterns, потому что не является ими — идея не в этом.


    1. elser Автор
      22.11.2017 00:13

      а что касается redux-act, то там тоже всё наоборот. Разъясню на пальцах — то, что отправляет события определяет их имена и как бы рассказывает стору: «я что то там диспатчу, значит ты должен быть на это подписан», создавая зависимость, направленную в сторону, обратную data flow
      Правильней так: «Стор подписан на определённые действия, отправка которых должна быть имплементирована в экшенах»
      В итоге мы получаем разработку, в которой мы работаем с состоянием приложения, не зависимо от того, что его меняет, так как инициация изменений может происходить в куче разных мест, а прилетают они в единое, четко определённое место и я могу поспорить на счет удобства тестирования — redux-refine тестируется на много лучше и тесты могут быть сколь угодно атомарными


      1. comerc
        22.11.2017 11:49

        Посмотрите первый пример кода в заметке. Я работаю в одном месте с объявлением экшена и реализацией редюсера. Что особенно полезно при поддержке кода — один клик мышкой вместо трех.


  1. faiwer
    22.11.2017 07:46

    Я пришёл к следующей схеме:


    Файл рядового модуля:
    const PRE = 'уникальный_префикс';
    const OPEN = `${PRE}_OPEN`;
    export const open = id => ({ type: OPEN, id });
    
    export const map =
    {
      [OPEN]: (st, { id }) => ({ ...st, id, active: true }),
    };


  1. Fox_exe
    22.11.2017 09:07

    Я чтото не понял, или в статье переизобрели enum и struct?


    1. elser Автор
      22.11.2017 22:32

      Нет, не переизобрели, а просто начали использовать там где давно пора было )))


  1. wheercool
    22.11.2017 11:24

    Redux был построен на идее Free Monad. Если перевести на ООП терминологию то это паттерн Interpretator. Actions — это DSL, а reducers — это Интерпретатор. Основной смысли и преимущество в том, что уровень Actions(DSL) изолирован от того, как он должен интерпретироваться и в любой момент можно для него написать другой интерпретатор (более эффективный, либо с измененной логикой).
    Вы же в Actions делаете связь с reducer, т.е. ваша абстракция начинает зависеть от реализации.
    Я не говорю, что ваше решение плохое, но это уже не redux. Возникает тогда логичный вопрос, а зачем вам вообще тогда Actions?
    Что такое store — это контейнер состояния. Основываясь на том как вы его используете, могу предложить вам перейти просто на классы.

    class Store {
        action1() {
        }
        action2() {
        }
    }
    


    1. elser Автор
      22.11.2017 21:39

      как то вы усложняете ) Всё гораздо проще. Вот что я сделал:
      Заменил конструкцию switch-case на выбор по ключу в хэшэ
      пояснение:


      function choice(selector){
        switch(selector) {
          case 'one': return 1;
          case 'two': return 2;
          case 'three': return 3;
          default: return 4;
        }
      }

      заменил на


      function choice(selector) {
        return ({
          one: 1,
          two: 2,
          three: 3
        })[selector] || 4;
      }

      Затем вынес хэш из скопа функции, что бы не пересоздавать его при каждом вызове, а каждое свойство этого хэша сделал методом, которому передаются необходимые данные в аргументах.
      Затем просто вместо объявления констант заюзал экспорт ключей хэша, так как они по определению уникальны, создав таким образом возможность импортировать эти ключи в экшенах из редьюсеров, что позволяет наглядно показать, с каким редьюсером работает данный модуль с экшенами.
      И не надо городить огород, так как redux-refine работает в точности так же, как и классический редьюсер — именно один в один, почему я и сказал, что можно его юзать одновременно с обычным подходом.
      Это не какая то новая архитектура, не какой то там новый наворот — это просто улучшение кодестайла и наглядности связей между модулями классического редакса.


      1. elser Автор
        22.11.2017 21:49

        Пожалуй это разъяснение тоже не плохо бы добавить в статью :)


      1. faiwer
        23.11.2017 06:50

        А покажите ваши actionCreator-ы. Всё никак не могу их найти.


        1. elser Автор
          24.11.2017 04:00

          Не поверите, там же где и Ваши ))) Они то тут при чем? )


          1. faiwer
            24.11.2017 08:08

            А я не совсем понимаю откуда они возьмут type для своих action-ов. Или просто перепечатываете? Едва ли там () => ({ type: Object.keys(map)[3], ... }).


  1. hubhito
    22.11.2017 16:33

    Покритикуете такой подход?

    // actions.js
    import { createAction } from 'redux-actions'
    export const someAction = createAction('SOME_ACTION')
    
    // reducers.js
    import { handleAction } from 'redux-actions'
    import { Map } from 'immutable'
    import * as actions from './actions'
    
    const someAction = (state, action) => {
      const { value } = action.payload
      return state.set('value', value)
    }
    
    export default [
      handleAction(actions.someAction().type, someAction, Map()),
      // ... other reducers
    ]
    
    // rootReducer.js
    import reducers from './reducers'
    const rootReducer = (state, action) => reducers.reduce((state, reducer) => reducer(state, action), state)