Как работает diffing алгоритм в React: без мифов про Virtual DOM

Подробно разбираем diffing алгоритм в React: как сравниваются деревья, зачем нужны key, где React делает O(n), где теряет state и как объяснить это на собеседовании.

13 марта 2026 г.18 минLexicon Team

Введение

Когда говорят, что React "сравнивает Virtual DOM", обычно подразумевают diffing алгоритм. Проблема в том, что эта фраза слишком общая. Она не объясняет, почему у списка может потеряться состояние инпута, зачем React так настойчиво просит key, и по какой причине замена <div> на <section> иногда приводит к полному пересозданию поддерева.

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

Если вы ещё не до конца освоили основы Virtual DOM и reconciliation, рекомендуем ознакомиться с ними параллельно. В этой статье сфокусируемся именно на diffing: как React определяет идентичность узла, где сравнение линейное, где оно ломается без key, и какие из этого следуют инженерные решения.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью.

Подписаться

Что именно React сравнивает

React не берет реальный DOM и не делает дорогой побайтовый deep diff браузерного дерева. Он сравнивает два дерева React-элементов: предыдущее и новое, полученное после очередного render phase.

Практически процесс выглядит так:

  1. Изменился state, props или context.
  2. React повторно вызывает компонент и получает новое дерево элементов.
  3. Diffing алгоритм проходит по старому и новому дереву.
  4. На основе различий формируется набор эффектов для commit-фазы.
  5. Только после этого изменяется реальный DOM.

Это важно подчеркнуть, потому что многие смешивают три разных процесса:

  • render — вычисление нового дерева;
  • diffing — поиск различий между деревьями;
  • commit — применение изменений к DOM.

Именно поэтому повторный рендер компонента не означает автоматический DOM update. Подробно про этот разрыв между "компонент вызвался" и "экран реально обновился" удобно читать в разборе ререндеров в React.

Архитектура diffing в React

Контекст задачи

Теоретически сравнение двух произвольных деревьев с поиском минимального набора операций — дорогая задача. Для больших UI-деревьев такой алгоритм был бы слишком медленным. React не может позволить себе подобную стоимость на каждый setState.

Схема компонентов и поток управления

Упрощенная архитектура выглядит так:

  1. Компонент рендерит JSX.
  2. JSX превращается в React-элементы.
  3. На уровне Fiber React связывает старую и новую версии узлов.
  4. Diffing определяет: узел сохранить, обновить, переместить или удалить.
  5. Commit-фаза применяет накопленные изменения.

В современной архитектуре React этот процесс встроен в Fiber, поэтому полезно держать в голове, что diffing не живет изолированно. Он работает внутри более широкой модели планирования, приоритетов и прерываемого рендера. Если вас интересует именно устройство движка, смотрите как устроен React Fiber.

Границы ответственности

  • Render phase отвечает за построение нового описания UI.
  • Diffing отвечает за сопоставление старого и нового дерева.
  • Commit phase отвечает за реальные побочные действия: DOM, ref, lifecycle-эффекты.

Узкие места и стратегия деградации

Главный компромисс React: алгоритм не пытается найти оптимальное решение для всех возможных перестроений дерева. Он предполагает, что разработчик будет помогать ему стабильной структурой компонентов и корректными key.

Если эти предположения нарушить, React не "ломается" в прямом смысле, но начинает принимать более дорогие и менее точные решения:

  • размонтирует поддеревья чаще, чем нужно;
  • теряет локальный state компонентов;
  • делает лишние операции вставки и удаления;
  • усложняет профилирование производительности.

Базовые эвристики diffing алгоритма

Эвристика 1. Разный тип элемента — разное поддерево

Если у узла изменился тип, React считает, что это уже другой элемент, и не пытается сохранить состояние старого элемента в новом.

function Screen({ isAuth }: { isAuth: boolean }) {
  return isAuth ? <Dashboard /> : <LoginForm />;
}

Для React здесь нет "немного изменившегося" компонента. Есть два разных типа: Dashboard и LoginForm. При переключении один будет размонтирован, другой смонтирован заново.

То же правило работает и для DOM-узлов:

return isCompact ? <div className="card" /> : <section className="card" />;

div и section считаются разными типами. React заменит один узел другим, а не попытается частично обновить его как один и тот же объект.

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

Эвристика 2. Один и тот же тип — обновляем props и идем глубже

Если тип совпадает, React сохраняет узел и сравнивает его свойства и дочерние элементы.

function Button({ primary }: { primary: boolean }) {
  return (
    <button className={primary ? "btn btn-primary" : "btn"}>
      Сохранить
    </button>
  );
}

При изменении primary React не пересоздает кнопку как новый DOM-элемент. Он сохраняет тип button, обновляет className и продолжает сравнение детей.

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

Эвристика 3. Дети по умолчанию сравниваются по позиции

Если речь не о списке со стабильными key, React сначала ориентируется на порядок следования дочерних элементов.

Пример:

<>
  <UserInfo />
  <UserStats />
</>

Если после рендера дерево стало таким:

<>
  <UserStats />
  <UserInfo />
</>

без дополнительных подсказок React не сможет надежно понять, что это те же элементы, просто поменявшиеся местами. Для него это выглядит как несоответствие на позиции 0 и позиции 1, а значит возможны лишние размонтирования и монтирования.

Эвристика 4. В списках идентичность задает key

Именно здесь diffing чаще всего становится видимой проблемой в production. Когда массив рендерится через .map, key сообщает React, какой новый элемент соответствует какому старому.

function TodoList({ items }: { items: Array<{ id: string; text: string }> }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.text}</li>
      ))}
    </ul>
  );
}

Если вместо item.id использовать индекс, алгоритм начинает полагаться на позицию. При удалении первого элемента все остальные как будто "съезжают", и React может переносить состояние между соседями. Это особенно болезненно для инпутов, анимаций и локального useState.

Отдельный практический разбор этой темы уже есть в статье про типичные ошибки с key в React.

Как diffing работает на списках

Сценарий без key

Допустим, был список:

["A", "B", "C"]

и стал:

["B", "C"]

Если React ориентируется только на позиции, он видит примерно такую картину:

  • старый элемент на позиции 0: A
  • новый элемент на позиции 0: B

То есть не "удалили A", а "элемент 0 изменился". Аналогично дальше: B превращается в C. На практике это значит, что состояние и DOM могут быть переиспользованы не теми узлами, на которые вы рассчитывали.

Сценарий со стабильным key

Если список был таким:

[
  { id: "a", value: "A" },
  { id: "b", value: "B" },
  { id: "c", value: "C" }
]

а затем удалился элемент a, React сопоставит b с b, c с c, а a удалит. Это уже не сравнение по позиции, а сравнение по идентичности.

Код-пример: потеря состояния из-за индексного key

function EditableList({ items }: { items: Array<{ id: string; text: string }> }) {
  return (
    <>
      {items.map((item, index) => (
        <EditableRow key={index} initialValue={item.text} />
      ))}
    </>
  );
}

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

Исправление:

function EditableList({ items }: { items: Array<{ id: string; text: string }> }) {
  return (
    <>
      {items.map((item) => (
        <EditableRow key={item.id} initialValue={item.text} />
      ))}
    </>
  );
}

Здесь идентичность строки привязана к бизнес-сущности, а не к текущей позиции на экране. Для diffing это критически важная разница.

Сравнение сценариев diffing

СценарийЧто видит ReactРезультат для DOM и stateКогда применять
Один и тот же тип элемента, меняются propsУзел сохраняетсяТочечное обновление props, state потомков сохраняетсяТиповой UI-апдейт
Тип элемента изменилсяДругой узелСтарое поддерево размонтируется, новое смонтируетсяКогда реально меняется структура
Список без стабильного keyСопоставление по позицииВозможны лишние обновления и перенос state между соседямиТолько для статичных списков без reorder
Список со стабильным keyСопоставление по идентичностиКорректное удаление, вставка и перенос элементовПочти всегда для динамических массивов
Искусственно меняющийся key у компонентаДля React это новый узел на каждом рендереПолный remount и сброс локального stateТолько если remount нужен намеренно

Из таблицы видно, что diffing — это не "магия оптимизации", а набор правил идентичности. Большая часть производственных проблем связана не с самим React, а с тем, что приложение дает алгоритму плохие сигналы.

Production pitfalls: где алгоритм чаще всего "наказывают" плохими входными данными

Ошибка 1. Генерировать key на каждом рендере

Плохой вариант:

{items.map((item) => (
  <Row key={crypto.randomUUID()} item={item} />
))}

Симптомы:

  • каждый элемент списка монтируется заново;
  • сбрасывается локальный state;
  • растет число mount/unmount в профайлере.

Последствие в production: дерганые анимации, потеря фокуса в полях, лишняя работа эффекта и подписок.

Ошибка 2. Использовать индекс как key в списках, которые могут сортироваться или изменять порядок элементов

Симптомы:

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

Последствие: неочевидные UI-баги, которые часто попадают в production, потому что не воспроизводятся на статичном тестовом наборе.

Ошибка 3. Случайно менять тип узла и ждать сохранения состояния

Команда рефакторит разметку, заменяет div на section или один компонент-обертку на другой, а потом удивляется, почему дочерний редактор текста сбрасывается.

Симптомы:

  • локальный state компонента исчезает;
  • cleanup-эффекты срабатывают чаще ожидаемого;
  • профайлер показывает remount вместо update.

Последствие: деградация UX и эффект "экран моргает без причины".

Ошибка 4. Оптимизировать мемоизацией то, что ломается из-за плохой идентичности

Иногда команда видит лишние ререндеры и сразу добавляет React.memo, useMemo и useCallback, хотя корневая причина в неверных key или постоянной смене типа узла.

В таком случае мемоизация маскирует симптом, но не чинит алгоритмическую причину. Сначала нужно проверить идентичность и структуру дерева, затем уже переходить к профилированию производительности React и точечной оптимизации.

Разбор производительности: почему React стремится к O(n)

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

Что это дает на практике:

  • сравнение дерева можно выполнять за линейное время для типовых сценариев;
  • большая часть апдейтов оказывается дешевой;
  • интерфейс остается отзывчивым даже при частых обновлениях состояния.

Но у этого решения есть границы:

  • массовые перестановки детей без key ведут к лишней работе;
  • искусственный remount сводит на нет выгоду от переиспользования узлов;
  • большие списки без виртуализации все равно будут дорогими, даже если diffing корректен.

Важно не переоценивать diffing. Он помогает сократить количество операций с DOM, но не спасает от тяжелых вычислений в render phase, дорогих селекторов, большого числа подписок и неудачной структуры состояния. В реальных приложениях узким местом часто становится не сам diff, а объем работы, который вы заставляете сделать до него.

Практики, которые помогают алгоритму

1. Делайте key стабильным и привязанным к данным

Хороший key — это идентификатор сущности, а не позиция, не случайное число и не текущее время. Если сущность та же, key должен оставаться тем же между рендерами.

2. Не меняйте тип узла без причины

Если цель — лишь изменить стили или поведение, лучше сохранить тип компонента или DOM-элемента и обновить props. Смена типа должна быть осознанной, потому что для diffing это сигнал "старый узел больше не существует".

3. Разделяйте большие деревья на логические границы

Когда экран разбит на независимые компоненты, React легче определять, какие части экрана нужно обновить. Это снижает стоимость diffing и делает профилирование прозрачнее.

4. Профилируйте до оптимизации

Сначала убедитесь, что проблема действительно в лишних update/remount, а не в тяжелой бизнес-логике или запросах. Для этого полезны React DevTools и связанный разбор про React DevTools и дебаг приложений.

5. Используйте remount через key только намеренно

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

Прокачай React за 7 дней

20 вопросов и разборов по React Hooks.

Начать

Частые ошибки

  • Думать, что React сравнивает реальный DOM целиком после каждого setState.
  • Смешивать diffing, reconciliation и commit в один "черный ящик".
  • Использовать индекс как key в списке, который сортируется, фильтруется или редактируется.
  • Генерировать новый key на каждом рендере и удивляться remount.
  • Пытаться лечить проблему мемоизацией, хотя сломана идентичность узлов.
  • Ожидать, что смена типа компонента сохранит локальный state потомков.

Как отвечать на интервью

Сильный короткий ответ обычно звучит так:

  1. Diffing алгоритм в React — это часть reconciliation, которая сравнивает предыдущее и новое дерево элементов.
  2. React не делает полный оптимальный diff произвольных деревьев, а использует быстрые эвристики.
  3. Если тип узла изменился, старое поддерево заменяется новым.
  4. Если тип тот же, React обновляет props и сравнивает детей.
  5. Для списков идентичность задается через key; без стабильного key возможны лишние update и потеря локального state.

Если хотите поднять ответ на middle-уровень, добавьте два уточнения:

  • цель алгоритма — сохранять стоимость типовых обновлений близкой к O(n);
  • ошибки вокруг key и идентичности — это не теория, а источник реальных production-багов.

Хорошо работает и связка с соседними темами: batching обновлений, Fiber и ререндеры. Тогда интервьюер видит, что вы понимаете не один термин, а всю цепочку обновления интерфейса.

Отработайте React-вопросы на реальных инженерных кейсах

Тренируем объяснение diffing, reconciliation, key и производительности так, как это ждут на собеседованиях junior и middle уровня.

Начать практику

FAQ

Diffing и Virtual DOM — это одно и то же?

Нет. Virtual DOM — это представление интерфейса в памяти. Diffing — алгоритм сравнения двух версий этого представления.

Почему React не делает идеальный diff для любого дерева?

Потому что универсально оптимальное сравнение деревьев слишком дорого для частых UI-обновлений. React выбирает более дешевый и практичный набор эвристик.

Всегда ли индекс плох как key?

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

Можно ли специально заставить React пересоздать компонент?

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

Значит ли diffing, что React автоматически очень быстрый?

Нет. Diffing снижает число лишних операций с DOM, но не отменяет цену тяжелого рендера, большого дерева, неудачной архитектуры состояния и плохих данных для сравнения.

Итоги

Diffing алгоритм в React — это набор практичных правил сравнения, а не магия, которая "сама все оптимизирует". React быстро работает там, где структура дерева относительно стабильна, типы узлов предсказуемы, а key корректно описывает идентичность элементов.

Если запомнить три опоры — сравнение по типу, сравнение детей и роль key в списках — большая часть странного поведения React становится объяснимой. А дальше уже проще и профилировать интерфейс, и проектировать список без потери состояния, и спокойно отвечать на вопросы про reconciliation на собеседовании.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью.

Подписаться

Автор

Lexicon Team

Читайте также