3.8 Согласование

React предоставляет декларативный API, так что вам не нужно беспокоиться о том, какие именно изменения происходят при каждом обновлении. Это значительно упрощает написание приложений, но может быть неочевидно, как это реализовано в React. В этой статье объясняются подходы, которые реализовали разработчики в алгоритме React, чтобы обновления компонентов были предсказуемыми, будучи достаточно быстрыми для высокопроизводительных приложений.


Когда вы используете React, в определенный момент времени вы можете думать о функции render () как о создателе дерева элементов React. При следующем обновлении состояния или свойств функция render () вернет другое дерево элементов React. После этого React необходимо выяснить, как эффективно обновить пользовательский интерфейс, чтобы соответствовать последнему дереву.

Существуют некоторые общие решения этой алгоритмической задачи генерации минимального количества операций для преобразования одного дерева в другое. Однако современные алгоритмы имеют сложность порядка O(n3), где n - количество элементов в дереве.

Если использовать это в React, то для отображения 1000 элементов потребуется порядка одного миллиарда сравнений. Это слишком дорого. Вместо этого React реализует эвристический алгоритм O(n), основанный на двух предположениях:

  • Два элемента разных типов будут создавать разные деревья.
  • Разработчик может указать, какие дочерние элементы могут быть стабильными между разными отрисовками с помощью свойства key.

На практике эти предположения применимы почти для всех практических случаев.


При сравнении двух деревьев, React сначала сравнивает два корневых элемента. Поведение различно в зависимости от типов корневых элементов.


3.8.2.1 Элементы различных типов

Всякий раз, когда корневые элементы имеют разные типы, React будет уничтожать старое дерево и строить новое с нуля. Переходы от <a> к <img>, или от <Article> к <Comment>, или от <Button> к <div> - любой из них приведет к полной перестройке дерева.

При уничтожении дерева старые DOM-узлы удаляются. React вызывает componentWillUnmount() для экземпляров компонентов. При создании нового дерева новые DOM-узлы вставляются в DOM. React вызывает componentWillMount(), а затем componentDidMount() для экземпляров компонентов. Любое состояние, связанное со старым деревом, теряется.

Любые компоненты ниже корня также будут демонтированы и уничтожены. Например, при сравнении:


Код
    
  <div>
    <MyComponent />
  </div>

  <span>
    <MyComponent />
  </span>
  

React уничтожит старый пользовательский и перемонтирует новый.


3.8.2.2 DOM-элементы одинакового типа

При сравнении двух React DOM-элементов того же типа React рассматривает атрибуты обоих, сохраняет один и тот же базовый узел DOM и обновляет только измененные атрибуты. Например:


Код
    
  <div className="до" title="блок" />

  <div className="после" title="блок" />
  

Сравнивая эти два элемента, React обнаруживает, что необходимо изменить только имя класса на базовом узле DOM.

При обновлении стиля style React обнаруживает, что необходимо обновить только измененное свойство color. Например:


Код
    
  <div style={{color: 'red', fontWeight: 'bold'}} />

  <div style={{color: 'green', fontWeight: 'bold'}} />
  

При преобразовании React изменит только свойство color, но не fontWeight.

После обработки DOM-узла React всё рекурсивно повторяет на дочерних элементах.


3.8.2.3 Элементы компонентов одинакового типа

Когда компонент обновляется, экземпляр остается неизменным, так что во время отрисовок состояние сохраняется. React обновляет свойства экземпляра компонента, чтобы соответствовать новому элементу (виртуальному узлу дерева) и вызывает методы componentWillReceiveProps() и componentWillUpdate() в этом экземпляре.

Затем вызывается метод render(), и алгоритм сравнения выполняет рекурсию на предыдущем и новом результате.




3.8.2.4 Рекурсивный обход дочерних элементов

По умолчанию при рекурсивном обходе дочерних DOM-узлов React просто выполняет итерацию по обоим спискам потомков одновременно и формирует изменение всякий раз, когда обнаружено отличие.

Например, при добавлении элемента в конец списка потомков преобразование между этими двумя деревьями работает хорошо:


Код
    
  <ul>
    <li>Один</li>
    <li>Два</li>
  </ul>

  <ul>
    <li>Один</li>
    <li>Два</li>
    <li>Три</li>
  </ul>
  

React соотнесёт два дерева <li>Один</li>, два дерева <li>Два</li>, а затем вставит дерево <li>Три</li>.

Если вы реализуете это в таком виде, вставка элемента в начало списка будет иметь худшую производительность. Например, преобразование между этими двумя деревьями работает неэффективно:


Код
    
  <ul>
    <li>Один</li>
    <li>Два</li>
  </ul>

  <ul>
    <li>Ноль</li>
    <li>Один</li>
    <li>Два</li>
  </ul>
  

React будет изменять каждый потомок вместо того, чтобы понять, что он может оставить поддеревья <li>Один</li> и <li>Два</li> нетронутыми. Такая неэффективность является проблемой, особенно в больших приложениях.


3.8.2.5 Ключи

Чтобы решить эту проблему, React поддерживает атрибут key. Когда у потомков есть ключи, React использует ключ для установления соответствия потомков в исходном дереве с дочерними элементами в последующем дереве.

Например, добавление ключа к нашему неэффективному примеру выше может сделать преобразование дерева эффективным:


Код
    
  <ul>
    <li key="1">Один</li>
    <li key="2">Два</li>
  </ul>

  <ul>
    <li key="0">Ноль</li>
    <li key="1">Один</li>
    <li key="2">Два</li>
  </ul>
  

Теперь React знает, что элемент с ключом “0” является новым, а элементы с ключами “1” и “2” только что переместились.

На практике найти ключ обычно не сложно. Элемент, который вы собираетесь отображать, может уже иметь уникальный идентификатор, поэтому ключ может быть получен просто из ваших данных:


Код
    
  <li key={item.id}>{item.name}</li>
  

Если это не так, вы можете добавить новое свойство идентификатора к своей модели или хеш части содержимого, чтобы сгенерировать ключ. Ключ должен быть уникальным среди своих соседей, а не глобально уникальным.

В крайнем случае вы можете передать индекс элемента в массиве в качестве ключа. Это будет хорошо работать, если элементы никогда не переупорядочиваются, но переупорядочивание будет работать медленно.

Реорганизация также может вызывать проблемы с состоянием компонента, когда в качестве ключей используются индексы. Экземпляры компонентов обновляются и повторно используются на основе их ключа. Если ключ является индексом, перемещение элемента изменяет его. В результате состояние компонента, отрисовывающего, например, управляемые input, может быть смешено и/или обновлено неожиданными путями.

Здесь в CodePen находится пример с проблемами, которые могут быть вызваны использованием индексов в качестве ключей, а здесь находится обновленная версия того же примера, показывающая, как избежать использования индексов, поскольку ключи будут исправлять все эти проблемы: переупорядочения, сортировки и добавления.


3.8.2.6 Компромиссы

Важно помнить, что алгоритм согласования является деталью реализации. React может перерисовывать все приложение в ответ на каждое действие. Конечный результат будет таким же. Разработчики регулярно совершенствуют эвристики, чтобы сделать общие случаи использования быстрее по производительности.

В текущей реализации если вы можете сказать, что поддерево перемещено относительно своих соседей, но не можете сказать, что оно переместилось где-либо еще, алгоритм перерисует всё это поддерево.

Поскольку React полагается на эвристики, то если предположения, стоящие за ними, не выполняются, производительность будет страдать.

  • Алгоритм не будет пытаться сопоставить поддеревья разных типов компонентов. Если вы видите, что работаете с двумя типами компонентов, имеющих очень похожий вывод, вы можете сделать их одним и тем же компонентом. На практике это не вызывает проблем.
  • Ключи должны быть стабильными, предсказуемыми и уникальными. Нестабильные ключи (например, созданные с Math.random()) приведут к излишнему пересозданию множества экземпляров компонентов и узлов DOM, что может привести к ухудшению производительности и потере состояния в дочерних компонентах.