Реконсиляция и 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 на такое не идёт и вместо этого использует эвристику линейной сложности, построенную на двух допущениях:
- Элементы разных типов дают разные деревья. Если на одном месте был
div, а сталsection— React не сравнивает их детей, а просто выбрасывает старое поддерево и строит новое. - Разработчик подскажет, какие дети списка стабильны, через
key. Об этом — следующий урок.
Отсюда главное правило: личность узла для React — это его тип плюс позиция в дереве (а внутри списка ещё и key). Совпали — узел переиспользуется, состояние живёт. Не совпали — размонтирование и монтирование заново.
| Что изменилось между рендерами | Что делает React |
Тот же тип, изменились пропсы (className, placeholder) | Оставляет DOM-узел, правит только изменённые атрибуты. Состояние сохраняется |
Тип изменился (input → div, Foo → Bar) | Размонтирует поддерево целиком, монтирует новое. Состояние теряется |
| Тот же компонент, но переехал в другое место дерева | Для 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).