Привет! Я хочу рассказать об очередной реализации Flux. А точнее о минимальной реализации, которую мы успешно используем в рабочих проектах. И о том, как мы пришли к этому. На самом деле многие так или иначе сами приходят к подобному решению. Описанное здесь решение является лишь вариацией ему подобных.
В Redradix мы уже около года разрабатываем веб-приложения на React и в течении этого времени у каждого из членов команды возникали идеи, которые мы постепенно выносили в свое, домашнее решение. Мы сразу же отказались от хранилищ в классическом Flux в пользу единого глобального состояния. Хранилища всего лишь выполняют роль сеттеров/геттеров в состояние приложения. Чем хорошо глобальное состояние? Одно состояние — это один конфиг всего приложения. Его без труда можно заменить другим, сохранить или передать по сети. Больше нету зависимостей между хранилищами.
Возникает вопрос: как разделить это состояние между компонентами в приложении? Самое простое и легко реализуемое решение — так называемый top-down rendering. Корневой компонент подписывается на изменения в состоянии и после каждого изменения он получает актуальную версию состояния, которую передает дальше по дереву компонентов. Таким образом все компоненты в приложении имеют доступ к состоянию и могут прочитать из него необходимые данные. У такого подхода две проблемы: неэффективность рендеринга (на каждое изменение в состоянии обновляется все дерево компонентов) и необходимость явно передавать состояние во все компоненты (компоненты зависимые от состояния могут быть внутри независимых компонентов). Вторая проблема решается с помощью контекста, для передачи состояния неявно. Но как уйти от обновления всего приложения на каждый чих?
Поэтому мы оставили top-down rendering. Мне понравилась идея Relay с колокацией запросов внутри компонента, которому нужны данные по этим запросам. Relay покрывает не только управление состоянием, но и работу с сервером. Мы пока что остановились только на управлении состоянием на клиенте.
Идея простая: описать запросы в глобальное состояние внутри компонента и подписать все такие компоненты на изменения в состоянии по заданным запросам. Теперь выходит, что данные из состояния будут получать только те компоненты, которым они действительно нужны. И обновляться будет не все дерево компонентов, а только те его части, которые подписаны на изменяемые данные. Такой компонент выглядит вот так:
Данные из запроса попадают в свойство с именем запроса, в данном случае это свойство count. Подписывание на изменение происходит внутри специальной функции connect, в которую оборачивается компонент с запросами.
Давайте заглянем внутрь этой функции.
Как видим функция выше возвращает React компонент, который управляет состоянием и передает его в оборачиваемый компонент. Метод _update перед обновлением компонента проверяет изменились ли данные по запросам на самом деле. Это необходимо для случаев, когда происходит изменение в дереве состояния, на часть которого подписан компонент. Тогда, если эта часть на самом деле не изменилась, компонент не будет обновлен. В этом примере я использовал библиотеку Immutable для неизменяемых структур данных, но вы можете использовать все, что угодно, это неважно.
Другая часть реализации находится в модуле с названием atom. Модуль представляет собой интерфейс с геттерами/сеттерами в объект состояния. Мне обычно хватает трех функций для чтения и записи в состояние: getIn, assocIn и updateIn. Эти функции могут быть обертками вокруг методов библиотеки Immutable или mori, или еще чего-нибудь. Обертка нужна лишь для того, что бы заменять текущее состояние на новое после его изменения (еще можно добавить логирование операций).
Так же нам потребуется функционал для подписывания компонентов на изменения по запросам и вызова этих слушателей, когда данные по запросам были изменены с помощью выше описанных функций.
Теперь функции изменяющие состояние должны еще и сообщать об изменениях:
Сложив все части вместе, изменение состояния и обработка этого изменения в приложении будет выглядеть следующим образом:
Осталось только инициализировать состояние. Обычно я это делаю непосредственно перед инициализацией дерева компонентов.
Вот пример хранилища, которое теперь выполняет роль сеттера в состояние:
Возвращаясь к проблемам, которые мы имели с top-down rendering:
В планах сделать что-нибудь с этим всем для работы с сервером, а точнее для получения всех данных одним запросом (как это делает Relay и Falcor). Например Om Next достает запросы из всех компонентов в одну структуру данных, вычисляет ее хэш и отправляет эти запросы на сервер. Таким образом для одних и тех же запросов всегда будет один и тот же хэш, а значит можно кэшировать ответ сервера с помощью этого хэша. Довольно простоя идея. Посмотрите доклад Дэвида Нолена об Om Next, много клевых идей.
Весь код из статьи оформлен здесь: gist.github.com/roman01la/912265347dd5c46b0a2a
Возможно вы используете подобное решение или что-то лучше? Расскажите, интересно же!
В 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
Возможно вы используете подобное решение или что-то лучше? Расскажите, интересно же!