Хотел бы поделиться своим опытом использования redux в enterprise приложении. Говоря о корпоративном ПО в рамках статьи, я акцентирую внимание на следующих особенностях:

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

Первые два пункта накладывают ограничения по запасу производительности. Об этом чуть позже. А сейчас, предлагаю обсудить проблемы, с которыми сталкиваешься, используя классический redux – workflow, разрабатывая что либо, сложнее чем TODO – list.

Классический redux


Для примера, рассмотрим следующее приложение:

image

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

Организация кода:

image

Есть два модуля. Точнее, один непосредственно модуль – poemScoring. И корень приложения с общими для всей системы функциями — app. Там у нас есть информация о пользователе, отображение сообщений пользователю. У каждого модуля свои редьюсеры, экшены, контролы и т. д. По мере роста приложения множатся новые модули.

Каскадом редьюсеров, с помощью redux-immutable, формируется следующий полностью immutable стейт:

image

Как это работает:

1. Контрол диспатчит action-creator:

import at from '../constants/actionTypes';

export function poemTextChange(text) {
  return function (dispatch, getstate) {
    dispatch({
      type: at.POEM_TYPE,
      payload: text
    });
  };
}

Константы типов действий вынесены в отдельный файл. Во-первых, мы так страхуемся от опечаток. Во-вторых, нам будет доступен intellisense.

2. Затем это приходит в редьюсер.

import logic from '../logic/poem';

export default function poemScoringReducer(state = Immutable.Map(), action) {
  switch (action.type) {
    case at.POEM_TYPE:
      return logic.onType(state,  action.payload);
    default:
      return state;
  }
}

Обработка логики вынесена в отдельную case-функцию. Иначе, код редьюсера быстро станет нечитаемым.

3. Логика обработки нажатия, с использованием лексического анализа и искусственного интеллекта:

export default {
  onType(state, text) {
    return state
      .set('poemText', text)
      .set('score', this.calcScore(text));
  },

  calcScore(text) {
    const score = Math.floor(text.length / 10);
    return score > 5 ? 5 : score;
  }
};

В случае с кнопкой «New poem», мы имеем следующий action-creator:

export function newPoem() {
  return function (dispatch, getstate) {
    dispatch({
      type: at.POEM_TYPE,
      payload: ''
    }); 
    dispatch({
      type: appAt.SHOW_MESSAGE,
      payload: 'You can begin a new poem now!'
    }); 
  };
}

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

Проблемы:


Мы разместили наше приложение. Но наши пользователи, увидев, что от них просят сочинять стихи, закономерно принялись постить свое творчество, несовместимое с корпоративными стандартами поэтического языка. Иными словами, нам нужно модерировать нецензурные слова.

Что будем делать:

  • во вводимом тексте необходимо заменять все некультурные слова на *censored*
  • помимо этого, если пользователь вбил грязное слово, нужно предупредить его сообщением о том, что он поступает неправильно.

Хорошо. Нам надо просто по ходу анализа текста, кроме вычисления оценки, произвести замену плохих слов. Не проблема. А еще, для сообщения пользователю, потребуется список того, что мы удалили. Исходный код тут.

Переделываем функцию логики, чтобы она нам в дополнение к новому стейту, возвращала информацию, необходимую для сообщения пользователю (замененные слова):

export default {
  onType(state, text) {
    const { reductedText, censoredWords } = this.redactText(text);

    const newState = state
      .set('poemText', reductedText)
      .set('score', this.calcScore(reductedText));

    return {
      newState,
      censoredWords
    };
  },


  calcScore(text) {
    const score = Math.floor(text.length / 10);
    return score > 5 ? 5 : score;
  },

  redactText(text) {
    const result = { reductedText:text };
    const censoredWords  = [];
    obscenseWords.forEach((badWord) => {
      if (result.reductedText.indexOf(badWord) >= 0) {
        result.reductedText = result.reductedText.replace(badWord, '*censored*');
        censoredWords.push(badWord);
      }
    });
    if (censoredWords.length > 0) {
      result.censoredWords = censoredWords.join(' ,');
    }
    return result;
  }
};

Давайте теперь ее применим. Но как? В редьюсере нам ее вызывать больше смысла нет, т. к. текст и оценку мы в стейт положим, а что делать с сообщением? Чтобы отправить сообщение, нам, в любом случае, придется диспатчить соответствующее действие. Значит, дорабатываем action-creator.

export function poemTextChange(text) {
  return function (dispatch, getState) {
    const globalState = getState();
    const scoringStateOld =  globalState.get('poemScoring'); // Получаем из глобального стейта нужный нам участок
    const { newState, censoredWords }  = logic.onType(scoringStateOld, text);

    dispatch({  // отправляем в редьюсер на установку обновленного стейта
      type: at.POEM_TYPE,
      payload: newState
    });

    if (censoredWords) { // Если были цензурированы слова, то показываем сообщение
      const userName = globalState.getIn(['app', 'account', 'name']);
      const message = `${userName}, avoid of using word ${censoredWords}, please!`;
      dispatch({
        type: appAt.SHOW_MESSAGE,
        payload: message
      });
    }
  };
}

Еще требуется, доработать редьюсер, т. к. он функцию логики больше не вызывает:

  switch (action.type) {
    case at.POEM_TYPE:
      return action.payload; 
    default:
      return state;

Что получилось:

image

А теперь, встает вопрос. Зачем нам нужен редьюсер, который, по значительной части действий, будет просто возвращать payload вместо нового стейта? Когда появятся другие действия, которые обрабатывают логику в экшене, надо будет регистрировать новый тип action-type? Или, может быть создать один общий SET_STATE? Наверное, нет, ведь тогда, в инспекторе будет неразбериха. Значит будем плодить однотипные case-ы?

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

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

Давайте, посмотрим на ситуацию еще с одной стороны. Мы в нашем экшене получаем кусок стейта из глобального. Это необходимо, чтобы провести его мутацию( globalState.get('poemScoring'); ). Получается, мы уже в экшене знаем, с каким куском стейта идет работа. У нас есть новый кусок стейта. Мы знаем куда его положить. Но вместо того, чтобы положить его в глобальный, мы запускаем его с какой-то текстовой константой по всему каскаду редьюсеров, чтобы он прошел по каждому switch-case, и подставился один единственный раз. Меня от осознания этого, корёбит. Я понимаю, что это сделано для простоты разработки и уменьшения связности. Но в нашем случае, это уже не имеет роли.

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

  1. Значительные неудобства при работе со стейтом, находящимся за пределами редьюсера.
  2. Проблема разделения кода. Каждый раз, когда мы диспатчим экшен, он проходит по каждому редьюсеру, проходит по каждому case-у. Это удобно, не заморачиваться, когда у вас приложение небольшое. Но, если у вас монстр, которого строили несколько лет с десятками редьюсеров и сотнями case-ов, то я начинаю задумываться о целесообразности подобного подхода. Возможно, даже с тысячами кейсов, это не сможет оказать существенного влияния на быстродействие. Но, понимая, что при печати текста, каждое нажатие, будет вызывать за собой проход по сотням case-ов, я не могу оставить это так как есть. Любой, самый маленький лаг, помноженный на бесконечность, стремится к бесконечности. Иными словами, если не думать о таких вещах, рано или поздно, проблемы появятся.

    Какие есть варианты?

    a. Изолированные приложения с собственными провайдерами. Придется дублировать в каждом модуле (подприложении) общие части стейта (аккаунт, сообщения, и т.п.).

    b. Использовать подключаемые асинхронные редьюсеры. Это не рекомендуется самим Даном.

    c. Использовать экшен-фильтры в редьюсерах. То есть, каждый диспатч сопровождать информацией о том, в какой модуль он направляется. И в корневых редьюсерах модулей, прописать соответствующие условия. Я пробовал. Такого количества непроизвольных ошибок не было ни до ни после. Постоянная путаница с тем куда отправляется экшен.
  3. Каждый раз при диспатче экшена, происходит не только прогон по каждому редьюсеру, но и сбор обратного состояния. Не важно, менялось ли состояние в редьюсере – оно будет заменено в combineReducers.
  4. Каждый диспатч заставляет обрабатывать mapStateToProps у каждого привязанного компонента, что смонтирован на странице. Если мы дробим редьюсеры, нам приходится дробить диспатчи. Критично ли это, что у нас по кнопке затирание текста и вывод сообщения происходит разными диспачтами? Наверное, нет. Но у меня есть опыт оптимизации, когда снижение количества диспатчей с 15 до 3 позволило существенно увеличить отзывчивость системы, при неизменном количестве обрабатываемой бизнес логики. Я знаю, что есть библиотеки, которые умеют объединять несколько диспатчей в один батч, но это же борьба со следствием с помощью костылей.
  5. При дроблении диспатчей, порой очень сложно посмотреть, что же все-таки происходит. Нет одного места, все разбросано по разным файлам. Искать, где реализована обработка, приходится через поиск констант по всем исходникам.
  6. В приведенном коде, компоненты и экшены обращаются напрямую к глобальному стейту:

    const userName = globalState.getIn(['app', 'account', 'name']);
    …
    const text = state.getIn(['poemScoring', 'poemText']);

    Это нехорошо по нескольким причинам:

    a. Модули, в идеале, должны быть изолированными. Они не должны знать в каком месте стейта они живут.

    b. Упоминание одних и тех же путей в разных местах многократно чревато не только ошибками/опечатками, но и крайне затрудняет рефакторинг в случае изменения конфигурации глобального стейта, либо изменения способа его хранения.
  7. Все чаще во время написания нового экшена, у меня возникало впечатление, что я пишу код ради кода. Допустим, мы хотим добавить на страницу чек бокс и отразить его булевое состояние в стейте. Если мы хотим единообразную организацию экшенов / редьюсеров, то нам придется:

    — Зарегистрировать константу экшен-тайпа
    — Написать экшен-крейтор
    — В контроле импортировать его и прописать в mapDispatchToProps
    — Прописать в PropTypes
    — Создать в контроле handleCheckBoxClick и указать его в чек боксе
    — Дописать свитч в редьюсере с вызовом case-функции
    — Написать в логике case-функцию

    Ради одного чек бокса!
  8. Стейт, который генерируется с помощью combineReducers — статичный. Не важно, заходили вы в модуль B уже или нет, данный кусок будет в стейте. Пустой, но будет. Не удобно пользоваться инспектором, когда в стейете куча неиспользуемых пустых узлов.

Как мы пытаемся решать часть из вышеописанных проблем


Итак, у нас получились бестолковые редьюсеры, а в экшен-крейторах / логике мы пишем портянки кода для работы с глубоко вложенным immutable – структурами. Чтобы избавить от этого, я использую механизм иерархических селекторов, которые позволяют произвести не только доступ к нужному куску стейта, но и провести его замену (удобный setIn). Я опубликовал это в пакете immutable-selectors.

Давайте, на нашем примере разберем как это работает (репозиторий):
Опишем в модуле poemScoring, объект селекторов. Мы описываем те поля из стейта, к которым мы хотим иметь непосредственный доступ на чтение/запись. Допускается любая вложенность и параметры для доступа к элементам коллекций. Не обязательно описывать все возможные поля в нашем стейте.

import extendSelectors from 'immutable-selectors';

const selectors = {
  poemText:{},
  score:{}
};

extendSelectors(selectors, [ 'poemScoring' ]);

export default selectors;

Далее, метод extendSelectors превращает каждое поле в нашем объекте в функцию- селектор. Вторым параметром указывает путь до той части стейта, которой управляет селектор. Мы не создаем новый объект, а изменяем текущий. Это дает нам бонус в виде рабочего интеллисенса:

image

Что из себя представляет наш объект – селектор после его расширения:

image

Функция selectors.poemText(state) просто выполняет state.getIn(['poemScoring', 'poemText']).

Функция root(state) – получает 'poemScoring'.

Каждый селектор имеет свою функцию replace(globalState, newPart), которая через setIn возвращает новый глобальный стейт с заменённой соответствующей части.

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

selectors = {
  dive:{
    in:{
      to:{
        the:{
          deep:{}
}      }    }  }}

То получить deep можно как selectors.dive.in.to.the.deep(state) или как selectors.flat.deep(state).

Идем дальше. Нам нужно обновить получение данных в контролах:

Poem:
function mapStateToProps(state, ownprops) {
  return {
    text:selectors.poemText(state) || ''
  };
}


Score:
function mapStateToProps(state, ownprops) {
  const score = selectors.score(state);
  return {
    score
  };
}

Далее, меняем корневой редьюсер:

import initialState from './initialState';

function setStateReducer(state = initialState, action) {
  if (action.setState) {
    return action.setState;
  } else {
    return state;
    // return combinedReducers(state, action); //
  }
}

export default setStateReducer;

При желании можем комбинировать с использованием combineReducers.

Экшен-крейтор, на примере poemTextChange:

export function poemTextChange(text) {
  return function (dispatch, getState) {
    dispatch({
      type: 'Poem typing',
      setState: logic.onType(getState(), text),
      payload: text
    });
  };
}

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

И, собственно, сама логика:

 onType(gState, text) {
    const { reductedText, censoredWords } = this.redactText(text);

    const poemState  = selectors.root(gState) || Immutable.Map(); // извлечение нужного куска стейта

    const newPoemState = poemState  // мутация
      .set('poemText', reductedText)
      .set('score', this.calcScore(reductedText));

    let newGState =  selectors.root.replace(gState, newPoemState);  // создание нового стейта

    if (censoredWords) {   // если требуется, стейт дополняем сообщением
      const userName = appSelectors.flat.userName(gState);
      const messageText = `${userName}, avoid of using word ${censoredWords}, please!`;
      newGState = message.showMessage(newGState, messageText);
    }

    return newGState;
  },

При этом, message.showMessage импортируется из логики соседнего модуля, в котором описаны свои селекторы:

  showMessage(gState, text) {
    return selectors.message.text.replace(gState, text);
  }.

Что получается:

image

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

Как еще это можно применять?


Данный подход удобно использовать, когда необходимо добиться, чтобы ваши контролы или модули обеспечивали работу с разными кусками стейта. Допустим, нам мало одной поэмы. Мы хотим, чтобы пользователь мог на двух параллельных вкладках сочинять поэмы по разным дисциплинам (детская, романтичная). В таком случае мы можем не импортировать селекторы в логике / контролах, а указать их параметром во внешнем контроле:

        <Poem selectors = {selectors.сhildPoem}/>
        <Poem selectors = {selectors.romanticPoem}/>

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

Ограничения при использовании immutable-selectors:

Не получится использовать ключ в состоянии «name», т. к. для родительской функции будет попытка переопределить зарезервированное свойство.

Что в итоге


В итоге, получился довольно гибкий подход, исключены неявные связи кода по текстовым константам, уменьшены накладные расходы при сохранении удобства разработки. Так же остался полность функционирующий redux inspector с возможностью time travel. У меня желания возвращаться на стандартные редьюсеры нет никакого.

В общем-то, все. Благодарю за уделенное время. Может быть, кому-то будет интересно опробовать!

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


  1. faiwer
    27.09.2018 18:42
    +2

    Я может не до конца понял суть (наверное), но вы, кажется, избавились вовсе не от reducer-ов, а скорее сократили цепочку actionCreator -> action -> reducer -> view, до something -> view. Т.е. впервую очередь выпилили сами action-ы. Сделали там монолит. Вы не первый ;) Где something это всё-в-одном. Нечто подобное я сегодня читал в документации в vuex.


    Касательно тех 8 пунктов-претензий. Я думаю мы все сталкивались с ними, но каждый с ними сражался как-то по своему.


    Вот, скажем, вам не нравится, что в базовом виде, action пробегает через множество switch-case-ов. Точнее через все. Это позволяет менять на 1 action store сразу в нескольких местах. Мне такая возможность сразу показалась проблемной, и я от неё отказался без задней мысли. У меня в reducer-ах нет никаких switch-ей. Прямое сопоставление action.type к map-е, которая его обрабатывает. 1 action = 1 handler. Никаких проблем с "путаницей" что-куда-где у меня не было. Там всё предельно просто и железно, т.к. у action-ов есть свои префиксы, исходя из вложенности.


    Или вот вы пишете, что есть большие проблемы с доступом к той части state, которая напрямую не касается текущего вложенного reducer-а, но нужна для правильной работы логики в нём (read-only естессно). У меня нет таких проблем, т.к. мои вложенные reducer-ы имеют такую сигнатуру, которая мне нужна в этом месте. Т.е. любую. И обычно rootState в ней идёт 3-им аргументом. Да и вообще я выстраиваю reducer-ы по ситуации. К примеру могу весьма специфически обрабатывать их результат (а не просто вернуть его же как значение поля). Очень помогает в рамках IoC.


    Так же вы писали, что вам не нравится, что доступ к rootState-у в mapStateToProps захламляет код. Ну я просто использую custom-ый connect-метод, который это учитывает. Долго объяснять механику. Но суть в том, что вообще никто не заставляет вас использовать обычный connect. У вас есть subscribe метод, и вы можете реализовать хоть иерархический, хоть сколь угодно другой причудливый HoC. Скажем как бы вы реализовали что-то вроде excel-я? Надо будет включить мозг и писать своё решение.


    Вы писали про груды кода в actionCreator-ах. Тут у меня ключевое отличие. У меня в них практически никогда нет никакого кода. Только если речь касается какой-то специфики (типа асинхронных api-запросов). Да и там я стараюсь писать actionCreator-ы предельно тупыми. Мне кажется это последнее место для реализации серьёзной бизнес-логики. По сути все сложные (не-DOM) вещи у меня собраны в 1) reducer-ах, 2) в selector-ах.


    Затем вы пишете, что очень мешает то, что каждый action завязан на множество файлов. В одном reducer, в другом константа, в третьем только import, в четвёртом ещё бог знает что. Я довольно быстро пришёл к схеме, когда у каждого под-модуля есть свой файл redux.js, где сразу и reducer (по сути просто map-object где ключи = action.type), и actionCreator-ы, и selector-ы (если надо). Это позволяет избавиться от львиной доли мусорных экспортов и импортов.


    Небольшой пример такого очередного redux.js:


    const A = actionFactory('PREFIX'); // make a fabric
    
    export const aDel = A.create('DEL', 'id');
    export const aClose = A.create('CLOSE');
    export const map =
    {
      [aDel](st, { id }){ /*...*/ },
      [aClose](st){ /* ... */ },
    };


    1. dmitrii-khr Автор
      28.09.2018 08:41

      Помимо желания поделиться своим опытом (о чем многим, уверен, было бы интересно почитать подробнее), в вашем комменте читается желание покритиковать. Но вот только я пока не понял что именно))
      Создатели Redux не принуждают зацикливаться на дефолтном combineReducers, чем пользуетесь и Вы и я.

      Позвольте только вопрос. Насколько понимаю, action-type у вас является константа вида «A.B.C» (либо массив/объект). Вы его импортируете из того же файла где лежит и хэндлер. А потом, диспатчем бросаете тип в «черный ящик», который, по факту, тут же вызывает ваш хендлер по принципу reducer['A']['B']['C'](state, action). В чем сакральный смысл такого непрямого вызова? Активно пользуетесь мидлварами? Или просто удобно часть «модульной» логики класть в редьюсеры вне хэндлеров?

      Про эксель — решал бы в лоб.
      Рендерятся те ячейки, что помещаются в видимую область. Каждая ячейка имеет адрес и данные в стейте в Map-е data[address]. С помощью immutable-selectors удобно получать данные из коллеккций. Объект — дерево выглядел бы таким образом:

      const selectors  = {
        data:{
          param:'x'
        }
      };

      Ячейки привязаны стандартным connect через mstp вида
      return{
        value:selectors.data(state, ownProps.address)
      }


      По изменению ячейки, либо блуру, вызов action-creator-а
      export function onChange(address, newValue) {
        return function (dispatch, getState) {
          dispatch({
            type:'Изменение ячейки',
            setState: selectors.data.replace(getState(), newValue, address),
            payload: { address, newValue }
          });
        };
      }


      Судя по имеющемуся опыту, каких либо проблем быстродействия тут быть не может. Даже если добавить вычисление эпических формул между ячейками.


      1. faiwer
        28.09.2018 09:21

        Позвольте только вопрос. Насколько понимаю, action-type у вас является константа вида «A.B.C» (либо массив/объект). Вы его импортируете из того же файла где лежит и хэндлер. А потом, диспатчем бросаете тип в «черный ящик», который, по факту, тут же вызывает ваш хендлер по принципу reducer['A']['B']['C'](state, action). В чем сакральный смысл такого непрямого вызова? Активно пользуетесь мидлварами? Или просто удобно часть «модульной» логики класть в редьюсеры вне хэндлеров?

        Честно говоря, я вас не понял. Но попробую ответить:


        1. action-type-ом у меня являются строки, в которых есть префиксы
        2. action-type-ы никуда не импортируются (а зачем?)
        3. вместо них импортируются actionCreator-ы для mapDispatchToProps
        4. "reducer"-ы не вызываются по принципу reducer[prefix1][prefix2][prefix3]. reducer-ы не являются какими-то очень универсальными, работающими по какому-то конкретному механизму. Они могут быть произвольными. Ну вот так например:

        import { map as mod1 } from 'mod1/redux';
        
        // reducer in the middle
        export default (state, action, rootState) =>
        {
          if(action.type.startsWith('mod1'))
          // or if(action.type in mod1)
          {
            const handler = mod1[action.type];
            const field1 = handler(state.field1, action, rootState);
            return { ...state, field1 };
          }
        
          if(action.type in ownMap)
            return ownMap[action.type](state, action, rootState);
        
          throw new Error(`unsupported action-type: ${action.type}`);
        }

        По сути ? тут идёт проверка на action-type и перенаправление его вложенному reducer-у. Предполагается, что вложенный reducer работает с каким-то конкретным полем. Вызываем его с нужным ему куском state-а и по результату меняем его же поле в state.


        Пример типовой, по факту же всё может быть совсем по-другому, т.к. real life задачи бывают сильно сложнее и причудливее. Но логика та же — в вышестоящем reducer-е мы можем произвольным образом вызывать нижестоящие. Если там удобно написать что-то хитрое — пишем. Если там всё просто — стараемся сделать всё предельно декларативно, почти без кода.


        диспатчем бросаете тип в «черный ящик»

        Понравилась формулировка. Я так понимаю, этим вы хотели сказать, что нижестоящий reducer не знает как с ним будет работать вышестоящий reducer и будет ли вообще. Да? Ну дак это прекрасно, имхо. IoC в действии.


        Активно пользуетесь мидлварами?

        Только thunk, не более. Схема простая как валенок, middlewares не требуются.


        Или просто удобно часть «модульной» логики класть в редьюсеры вне хэндлеров?

        Тут пожалуй я окончательно запутался. Что есть "модульная логика" и что есть "хэндлер"? В reducer-ах у меня есть "кастомная" логика по распределению ответственности — какой конкретно подредсьсер будет ответственным за какие типы action-ов, и какие куски state-а его должны интересовать, и как их обрабатывать. То как это сделать в конкретном месте — решается согласно задаче. По сути так, как это будет удобнее. Чем меньше нижестоящий reducer знает лишнего, тем лучше, не его ума дело. Поэтому сигнатуры от задачи к задаче могут меняться. Ладно, что-то меня понесло в дебри.


        Ячейки привязаны стандартным connect через mstp вида

        Ну вот тут мы и приехали. Сразу. Судите сами: у вас на экране 20 колонок и 30 строк. Это 600 ячеек. Сразу 600 subscribe-ов к connect-у. Которые будут отрабатываться вообще на любой чих. Какую часть store вы бы не изменили, вызываются ВСЕ АБСОЛЮТНО подписчики. Берём экран побольше — теперь у нас не 20х30, а скажем 25х40 = 1000. Беда. Усложняем ячейки (они же по своей природе могут быть сложносоставными). Получаем 2000, 3000, 4000… И т.д… Так проект не взлетит.


        В чём проблема? В том что у вас вызываются тысячи mapStateToProps. Они что-то делают (обычно они легковесные). Их результат подвергается shallow-comparison проверке. Чем больше полей, тем дольше. Практически всегда проверка выдаёт, что данные те же, и дальнейший render не требуется. Приемлемо ли это? Нет, конечно. Есть ли нормальные пути решения? Да, конечно.


        Как решать? Ну первое что приходит в голову, это иерархический connect. Скажем если поменялось что-то вне таблицы, то зачем вызывать эти наши тысячи mapStateToProps? Стало быть subscribe на таблицу должен быть 1, а в нём уже кастомная логика дилегирования нижестоящим звеньям. Ну дальше надо уже плотно думать опираясь на задачу. По сути говоря задача написать такой "connect" который опираясь на кастомную логику задачи будет дёргать как можно меньше лишних участков кода. Из коробки redux даёт доступ к глобальной области всем connect-ам. Ввиду этого вынужден вызывать вообще все subscribers.


        Судя по имеющемуся опыту, каких либо проблем быстродействия тут быть не может. Даже если добавить вычисление эпических формул между ячейками.

        Я надеюсь это была шутка )


        1. dmitrii-khr Автор
          28.09.2018 14:42

          Получаем 2000, 3000, 4000… И т.д…

          Я однажды видел реализованную в экселе картографию. Реально, карта России, нарисованная пиксельно в ячейках. Внутри карты так же пиксельно раскрашивались регионы в соответствии с показателями. Среди всей гаммы чувств, при виде этого, особенно сильными были «Офигеть!» и «Нафига?». Вы, видимо, собираетесь не меньше чем сапера замутить)))

          Если серьезно, то вот я взял первый попавшийся реальный файл с постановкой. На фулскрине — 12*16 ячеек. Я уверен, что даже 400 привязанных ячеек(по одному значению) не вызовут заметных лагов на блур. Другой вопрос, что это, как Вы заметили, «архитектурно не оптимально». Однако, пока это дешево в реализации, решает задачу и не лагает, это может быть приемлемо.

          Connect со своей логикой — это круто. Надо будет поковыряться на досуге в этом направлении. Может быть есть где почитать кроме исходников react-redux?

          Еще вариант оптимизации на стандартном connect — это сделать составной ключ строка*столбец, аналогично хранить данные data[row][col]. Привязать только таблицу, сделать отдельным компонентом строки. Строки и ячейки как PureComponent. При рендере, компонентам раздавать свои данные. Количество shallow-comparison проверок будет много меньше.


          1. faiwer
            28.09.2018 15:04

            Однако, пока это дешево в реализации, решает задачу и не лагает, это может быть приемлемо.

            Ну тут судите сами. 12*16 это мелочь. Это не файл. Скажем когда мы делали на knockout аналог объектного-word-а, нам нужно было обрабатывать документы >1500 стр… В них были таблицы на сотни страниц. В них были объединённые ячейки на тысячи строк. В общем был масштаб. Такого же рода может быть и excel-документ гос-го толка. Отсюда простейший вывод, если вы правда делаете excel, то без виртуального скроллинга вы никуда не уедете. А ещё вам придётся учесть, что уж больно много элементов на экране, к тому же они могут быть весьма сложно устроены. Прямая реализация в лоб невозможна. Надо будет разбираться в используемых технологиях, алгоритмах, структурах данных, к самим подходам к таким вещам и пр. и пр… В частности никаких стандартных mapStateToProps в тысячах или десятках тысяч instance-ов там быть не может. Это же чудовищная bigO.


            А теперь вернёмся к примеру про 12*16 и примитив. У вас на любой чих вызывается минимум 192 mstp. На это уходит батарейка. И ресурсы. Вспомните про старые андроид трубки. В общем так мыслить нельзя. Если у вас 99.999% работы проходит в пустую, то надо менять подходы. Желательно до реализации, а не после.


            Потом на нас жалуются, что на наших десктопных машинах по 32 GiB памяти, SSD и пр. и пр., и у нас "не тормозит", поэтому мы пишем тяп-ляп. А у клиентов всё бывает очень грустно. В дев-тулзах даже есть специальные замедлители.


            Может быть есть где почитать кроме исходников react-redux?

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


  1. apapacy
    28.09.2018 14:33
    +1

    Наверное у Вас интересный подход. Но не оень пока понятно в чем именно онзаключается. Я бы предложил Вам убрать в статье все ссылки на «классический» redux т.к. его достоинства и недостатки известны достаточно хорошо.
    Я не так много писал на redux и для себя сделал некотороые выводы по поводу того как это можно немного упростить.
    1. Я не пропускаю весь ввод с клавиатуры в формах через redux. Т.к. по сути изменение сосотояния приложения произойдет после отправки данных на сервер. (Использую state)
    2. Я совместил определение констант экшшинов и редьюсера в одном файле (пдосмотрел у github.com/erikras/react-redux-universal-hot-example)

    С моей точки зрения вопрос с redux в ближайшее время перейдет в немного другую плоскость. Т.к. грядет react suspense см. habr.com/post/350816 которое как мне кажется будет сильно конфиликтовать с логикой redux.

    Если уже указывать на глобальный недостаток redux я бы назвал в первую очередь его имперавтивный (не декларативный) подход к разраотке приложения. Я больше склоняюсь к широкому использованию связки graphql/apollo см. habr.com/post/358942 которые позволяют декларативно описывать состояние приложения.


    1. to0n1
      28.09.2018 15:07

      Я совместил определение констант экшшинов и редьюсера в одном файле


      этот подход называется duck modules


    1. dmitrii-khr Автор
      28.09.2018 15:21

      Подход в следующем:

      1. Обрабочтики компонет (экшены) непосредственно мутируют глобальный стейт. Диспатчем просто новая версия кладется в store
      2. Для удобства работы с глобальным глубоко вложенным стейтом, используется библиотека.


      Я не пропускаю весь ввод с клавиатуры в формах через redux. Т.к. по сути изменение сосотояния приложения произойдет после отправки данных на сервер. (Использую state)

      Это у вас форма ввода слишком простая. От значения вашего контрола ничего не зависит. Даже в статье, это не так.
      За ссылки спасибо


      1. faiwer
        28.09.2018 15:56

        Если кратко охарактеризовать сущность вашей схемы в отличии от базовой:


        В самой в базовой схеме все actionCreator-ы обязаны создавать plain object action с полем type. В расширенной для асинхронности добавлены redux-thunk или redux-saga или ещё чего. В вашем случае видимо redux-thunk. Вы используете его для получения getState. В базовой схеме reducer выполняется над данными после dispatch-а action-а в store. У вас же наоборот, reducer выполняется в обход redux-а, когда душе угодно, а в redux уже уходят готовые данные для замены. В базовом виде reducer-ы дробятся иерархично, и каждому подредьюсеру доступен свой кусок state-а. У вас всё работаете через getState и линзы-селекторы, т.е. "глобально". Ну и в целом если подвести — в базовом виде action-ы, actionType-ы, actionCreator-ы, reducer-ы — всё отдельные сущности, с друг другом связанные лишь косвенно (к примеру action.type-ом). В этом философия ынтерпрайза на redux. У вас это одна большая сущность, которую при желании можно декомпозировать, но можно и не декомпозировать.


        Подходов когда выбрасывается большая часть redux цепи уже много. Ваш от большинства тех, что я видел отличается тем, что getState вызывается до dispatch-а plain action-а. Т.е. у вас "внешний" reducer. А потом все action-ы thunk-уты. Обычно делали не так, то что у вас называется setState и является скорее newState: data, обычно это делают методом, который вызывается всё таки из reducer-а, а не за его пределами. Хотя результат тот же.


        Все эти подходы напоминают Vuex. Там посмотрели на redux, посмотрели на vue, взяли redux и написали с нуля, но выкинули практически всю ынтерпрайзную бюрократию, добавили какой-то своей мути (модули), добавили к этой мути костылей, получили довольно компактное решение. В живую ещё не пробовал, но после пары лет redux-а — подход Vuex выглядит гораздо аппетитнее. Особенно ввиду отсутствия необходимости морочить себе голову с immutable-значениями, там вместо этого observable-реактивность. Планирую следующий небольшой проект сделать на vue+vuex чтобы распробовать.


        1. serf
          28.09.2018 19:40

          Полистал сейчас vuex доку пару минут. Получается ридьюсеры у них называются mutations, при этом мутировать можно напрямую. Полагаю в мутатор функцию аргументом приходит прокси стейта, поэтому можно и напрямую. Получается у них там mobx-like стор с мутаторами (actions в случае mobx). Например с immer тоже можно мутировать стейт напрямую (библиотеку запилил тот же чувак что и mobx, полагаю это и есть кусок функционала из mobx), только там есть разные особенности — с некоторыми видами стейта он не работает (просто висит бесконечно), например с перелинкованными между собой данными. PS и actions в vuex тоже есть, сразу не заметил.

          Я не вижу особенной громоздкости в redux подходе. Если экшены объявлять компактным образом используя github.com/pelotom/unionize — like библиотеку и редьюсеры писать с immer/hydux-mutator подобной библиотекой хелперов иммутабельного патчинга стейта. Зато меньше «магии» чем с mobx подобными штуковинами.


          1. faiwer
            28.09.2018 19:50

            Полагаю в мутатор функцию аргументом приходит прокси стейта, поэтому можно и напрямую.

            Там (Vue) под капотом observer. Это совсем другой подход к реактивности. Наиболее классический. Никакой иммутабельности, shallow comparison и пр. Мутировать можно что угодно, где угодно и когда угодно, и всё будет работать. Vuex позволяет это всё как-то систематизировать в духе Flux/Redux. Своего рода добровольные кандалы во имя порядка. Насколько Vuex популярно в мире Vue — не знаю. Mobx не пробовал пока, за него не скажу ничего (но людям нравится).