JavaScript веб-разработчик
0,0
рейтинг
27 ноября 2015 в 04:03

Разработка → Управление состоянием и эффективный рендеринг в приложениях на 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

Возможно вы используете подобное решение или что-то лучше? Расскажите, интересно же!
Роман Лютиков @roman01la
карма
36,2
рейтинг 0,0
JavaScript веб-разработчик
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (10)

  • 0
    ваш сайт радует
    Internal server error
  • +3
    Вы изобрели react-redux! )
    • 0
      Честно признаться, я до сих пор не смотрел на Redux. Значит так оно и есть, все приходят к чему-то похожему :)
  • 0
    Можем или в комментариях или в личке обсудить разные подходы.
    Вот с что я использовал:

    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 :)
    • 0
      Привет! Мы использовали курсоры в top-down рендеринге. Без сомнения это интересное решение, но вот это вот отпочкование частей от дерева состояния не всегда работает. Не всегда выходит составить подходящую структуру состояния.
      • 0
        Есть такая проблема. Курсоры работают с деревьями, а состояние может быть графом. Т.е. деревом с дополнительными связями между узлами.
        Соответственно, есть задачи для которых курсоры неприменимы.
  • 0
    Да, в react-redux делается все тоже самое с помощью HOC connect. Любой компонент обернутый в connect, становится smart-компонентом и получает доступ к глобальному стейту. Однако, на сколько я понял, redux-сообщество придерживается принципа «чем меньше смарт-компонентов, тем лучше» и как раз лучшим решением считает top-down рендеринг, так как он более предсказуемый.
    • 0
      Интересно. А где можно почитать, почему redux-сообщество придерживается этого принципа?
      • +1
        Вот, из официальной документации к redux:
        In this todo app, we will only have a single container component at the top of our view hierarchy. In more complex apps, you might have several of them. While you may nest container components, we suggest that you pass props down whenever possible.

        Пруфлинк
        • 0
          Спасибо. Я согласен, хотелось увидеть пруф :)
          Такой подход помогает создавать переиспользуемые компоненты и выделять их в библиотеку.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.