Привет! Я хочу рассказать об очередной реализации Flux. А точнее о минимальной реализации, которую мы успешно используем в рабочих проектах. И о том, как мы пришли к этому. На самом деле многие так или иначе сами приходят к подобному решению. Описанное здесь решение является лишь вариацией ему подобных.
В 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
Возможно вы используете подобное решение или что-то лучше? Расскажите, интересно же!
ссылка на оригинал статьи http://habrahabr.ru/post/271819/
Добавить комментарий