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

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

Мотивация

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

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

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

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

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

Алгоритм сравнения

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

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

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

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

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

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

При этом старый Counter уничтожится, а новый — смонтируется.

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

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

<div className="before" title="stuff" />

<div className="after" title="stuff" />

Сравнивая эти элементы, React знает, что нужно модифицировать только className у лежащего в основе DOM-узла.

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

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

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

При конвертации между этими элементами, React знает, что нужно модифицировать только стиль color, а fontWeight сохранить.

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

Компоненты одного типа

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

Далее вызывается метод render() и алгоритм сравнения рекурсивно обходит предыдущий и новый результаты.

Рекурсия по дочерним элементам

По умолчанию при рекурсивном обходе дочерних элементов 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> нетронутыми. Эта неэффективность может стать проблемой.

Ключи

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

<ul>
  <li key="2015">Санкт-Петербург</li>
  <li key="2016">Москва</li>
</ul>

<ul>
  <li key="2014">Ростов-на-Дону</li>
  <li key="2015">Санкт-Петербург</li>
  <li key="2016">Москва</li>
</ul>

Теперь React знает, что элемент с ключом '2014' — новый, а элементы с ключами '2015' и '2016' только что переместились.

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

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

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

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

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

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

Компромиссы

Важно помнить, что алгоритм согласования — это деталь реализации. React может повторно рендерить всё приложение на каждое действие, конечный результат будет тем же. Для ясности, повторный рендер в этом контексте означает вызов функции render для всех компонентов, но это не означает, что React размонтирует и смонтирует их заново. Он применит различия только следуя правилам, которые были обозначены в предыдущих разделах.

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

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

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