Что такое 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 в прерываемом рендере.
- Знает ли, как смена типа сбрасывает поддерево.