Pull to refresh

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

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

Возможно вы используете подобное решение или что-то лучше? Расскажите, интересно же!
Tags:
Hubs:
+9
Comments10

Articles

Change theme settings