Reconciliation в React: как reconciler решает, что перерисовать, и зачем key

Фронтенд5 мин чтения
  • #react
  • #reconciliation
  • #virtual-dom
  • #render
  • #performance

«React быстро рендерит за счёт виртуального DOM» — фраза, которую повторяют, но редко объясняют, что за ней стоит конкретный алгоритм. На самом деле «виртуальный DOM» — это не магия, а простая идея: держать в памяти второе дерево описаний элементов, сравнивать его с предыдущим и применять к настоящему DOM только разницу. Алгоритм, который делает сравнение, называется reconciliation (примирение/сверка). От его работы зависит, насколько быстро обновится экран после изменения состояния.

Зачем нужно второе дерево

Прямой DOM медленный: каждое чтение布局-свойств и каждая мутация (через appendChild, innerHTML) — это синхронная работа браузера, которая может вызвать перерасчёт стиля и layout. Если на каждое изменение состояния переписывать всё дерево целиком — интерфейс заикнется.

React делает иначе: вы описываете, каким UI должен быть после изменения, в виде дерева React-элементов (это и есть «виртуальный DOM»). React сравнивает новое дерево со старым, находит минимальный набор изменений и применяет только их к настоящему DOM. Одно сравнение в памяти — дёшево, одна пачка точечных DOM-мутаций — быстро.

Почему сравнение за O(n), а не O(n³)

В общем случае сравнить два дерева за минимальное число правок — задача за O(n³) (это классический tree-edit-distance). Для интерфейса это непозволительно дорого. React жертвует точностью ради скорости и делает три допущения (описаны в официальной документации React, раздел «Reconciliation»):

  1. Элементы разных типов дают разные деревья. Если на месте был <div>, а стал <span> — React не пытается адаптировать старое поддерево, он уничтожает его и строит заново.
  2. Ключи (key) определяют идентичность детей в списках. По ним React понимает, какой элемент переехал, а какой — новый.
  3. Порядок детей сравнивается последовательно. React идёт по списку детей слева направо и сопоставляет пары.

Из-за этих упрощений сравнение становится линейным — O(n) — но React может сделать чуть больше DOM-мутаций, чем теоретический минимум. На практике это разумный компромисс.

Правило разных типов — и почему это важно

Если вы меняете тип элемента в одном месте, React сносит всё поддерево под ним и строит заново — со всеми потерянными DOM-узлами, state'ом дочерних компонентов и фокусом.

// Плохо: условно рендерим div или span в одной позиции
{isInline ? <span>{text}</span> : <div>{text}</div>}

При переключении isInline React увидит разный тип у корня — уничтожит старый узел, создаст новый. В этом простом случае это неважно. Но если внутри тяжёлый компонент со своим стейтом — он размонтируется и потеряет всё.

Правильнее — держать один и тот же тип и варьировать через props/классы:

<div className={isInline ? 'inline' : 'block'}>{text}</div>

key — не косметика

Самое частое недопонимание — про key в списках. React использует ключ, чтобы сопоставить элементы старого и нового списка: «элемент с ключом X в старом списке = элемент с ключом X в новом». Если ключи стабильны, React переиспользует DOM-узлы и state компонентов при перестановках, добавлениях, удалениях.

// Правильно: стабильные уникальные ключи
{items.map(item => <ListItem key={item.id} item={item} />)}

Что ломается при key={index} (частая ошибка):

{items.map((item, index) => <ListItem key={index} item={item} />)}

Удаление первого элемента сдвигает все индексы. React решит, что элемент с key=0 изменил содержимое, key=1 — тоже, и т.д. В итоге он переиспользует DOM-узлы, но подставит в них новые данные — компоненты не размонтируются, их внутренний state (например, открыт ли accordion, введённый текст) перепутается между элементами. Для статичных списков это незаметно; для списков с изменяемым порядком или удалениями — источник трудноуловимых багов.

Ключ должен быть стабильным (один и тот же для одного и того же элемента между рендерами) и уникальным среди соседей (не глобально — только внутри одного списка). Идентификатор из БД, natural key предметной области — подходят. Индекс массива — нет, если список меняется.

Что заставляет React рендерить лишнее

Когда состояние компонента меняется, React перерендеривает его и всех его потомков по умолчанию. Это не всегда проблема — React быстр, и часто дешевле перерендерить, чем оптимизировать. Но в тяжёлых компонентах появляются паттерны, которые заставляют reconciler делать лишнюю работу.

Новые функции и объекты в render. Каждый рендер создаёт новую функцию-обработчик и новые объекты-стили. Если их передать как props дочернему компоненту, обёрнутому в React.memo, сравнение props === nextProps провалится (потому что ссылки разные), и memo не сработает.

// memo не поможет: onChange — новая функция каждый рендер
const Child = React.memo(({ onChange }) => <button onClick={onChange} />);

function Parent() {
  // каждый рендер — новая функция
  return <Child onChange={(e) => doSomething(e.target.value)} />;
}

Лечится useCallback для функций и useMemo для объектов — но только если дочерний компонент действительно обёрнут в memo и реально тяжёлый.

Нестабильные объекты как props. То же самое со стилями: <Child style={{ color: 'red' }} /> создаёт новый объект каждый рендер. Если нужно — выносите в константу снаружи компонента или в useMemo.

Глобальный Context. Любое изменение значения в Context.Provider перерендеривает всех потребителей, независимо от того, какую часть контекста они используют. Если контекст большой и меняется часто — это узкое место. Решения: разбить контекст на части, вынести редко меняющиеся данные отдельно, или хранить состояние внешне (Zustand) с подпиской на нужные срезы.

Когда не надо оптимизировать

memo, useMemo, useCallback — не бесплатные. Сравнение props само стоит ресурсов, и обёртка добавляет накладные. Оптимизировать стоит, когда есть измеримая проблема: компонент рендерится часто, рендер дорогой, а props реально меняются реже, чем запускается рендер.

Правило: сначала профилируйте, потом оптимизируйте. React DevTools Profiler показывает, какой компонент сколько рендерился и почему. Без профиля легко наставить memo впустую и сделать код сложнее без выгоды.

Короткое summary

Reconciliation — это алгоритм сравнения старого и нового дерева React-элементов, за счёт которого DOM-мутации минимальны. Он работает за O(n) благодаря трём допущениям: разные типы → разные поддеревья, ключи определяют идентичность детей, последовательное сопоставление. Смена типа элемента разрушает поддерево; нестабильные key ломают state списков; новые ссылки в props сводят на нет memo. Оптимизация (memo/useMemo/useCallback) нужна не везде — только там, где измеримый рендер дорог, а данные меняются реже, чем рендеринг.

Что почитать