Управление состоянием и эффективный рендеринг в приложениях на React

от автора

Привет! Я хочу рассказать об очередной реализации 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *