Реконсиляция и Virtual DOM

Пользователь набрал имя в поле, вы показали над полем подсказку — и текст исчез. Разбираем, почему React размонтировал компонент, хотя «ничего же не менялось».

Реконсиляция (reconciliation) — алгоритм, которым React сравнивает новое дерево элементов со старым и решает, какие узлы настоящего DOM создать, обновить или выбросить.

Вопрос: что произойдёт с введённым текстом?

Интервьюер показывает форму и говорит: «Пользователь ввёл имя, потом нажал кнопку и включил подсказку. Что случится с тем, что он напечатал?»

function Form() {
  const [withHint, setWithHint] = useState(false);

  if (withHint) {
    return (
      <div className="hint">
        <p>Имя — как в паспорте</p>
        <input placeholder="Имя" />
      </div>
    );
  }

  return <input placeholder="Имя" />;
}

Правильный ответ: текст пропадёт. И это не баг React, а прямое следствие его алгоритма сравнения. До нажатия на верхнем уровне компонента стоял элемент типа input. После нажатия на том же месте стоит div. Типы разные — значит, для React это другой узел. Он не пытается «переделать input в div»: он размонтирует всё старое поддерево (вместе с реальным DOM-узлом <input>, в котором и жил набранный текст) и монтирует новое с нуля.

Тот же самый эффект убивает состояние дочерних компонентов, сбрасывает фокус, позицию прокрутки и проигрываемое видео. Поэтому вопрос и любят: он проверяет, понимаете ли вы, что React сравнивает не DOM с DOM, а описания — старое и новое дерево элементов.

Как починить

Нужно, чтобы input оставался на одном и том же месте дерева, а менялось только его окружение:

function Form() {
  const [withHint, setWithHint] = useState(false);

  return (
    <div className="hint">
      {withHint && <p>Имя — как в паспорте</p>}
      <input placeholder="Имя" />
    </div>
  );
}

Тонкость, за которую на собеседовании дают плюс: когда withHint равно false, выражение {withHint && ...} возвращает false — и этот «пустой» ребёнок всё равно занимает свою позицию в массиве детей. Поэтому input и в одном, и в другом случае остаётся вторым ребёнком div. Позиция та же, тип тот же — React переиспользует существующий DOM-узел, текст остаётся на месте.

Что такое Virtual DOM на самом деле

Virtual DOM (VDOM) звучит загадочно, а на деле это обычные объекты JavaScript. JSX <div className="card">…</div> компилируется в вызов функции, которая возвращает описание: что за тип, какие пропсы, какие дети. Никакого DOM в этот момент не создаётся. Смоделируем это в чистом JS — можете нажать «Запустить» и посмотреть на «виртуальный DOM» своими глазами:

function h(type, props, ...children) {
  return { type, props: { ...props, children } };
}

const tree = h('div', { className: 'card' },
  h('h2', null, 'Заголовок'),
  'просто текст'
);

console.log(JSON.stringify(tree, null, 2));
console.log('тип корня:', tree.type);
console.log('детей у корня:', tree.props.children.length);

Результат:

{
  "type": "div",
  "props": {
    "className": "card",
    "children": [
      {
        "type": "h2",
        "props": {
          "children": [
            "Заголовок"
          ]
        }
      },
      "просто текст"
    ]
  }
}
тип корня: div
детей у корня: 2

Вот и весь секрет: дешёвый объект вместо дорогого DOM-узла. Создать сто тысяч таких объектов — вопрос миллисекунд, а вот сто тысяч раз тронуть настоящий DOM — заметные тормоза и лишние пересчёты стилей браузером. React строит новое дерево описаний, сравнивает его со старым и трогает DOM только там, где реально что-то изменилось.

Как React сравнивает деревья

Честное сравнение двух деревьев — задача с кубической сложностью. React на такое не идёт и вместо этого использует эвристику линейной сложности, построенную на двух допущениях:

  1. Элементы разных типов дают разные деревья. Если на одном месте был div, а стал section — React не сравнивает их детей, а просто выбрасывает старое поддерево и строит новое.
  2. Разработчик подскажет, какие дети списка стабильны, через key. Об этом — следующий урок.

Отсюда главное правило: личность узла для React — это его тип плюс позиция в дереве (а внутри списка ещё и key). Совпали — узел переиспользуется, состояние живёт. Не совпали — размонтирование и монтирование заново.

Что изменилось между рендерамиЧто делает React
Тот же тип, изменились пропсы (className, placeholder)Оставляет DOM-узел, правит только изменённые атрибуты. Состояние сохраняется
Тип изменился (inputdiv, FooBar)Размонтирует поддерево целиком, монтирует новое. Состояние теряется
Тот же компонент, но переехал в другое место дереваДля React это новый узел: старый размонтирован, новый смонтирован
Изменился key у элементаРазмонтирование и монтирование заново — даже если тип тот же

Ловушка: компонент, объявленный внутри компонента

Любимая задачка follow-up. Почему поле здесь теряет фокус после каждого символа?

function Page() {
  const [text, setText] = useState('');

  // ПЛОХО: новая функция-компонент при каждом рендере Page
  function Field() {
    return <input value={text} onChange={(e) => setText(e.target.value)} />;
  }

  return <Field />;
}

Каждый рендер Page создаёт новую функцию Field. А тип элемента — это ссылка на функцию. Новая ссылка — новый тип — размонтирование и монтирование заново на каждое нажатие клавиши. Отсюда и потерянный фокус. Лечится выносом Field наружу, за пределы Page.

Как это работает под капотом

С React 16 внутри работает архитектура Fiber. Для каждого элемента React держит рабочий узел (fiber) с ссылками на родителя, первого ребёнка и следующего соседа — обход дерева стал обычным циклом, а не рекурсией, и его можно прервать и продолжить позже.

Работа делится на две фазы. Фаза рендера — React вызывает функции компонентов, строит новое дерево fiber'ов рядом со старым (техника double buffering: у каждого fiber есть «двойник» — alternate) и помечает узлы эффектами: «вставить», «обновить», «удалить». Эта фаза прерываемая и не трогает DOM — именно поэтому в теле компонента нельзя писать побочные эффекты, React вправе вызвать функцию и выбросить результат. Фаза коммита — синхронный проход по списку эффектов: React применяет их к настоящему DOM, а затем вызывает useLayoutEffect и useEffect.

Если у узла не изменились ни пропсы, ни состояние, ни контекст, React делает bailout — не вызывает функцию компонента и переиспользует старое поддерево.

Частые ошибки на собеседовании

  • «Virtual DOM быстрее реального DOM». Нет. Быстрее — не трогать DOM лишний раз. VDOM добавляет работу (построить дерево, сравнить), а окупается тем, что итоговых операций с DOM оказывается меньше. Ручной, точечный код без React в теории быстрее — но его невозможно поддерживать.
  • «React сравнивает старый DOM с новым». Нет, он сравнивает два дерева JS-объектов и лишь потом идёт в DOM с готовым списком правок.
  • «Diff всегда точный». Нет, это эвристика. Она может «промахнуться» — и как раз ваша задача помочь ей типами и ключами.
  • Путать реконсиляцию и рендер. Рендер — вызов функции компонента. Реконсиляция — сравнение результата со старым деревом. Обновление DOM — только третий шаг, и он часто пустой.

Итоги-шпаргалка

  • Элемент React — это простой объект { type, props, key }, а не DOM-узел.
  • Реконсиляция сравнивает деревья элементов и выдаёт минимальный список правок для DOM.
  • Личность узла = тип + позиция в дереве (+ key в списках). Изменился тип — поддерево размонтируется, состояние умирает.
  • Условный рендер делайте внутри общей обёртки, а не подменяйте корень целиком.
  • Никогда не объявляйте компонент внутри другого компонента — получите ремоунт на каждый рендер.
  • Две фазы: render (прерываемая, без побочных эффектов) и commit (синхронная, трогает DOM).
Проверьте себя
1. Компонент возвращал <input />, а после смены состояния стал возвращать <div><input /></div>. Что произойдёт с текстом, который пользователь уже ввёл?
AТекст сохранится: input же остался input
BТекст пропадёт: тип элемента на этой позиции изменился, старое поддерево размонтировано
CТекст сохранится, но потеряется фокус
DReact выбросит предупреждение и оставит старый DOM
2. Что React сравнивает во время реконсиляции?
AСтарые и новые DOM-узлы напрямую в документе
BHTML-строки, полученные из JSX
CСтарое и новое деревья элементов React — обычных JS-объектов
DХеш-суммы стилей и атрибутов