Оптимизация обновлений DOM в React: Virtual DOM и diff

Какой механизм лежит в основе оптимизации обновлений DOM в React?

Теория: что именно оптимизируется

В React обновление интерфейса происходит в несколько шагов: триггер рендера → вычисление результата рендера → фиксация изменений в DOM (commit).
Рендер в React — это вычисление нового «описания интерфейса», а реальный DOM изменяется только на этапе commit.
При повторных рендерах React определяет, что именно изменилось по сравнению с предыдущим результатом, и старается обновить DOM точечно, а не пересоздавать всю разметку.

Важно различать «перерендер компонента» и «обновление DOM»: компонент может вычислиться заново, но если результат совпал с предыдущим, реальный DOM может не измениться.

Механизм React: Virtual DOM → reconciliation → diff → commit

Virtual DOM как “описание UI”

Результат работы компонента (функционального или render() в классовом) можно воспринимать как дерево элементов React — легковесное описание того, что должно быть на экране.
После изменения state или props получается новое дерево, и требуется привести интерфейс к нему.

Схема процесса (упрощенно):

[Событие / setState / обновление данных]
                 |
                 v
(1) Trigger: планирование обновления
                 |
                 v
(2) Render: вычисление нового дерева UI (в памяти)
                 |
                 v
(3) Reconciliation/Diff: сравнение со старым деревом
                 |
                 v
(4) Commit: применение минимальных изменений к DOM

Reconciliation и “diffing” (согласование деревьев)

Теоретически поиск «самого оптимального» набора операций, превращающих одно дерево в другое, может быть слишком дорогим по времени, поэтому используется быстрый эвристический подход.
Базовые идеи эвристик: если тип элемента изменился, старое поддерево обычно заменяется новым; если тип совпадает, можно переиспользовать существующий DOM-узел и обновить только изменившиеся атрибуты/свойства.
Для списков добавляется важная подсказка — key, помогающий понять, какие элементы являются «теми же самыми» между рендерами.

Commit: применение операций к реальному DOM

На первичном рендере создаются DOM-узлы и добавляются в документ.
На повторных рендерах применяются только операции, необходимые для синхронизации DOM с новым деревом (например, изменение атрибута, текста, добавление/удаление конкретного узла).
Идея минимальности заключается в том, что меняется только то, что действительно отличается, а одинаковые части переиспользуются.

Пример, где меняется только атрибут (идея точечного обновления):

function Box({ active }) {
  return (
    <div className={active ? "on" : "off"} title="same-title">
      hello
    </div>
  );
}

При смене active ожидается изменение только className, а не пересоздание всего узла.

Почему остальные варианты неверны

Вариант 1 неверен: React не сводится к прямым ручным манипуляциям DOM «без промежуточных слоев», так как сначала вычисляется новое описание UI, затем выполняется сравнение и только потом происходит commit в DOM.
Вариант 3 неверен: CSS-анимации не являются базовым механизмом вычисления минимальных DOM-изменений; анимации могут применяться дополнительно, но не заменяют согласование дерева.
Вариант 4 неверен: распараллеливание рендера каждого компонента через Web Workers не является основой механизма обновления DOM в React; ключевой механизм — сравнение деревьев и последующее применение рассчитанных изменений.

Практические детали (keys, списки, состояние)

Keys и списки: зачем нужен `key`

Без key элементы в списке сопоставляются в упрощенном порядке (по позиции), из‑за чего вставка/удаление в начале списка может приводить к большему числу изменений, чем нужно.
key помогает правильно сопоставить элементы между старым и новым деревом, чтобы понять, что было добавлено, удалено, перемещено и что можно переиспользовать.
key должен быть уникальным среди соседних элементов и стабильным во времени; нестабильные ключи могут вызывать лишние пересоздания и неожиданную потерю локального состояния дочерних компонентов.

Пример корректного key в списке:

function TodoList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.text}</li>
      ))}
    </ul>
  );
}
Индекс массива в роли key часто приводит к проблемам при вставках/удалениях и перестановках, так как «личность» элемента начинает зависеть от позиции, которая меняется.

Когда React заменяет поддерево целиком

Если в одной и той же позиции дерева раньше был элемент одного типа, а стал другого типа, чаще всего старое поддерево уничтожается и создается заново.
Это может приводить к тому, что дочерние компоненты будут смонтированы заново, а их внутреннее состояние (например, введенный текст, фокус, локальные состояния) может быть потеряно.

Пример смены типа корневого узла (упрощенно):

function Wrapper({ mode }) {
  if (mode === "a") {
    return <div><Counter /></div>;
  }
  return <span><Counter /></span>;
}

Таблица: фазы обновления в React

ФазаЧто происходитГде появляется “оптимизация”
TriggerИзменение данных (state/props), планирование обновленияОпределяется, что надо пересчитать
RenderВычисляется новое дерево UI (в памяти)Здесь готовится новое описание интерфейса
Reconciliation/DiffСравнивается новое дерево со старым, выбираются измененияНаходится минимально необходимый набор действий
CommitПрименяются изменения к реальному DOMDOM меняется точечно, только где нужно

Итого: оптимизация обновлений DOM в React основана на вычислении нового дерева UI в памяти, сравнении его со старым (reconciliation/diff, включая роль key в списках) и применении в commit-фазе только минимально необходимых операций к реальному DOM.