ReactSeniorTechnical

Что такое reconciliation в React? Как работает алгоритм сравнения (diffing algorithm)?

Reconciliation — эвристический O(n) алгоритм: разные типы = ремаунт, одинаковые типы = обновление атрибутов, key в списках = сопоставление по идентификатору. Fiber разбивает обход на прерываемую render-фазу и синхронную commit-фазу.

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

Reconciliation — процесс, с помощью которого React определяет минимальный набор изменений DOM при обновлении дерева компонентов. Полное сравнение двух деревьев имеет сложность O(n³); React использует эвристический алгоритм O(n), основанный на двух предположениях.

Два ключевых предположения алгоритма

  • Разные типы элементов порождают разные деревья. Если тип узла изменился (например, <div><span>), React полностью удаляет старое поддерево и монтирует новое. Сравнение внутри не происходит.
  • Разработчик подсказывает стабильные идентификаторы через prop key. Ключи позволяют React сопоставлять узлы в списках между рендерами без полного перебора.

Как работает алгоритм пошагово

React обходит оба дерева (старое и новое) одновременно, сравнивая узлы на одном уровне:

  • Элементы DOM одного типа — React обновляет только изменившиеся атрибуты. Дочерние элементы рекурсивно сравниваются.
  • Компоненты одного типа — экземпляр сохраняется, вызывается рендер с новыми props. Fiber-узел переиспользуется.
  • Разные типы — старое дерево размонтируется (componentWillUnmount / cleanup эффектов), новое монтируется с нуля.
  • Списки без key — React сравнивает по позиции. Вставка в начало списка приводит к обновлению всех узлов.
  • Списки с key — React строит карту {key → fiber} и сопоставляет элементы по ключу, а не позиции. Перемещение элементов не вызывает пересоздания.

Fiber — структура данных за reconciliation

Начиная с React 16, дерево компонентов хранится как связный список Fiber-узлов. Каждый Fiber содержит тип компонента, props, state, ссылки на parent/child/sibling и очередь обновлений. Это позволяет прерывать обход (Concurrent Mode) и возобновлять его позже.

Reconciliation проходит в две фазы:

  • Render phase (reconciler) — чистая, прерываемая. React строит «work-in-progress» дерево Fiber, вычисляет изменения, но не трогает DOM.
  • Commit phase — синхронная, непрерываемая. React применяет изменения к реальному DOM за один проход: сначала мутации, затем layout-эффекты, затем passive-эффекты.

Демонстрация влияния key

// Плохо: React пересоздаёт все компоненты при вставке нового элемента в начало
const items = ['b', 'c', 'd'];
const bad = items.map((item, index) => <Item key={index} value={item} />);
// При добавлении 'a' в начало: index 0 = 'a' (было 'b') → React обновляет все три

// Хорошо: стабильный ключ = идентификатор из данных
const good = items.map(item => <Item key={item.id} value={item.label} />);
// React видит, что 'b', 'c', 'd' не изменились; монтирует только 'a'

Принудительный сброс через key

// Смена key на компоненте — самый простой способ заставить React
// полностью пересоздать экземпляр (reset state, повторный mount)
function UserProfile({ userId }: { userId: string }) {
  return <ProfileForm key={userId} />;
  // При смене userId React уничтожит старый ProfileForm и создаст новый
  // Все хуки (useState, useEffect) стартуют с нуля
}

Concurrent Mode и Transitions

В React 18 Concurrent Mode render-фаза стала прерываемой. Вызов startTransition помечает обновление как некритическое — React может отложить его и в это время обработать срочные события (ввод пользователя).

import { startTransition, useState } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value); // срочное — обновляется немедленно
    startTransition(() => {
      setResults(search(e.target.value)); // некритическое — может прерываться
    });
  };

  return <input value={query} onChange={handleChange} />;
}

Подводные камни

  • key={index} в динамических списках — при перестановке или вставке элементов React неправильно сопоставляет узлы, возникают баги с состоянием (форма «прыгает» на другой элемент).
  • Условный рендер разными типами — если вы переключаете между <Input /> и <Textarea />, каждый раз монтируется новый компонент и state сбрасывается. Используйте одно поле с hidden или параметр type.
  • Объявление компонента внутри компонента — тип функции создаётся заново на каждом рендере. React видит «другой тип» и пересоздаёт вложенный компонент при каждом рендере родителя. Всегда объявляйте компоненты на верхнем уровне модуля.
  • Ожидание синхронного DOM после setState — commit происходит асинхронно. Читайте DOM в useLayoutEffect, а не сразу после setState.
  • Стабильность ключей в транзиентных данных — не генерируйте ключ через Math.random() или Date.now() внутри рендера: каждый рендер даёт новый ключ = полный ремаунт.
  • React.StrictMode двойной вызов render — в dev-режиме render-фаза вызывается дважды специально, чтобы выловить side effects в render. Это норма, не баг.
  • Смешение key и index при фильтрации — если отфильтрованный список сохраняет оригинальные индексы, React всё равно сравнивает по позиции в отображаемом массиве. Используйте ID из данных, а не позицию в оригинальном массиве.

Common mistakes

  • Использовать индекс как key в перемешиваемом списке.
  • Считать, что одинаковый JSX гарантирует сохранение state — не гарантирует, если меняется тип.
  • Делать тяжёлые синхронные render-функции и винить diff.

What the interviewer is testing

  • Может ли сформулировать обе эвристики diff.
  • Понимает ли роль Fiber в прерываемом рендере.
  • Знает ли, как смена типа сбрасывает поддерево.

Sources

Related topics