В Redradix мы уже около года разрабатываем веб-приложения на React и в течении этого времени у каждого из членов команды возникали идеи, которые мы постепенно выносили в свое, домашнее решение. Мы сразу же отказались от хранилищ в классическом Flux в пользу единого глобального состояния. Хранилища всего лишь выполняют роль сеттеров/геттеров в состояние приложения. Чем хорошо глобальное состояние? Одно состояние — это один конфиг всего приложения. Его без труда можно заменить другим, сохранить или передать по сети. Больше нету зависимостей между хранилищами.
Возникает вопрос: как разделить это состояние между компонентами в приложении? Самое простое и легко реализуемое решение — так называемый top-down rendering. Корневой компонент подписывается на изменения в состоянии и после каждого изменения он получает актуальную версию состояния, которую передает дальше по дереву компонентов. Таким образом все компоненты в приложении имеют доступ к состоянию и могут прочитать из него необходимые данные. У такого подхода две проблемы: неэффективность рендеринга (на каждое изменение в состоянии обновляется все дерево компонентов) и необходимость явно передавать состояние во все компоненты (компоненты зависимые от состояния могут быть внутри независимых компонентов). Вторая проблема решается с помощью контекста, для передачи состояния неявно. Но как уйти от обновления всего приложения на каждый чих?
Поэтому мы оставили top-down rendering. Мне понравилась идея Relay с колокацией запросов внутри компонента, которому нужны данные по этим запросам. Relay покрывает не только управление состоянием, но и работу с сервером. Мы пока что остановились только на управлении состоянием на клиенте.
Идея простая: описать запросы в глобальное состояние внутри компонента и подписать все такие компоненты на изменения в состоянии по заданным запросам. Теперь выходит, что данные из состояния будут получать только те компоненты, которым они действительно нужны. И обновляться будет не все дерево компонентов, а только те его части, которые подписаны на изменяемые данные. Такой компонент выглядит вот так:
const MyComponent = React.createClass({
statics: {
queries: {
count: ['ui', 'counter', 'count']
}
},
render() {
return <button>{this.props.count}</button>;
}
});
export default connect(MyComponent);
Данные из запроса попадают в свойство с именем запроса, в данном случае это свойство count. Подписывание на изменение происходит внутри специальной функции connect, в которую оборачивается компонент с запросами.
Давайте заглянем внутрь этой функции.
import React from 'react';
import equal from 'deep-equal';
import { partial } from 'fn.js';
import { is } from 'immutable';
import {
getIn,
addChangeListener,
removeChangeListener
} from './atom';
function resolveQueries(queries) {
return Object.entries(queries)
.reduce((resolved, [name, query]) => {
resolved[name] = getIn(query);
return resolved;
}, {});
}
function stateEqual(state, nextState) {
return Object.keys(state)
.every((name) => is(state[name], nextState[name]));
}
export default function connect(Component) {
// Сохраним запросы
const queries = Component.queries;
// Создадим функцию для извлечения данных из состояния по запросам
const getNextState = partial(resolveQueries, queries);
// Здесь будут данные извлеченные из состояния
let state = {};
return React.createClass({
// Обозначим имя компонента для отладки
displayName: `${Component.displayName}::Connected`,
componentWillMount() {
// Первичное состояние
state = getNextState();
},
componentDidMount() {
// Компонент слушает изменение данных по запросам
// и обновляется на каждое такое изменение
addChangeListener(queries, this._update);
},
componentWillReceiveProps(nextProps) {
// Обновить компонент, если изменились свойства
if (equal(this.props, nextProps) === false) {
this.forceUpdate();
}
},
shouldComponentUpdate() {
// Игнорируем SCU,
// т.к. обновление производится только с помощью forceUpdate
return false;
},
componentWillUnmount() {
removeChangeListener(queries, this._update);
},
_update() {
const nextState = getNextState();
// Обновить компонент если новые данные из запросов отличаются от текущих.
// И заменить состояние на новое.
if (stateEqual(state, nextState) === false) {
state = nextState;
this.forceUpdate();
}
},
render() {
// Передать свойства и новое состояние в компонент
return <Component {...this.props} {...state} />;
}
});
}
Как видим функция выше возвращает React компонент, который управляет состоянием и передает его в оборачиваемый компонент. Метод _update перед обновлением компонента проверяет изменились ли данные по запросам на самом деле. Это необходимо для случаев, когда происходит изменение в дереве состояния, на часть которого подписан компонент. Тогда, если эта часть на самом деле не изменилась, компонент не будет обновлен. В этом примере я использовал библиотеку Immutable для неизменяемых структур данных, но вы можете использовать все, что угодно, это неважно.
Другая часть реализации находится в модуле с названием atom. Модуль представляет собой интерфейс с геттерами/сеттерами в объект состояния. Мне обычно хватает трех функций для чтения и записи в состояние: getIn, assocIn и updateIn. Эти функции могут быть обертками вокруг методов библиотеки Immutable или mori, или еще чего-нибудь. Обертка нужна лишь для того, что бы заменять текущее состояние на новое после его изменения (еще можно добавить логирование операций).
let state;
export function getIn(query) {
return state.getIn(query);
}
export function assocIn(query, value) {
state = state.setIn(query, value);
}
export function updateIn(query, fn) {
state = state.updateIn(query, fn);
}
Так же нам потребуется функционал для подписывания компонентов на изменения по запросам и вызова этих слушателей, когда данные по запросам были изменены с помощью выше описанных функций.
const listeners = {};
export function addChangeListener(queries, fn) {
Object.values(queries)
.forEach((query) => {
const sQuery = JSON.stringify(query);
listeners[sQuery] = listeners[sQuery] || [];
listeners[sQuery].push(fn);
});
}
Теперь функции изменяющие состояние должны еще и сообщать об изменениях:
// Изменить состояние
export function assocIn(query, value) {
swap(state.setIn(query, value), query);
}
// Заменить текущее состояние на новое
export function swap(nextState, query) {
state = nextState;
notifySwap(query);
}
// Вызвать слушатели привязанные к запросам или их частям,
// по которым произошли изменения
export function notifySwap(query) {
let sQuery = JSON.stringify(query);
sQuery = sQuery.slice(0, sQuery.length - 1);
Object.entries(listeners)
.forEach(([lQuery, fns]) => {
if (lQuery.startsWith(sQuery)) {
fns.forEach((fn) => fn());
}
});
}
Сложив все части вместе, изменение состояния и обработка этого изменения в приложении будет выглядеть следующим образом:
- Изменить состояния с помощью сеттеров описанных в модуле atom
- Вызвать слушатели привязанные к запросам, которые были использованы для изменения состояния
- Получить данные из состояния по запросам обновляемого компонента
- Обновить компонент передав в него новые данные
Осталось только инициализировать состояние. Обычно я это делаю непосредственно перед инициализацией дерева компонентов.
import React from 'react';
import { render } from 'react-dom';
import Root from './components/root.jsx';
import { silentSwap } from './lib/atom';
import { fromJS } from 'immutable';
const initialState = {
ui: {
counter: { count: 0 }
}
};
silentSwap(fromJS(initialState));
render(<Root />, document.getElementById('app'));
Вот пример хранилища, которое теперь выполняет роль сеттера в состояние:
import { updateIn } from '../lib/atom';
import { listen } from '../lib/dispatcher';
import actions from '../config/actions';
import { partial } from 'fn.js';
const s = {
count: ['ui', 'counter', 'count']
};
listen(actions.INC_COUNT, partial(updateIn, s.count, (count) => count + 1));
listen(actions.DEC_COUNT, partial(updateIn, s.count, (count) => count - 1));
Возвращаясь к проблемам, которые мы имели с top-down rendering:
- Теперь нет необходимости передавать состояние через все дерево компонентов. Нужно лишь «присоединить» нужные компоненты к состоянию.
- Когда состояние было изменено, будут обновлены только те компоненты, которые подписаны на измененные данные.
В планах сделать что-нибудь с этим всем для работы с сервером, а точнее для получения всех данных одним запросом (как это делает Relay и Falcor). Например Om Next достает запросы из всех компонентов в одну структуру данных, вычисляет ее хэш и отправляет эти запросы на сервер. Таким образом для одних и тех же запросов всегда будет один и тот же хэш, а значит можно кэшировать ответ сервера с помощью этого хэша. Довольно простоя идея. Посмотрите доклад Дэвида Нолена об Om Next, много клевых идей.
Весь код из статьи оформлен здесь: gist.github.com/roman01la/912265347dd5c46b0a2a
Возможно вы используете подобное решение или что-то лучше? Расскажите, интересно же!
Комментарии (7)
mkuzmin
28.11.2015 08:33Можем или в комментариях или в личке обсудить разные подходы.
Вот с что я использовал:
github.com/darkleaf/react-immutable-todo
Это один из первых экспериментов с react и immutable.
Для изменения состояния используются чистые функции github.com/darkleaf/react-immutable-todo/blob/master/lib/item/transitions.js
Недавно была задача реализовать сложные интерактивные формы для админки. Тут отлично подходили курсоры. Но не одна из реализаций мне не подошла по разным причинам. По этому я написал свою реализацию на 20 полезных строчек кода.
github.com/darkleaf/state/blob/master/state.js
Кратко про курсоры. Они позволяют пробрасывать некоторую часть состояния подкомпоненту, подкомпонент имеет доступ только к этим данным и может обновить только эти данные. При этом курсоры иммутабельны и позволяют использовать shouldComponentUpdate в реакте для очень быстрого рендеринга. Если интересно, то могу подробнее рассказать про курсоры.
Следующим шагом планирую разбираться с redux :)roman01la
28.11.2015 10:15Привет! Мы использовали курсоры в top-down рендеринге. Без сомнения это интересное решение, но вот это вот отпочкование частей от дерева состояния не всегда работает. Не всегда выходит составить подходящую структуру состояния.
mkuzmin
28.11.2015 10:29Есть такая проблема. Курсоры работают с деревьями, а состояние может быть графом. Т.е. деревом с дополнительными связями между узлами.
Соответственно, есть задачи для которых курсоры неприменимы.
smashercosmo
03.12.2015 01:06Да, в react-redux делается все тоже самое с помощью HOC connect. Любой компонент обернутый в connect, становится smart-компонентом и получает доступ к глобальному стейту. Однако, на сколько я понял, redux-сообщество придерживается принципа «чем меньше смарт-компонентов, тем лучше» и как раз лучшим решением считает top-down рендеринг, так как он более предсказуемый.
scramble
ваш сайт радует