javascript

Restate — или как превратить бревно Redux в дерево

  • воскресенье, 7 января 2018 г. в 03:13:06
https://habrahabr.ru/post/346116/
  • Ненормальное программирование
  • ReactJS
  • JavaScript


История развития IT намного интереснее любой мыльной оперы, но пересказывать ее мы не будем. Скажем только, что были свидетили принципа «data-driven», адреналинщики с two-way-binding и беспредельщики без принципов и понятий.
Бог создал людей сильными и слабыми. Сэмюэл Кольт сделал их равными.
Примерно тоже самое сделали Flux и Redux.

Была только одна проблема — Redux по сути своей крайне примитивная хреновина, и чтобы с ним хоть как-то работать надо было добавить парочку middleware — thunk, saga, observable и так далее.

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



Для лучшего понимания проблемы — давайте начнем с азов, и сделаем TODO.

React вариант


Реакт все любят за компонентную модель, и вообще за то, что он очень «composable». Можно взять почти любой компонент, обернуть в 10 других и он будет работать так, как тебе надо.(запомните эту фразу)

// Создадим список TODO
const TODOs = [todo1, todo2, todo3];

// Передадим приложению как пропс
const Application = <TodoList todos={TODOs} />

// Определим TodoList, который прокинет пропсы конечному элементу
const TodoList = ({todos}) => (
  <ol>
    {todos.map( todo => <Todo key={todo.id} {...todo} />
  </ol>
);

// Ну и сам TODO очень просто
const Todo = (props) => <div>......</div>

Все хорошо, но встает вопрос о том что делать с событиями, как это все вообще контролировать

Простой Redux вариант


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

// Создадим стор
const store = createStore({
   todos: [todo1, todo2, todo3]
});

// Создадим приложение
const Application = <Provider store={store}>
 <ConnectedTodoList/>
</Provider>

// Коннектим компонент к стору. Нам нужны все TODO
const ConnectedTodoList = connect(
   state => ({todos: state.todos}),
   { onTodoClick }
)(TodoList)

// Далее все тоже самое
const TodoList = ({todos}) => (
  <ol>
    {todos.map( todo => <Todo key={todo.id} {...todo} onClick={() => onTodoClick(todo.id)} />
  </ol>
);
....

Это пример плохого redux приложения, и именно так выглядит оригинальный пример из репозитория redux.

Проблемы тут две:
— миксуем React и Redux
— при изменении любого Todo происходит перерендер всего — и списка Todo и каждого из Todo.

Более правильный Redux


Правильный вариант от не правильного особо то и не отличается

// Коннектим компонент к стору. Нам нужны все TODO
const ConnectedTodoList = connect(
   state => ({todos: getOnlyIds(state.todos)}), <----- изменение вот тут
   { onTodoClick }
)(TodoList)

const TodoList = ({todos}) => (
  <ol>
    {todos.map( id => <ConnectedTodo key={id} id={id}  /> <----- вот тут
  </ol>
);

// Конектим Todo к стору
const ConnectedTodo = connect(
   (state,props) => ({...state.todos[props.id])}), <----- и вот тут
   { onTodoClick: () => dispatch => dispatch(onTodoClick(props.id)) }
)(Todo)
....

Чем это лучше? TodoList не зависит от содержимого конкретного Todo, реагируя только на сам факт наличия. Ну а Todo — сам сходит за данными, и сам свой Id в onTodoClick добавит.

По сути — добавить «больше» редакса тут уже нельзя. Заодно React механизм прокидывания данных от родителей к детят более не используется. Как результат — никакая композиция более не возможна.

Какая композиция?


Хороший пример «проблемы» — это пример Tree-view, опять же из официального репозитория.

Технически — тот же самый TODO-list, только появляется «вложеность». «Вложености» там правда нет, потому что «стор» совершенно плоский, и любую, самую глубокую ноду можно адресовать по Id. И проблема не в этой структуре данных, а в том, что другую использовать банально не получается!

Все потому, что Node, встречая для отрисовки дочерних Node рендерит ConnectedNode, а в случае reduxа — все Connected компоненты равны, и конектятся непосредственно к стору.
И, если компоненты равны, они еще и должны совершить совершенно одинаковое действие — взять элемент массива с известным Id.

Другими словами — Компоненты работают независимо от своего положение в дереве, и совершенно нельзя взять компонент, обернуть еще в 10, и заставить работать так как вам нужно — он будет работать всегда одинаково. Предсказуемо — конечно же да. Только хотели ли мы это?

Банально — попробуйте переделать любой TODO список в список-списков, аля Trello — прийдется переписывать примерно все, и уж точно каждый connect.

И если проблема именно в полном игнирировании reduxом вложености компонентов — возможно именно это и требуется починить. В том смысле, что

Хватит это терпеть!


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


Вот только на самом деле это немного разные вещи. Проблема была в двунаправленности событий, которая порождала каскадные апдейты, которые было практически невозможно контролировать.
Плюс — 99% примеров продолжают использовать стандартный подход Reacta, который как раз и позволяет правдами или не правдами прокинуть некий секретный Id в ребенка, чтобы он потом смог прочитать данные из стора.

Просто это можно сделать немного более проще, и правильнее.

Redux-restate


Redux-restate (github) — миниатюрная (50 строк*) библиотека, которая принимает один или более стор на вход, и совершает над ними некие операции и выдавает стор на выход.
Сразу стоит уточнить — колличество сторов не меняется! restate это view в базе данных, или transformation в mobx, или линза, или mapStateToProps(только ToState). Это не «стор».

На самом деле restate это три отдельный пакета:
— redux-restate — нижний уровень, делай что хочешь
— react-redux-restate — обвязка вокруг реакта — получить стор из контекста, и положить обратно (узкий момент)
— react-redux-focus — упрощенный вариант restate. Для использования в 99% случаев.

Работает все просто:
— При «погружении»(создании стейта) можно програмно менять те данные, которые будут доступны для детей
— При «всплытии»(обработке событий) можно дополнять ивент нужными данными, чтобы правильно сформировать конечную команду.

«Погружение» — это просто однонаправленное действие, которое берет стейт на вход и давает стейт на выход. При этом очень важно мемоизировать промежуточный результат тем же recompose, так же как это делается в mapStateToProps.

const composeState = (state, props) => ({
   ...state,
   part: recompose(operationOn(state))
});

«Всплытие» — однонаправленое действие, которое выполняется когда подчиненный компонет вызывает dispatch. Его смысл — добавить ту «специфичность», что была убрана в момент создания стейта.

 const routeDispatch = (dispatch, event, props) => dispatch({...event, somethingFrom: props.data });

Пример «еще более правильного» redux:

// Коннектим компонент к стору. Нам нужны все TODO
const ConnectedTodoList = connect(
   state => ({todos: getOnlyIds(state.todos)}), 
   { onTodoClick }
)(TodoList)

const TodoList = ({todos}) => (
  <ol>
    {todos.map( id => <RestatedTodo key={id} id={id}  /> <----- изменение вот тут
  </ol>
);

// Конектим Todo к стору (reactReduxFocus - простой самый простой API для одного стора)
const RestatedTodo = reactReduxFocus(
   (state, props) => ({todo: state.todos[props.id]}), // оставить в сторе _только_ todo
   (dispatch, event, props) => dispatch({...event, id: props.id}); // добавить в ивент todoID
)(ConnectedTodo)

// Конектим Todo к стору
const ConnectedTodo = connect(
   (state,props) => ({...state.todo}), <----- тут стало проще
   { onTodoClick } <---- тут стало проще
)(Todo)
....

Как сайд эффект получаем автоматический areStatesEqual — если вычисленный стейт shallowEqual старому значению — распространение изменения прекращается, что позволяет изолировать различные части приложения друг от друга.

Пример с деревом теперь можно написать «более» правильно

  const RestatedNode = reactReduxFocus(
     (state, props) => ({
        ...state,
        // перекрываем node данными ребенка
        node: state.node.children[props.nodeId]
     }),
     (dispatch, event, props) => dispatch({
         ...event, 
        // сохраняем в nodeId хлебные крошки пройденого пути
         nodeId:[props.nodeId, ...event.nodeId]
      });
  )(ConnectedNode);
  
  const ConnectedNode = connect(
    state => {
      ...state,
      children: state.node.children,
      leafs: state.node.leafs
   }........);
  )(Node)

И все — вкладывай RestatedNode друг в друга — reactReduxFocus все аккуратно в начале разберет, а потом соберет обратно все хлебные крошки во диспача команды.

Остается вопрос как же вернуться в нормальному стору, когда «синтетический», «в котором ничего нет» больше не нужен. Ну и становиться нужен reactReduxRestate, который принимает больше одного стора на вход.

  import {createProvider} from 'react-redux'
  import reactReduxFocus from 'react-redux-focus'
  import reactReduxRestate from 'react-redux-restate'
 
 const Provider = createProvider('realStore');

// restate будет читать из 'realStore', а писать - в 'store', с которым работает connect
 const FocusedComponent = reactReduxFocus(..., ..., {
   storageKey: 'realStore'
 })(SomeComponent);

 const RestatedComponent = reactReduxRestate({
    // будет произведен коннект с default стором ('store') и 'realStore'
    // после чего можно будет читать данные и из реального стора, и из синтетического. Вдруг там что-то интересное есть?
    realStore: 'realStore'
  },..., ...)(AnotherComponent)

 <Provider store={realStore}>
   <FocusedComponent >
     <RestatedComponent /> - подключен сразу к двум "сторам"
     <FocusedComponent /> - подключен только к реальному, несмотря на то, что живет в синтетическом
   </FocusedComponent
 </Provider> 

В общем — исходных код всех трех компонент реально занимает пару десятков строк, но открывает столько вариантов для… композиии.

В принципе — именно это и требовалось.

А что, так можно было?


Restate далеко не пионер в данном вопросе. Например electron-redux занимается «почти» что этим же — соединяет два стора (main и render), один из который не настоящий, да еще и роутит dispatchи из одного в другой.

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

Или взять «систему опций» Яндекс.Карт. Она вообще не так чтобы очень известная (публична и документированна), но всякий кто хоть раз использовал API Яндекс Карт могли заметить как там красиво «каскадируются» опции — есть цвет полилинии задается на уровне карты, то опция называется geoObjectStrokeColor, а если на уровне геообьекта — strokeColor.

В общем работает «префексирование», и его можно встретить везде — самый лучший пример работы — в примере настройки карты задается «scrollZoomSpeed», сам же компонент управления будет читать из стора просто «speed».

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

Restate — попытка скрестить именно систему Яндекс.Карт и redux. Починить и то и другое. Ну и занять долгие вечера каникул.

-> github.com/thekashey/restate