Перейти к содержимому

Как React рендерит: виртуальный DOM, reconciliation и батчинг

Ты нажал кнопку, состояние изменилось, и React «перерисовал» интерфейс. Всё просто, правда? Но за этим словом «перерисовал» скрывается цепочка решений: что вообще считается изменением, как React понимает, какие части дерева тронуть, почему два вызова setState подряд не дают два рендера, и почему список из ста элементов может либо мгновенно обновиться, либо подвесить вкладку на полсекунды. Краткая версия учебника давала тебе API. Здесь — механизм.

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

Виртуальный DOM — это обычный JavaScript-объект, описывающий, как должен выглядеть DOM. Когда ты пишешь JSX:

const element = <h1 className="title">Привет, {name}</h1>;

на выходе получается не DOM-узел, а примерно такой объект:

const element = {
type: 'h1',
key: null,
props: {
className: 'title',
children: `Привет, ${name}`,
},
};

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

Кстати, у виртуального DOM есть второе, менее известное применение — рендеринг вне браузера. React Native рисует нативные компоненты, а react-dom/server генерирует HTML-строку, обходя DOM вообще. Механизм один: функция компонента → дерево элементов → конкретный «рендерер» превращает его в цель (DOM, нативные вьюхи, HTML).

Рендер — это вызов твоей функции-компонента. React запускает его в трёх случаях:

  1. Первый рендер (монтирование) — всегда.
  2. Изменение состояния этого компонента (setState, useState-сеттер, dispatch).
  3. Изменение пропсов родителя — родитель перерендерился, и React вызывает дочерний компонент заново, чтобы узнать, изменился ли его вывод.

Важный, часто неверно понимаемый момент: по умолчанию рендер родителя рендерит всех детей, даже если их пропсы не изменились. Если ты не обернул дочерний компонент в React.memo и не мемоизировал пропсы — он перерендерится. Это не баг, это дефолтная модель: React не знает, зависит ли вывод дочернего компонента от чего-то кроме пропсов (например, от глобального состояния), поэтому вызывает его заново и сравнивает результат.

function App() {
const [query, setQuery] = useState('');
return (
<>
<SearchInput value={query} onChange={setQuery} />
{/* Sidebar перерендерится на КАЖДЫЙ ввод символа, хотя ему query не нужен */}
<Sidebar />
</>
);
}

Вводишь символ → setQuery → рендер App → рендер SearchInput и Sidebar. Если Sidebar тяжёлый (меню, дерево, графики), интерфейс начнёт подтормаживать. Это и есть классическая причина «оптимизировать рендеры» через React.memo, useMemo и useCallback — но об этом в следующей главе.

Итак, у React есть два дерева элементов: старое (отражающее текущий DOM) и новое (результат свежего рендера). Reconciliation — алгоритм сравнения, который решает, что изменилось. Он работает за O(n), а не за O(n³) (как полное сравнение всех пар узлов), за счёт двух эвристик:

Эвристика 1. Разные типы — разные поддеревья. Если элемент был <div>, а стал <span> — React не сравнивает их детей, а разрушает старое поддерево целиком и строит новое. То же с компонентами: было <Comments />, стало <SearchBox /> — внутреннее состояние Comments уничтожается. Отсюда важное следствие: не меняй тип корневого элемента компонента «на лету», если не хочешь потерять состояние детей.

Эвристика 2. Список сопоставляется по ключам. Когда у соседних элементов списка есть key, React понимает: «тот же ключ — тот же элемент, просто переместился». Ключей нет — React сопоставляет по позиции, и вот тут начинаются проблемы. Подробный разбор того, как ключи и позиция влияют на сохранение состояния, — в официальном гайде Preserving and Resetting State.

Антипаттерн key={index} выглядит безобидно, пока список статичен. Сломается он при двух операциях: вставке/удалении в начало или середине и сортировке.

// ❌ Плохо: ключи = индексы
function TodoList({ todos }: { todos: string[] }) {
return (
<ul>
{todos.map((text, index) => (
<li key={index}>
<TodoInput initialText={text} />
</li>
))}
</ul>
);
}

Сценарий бага: пользователь ввёл в первый инпут «Купить молоко», во второй — «Позвонить маме». Удаляется первый элемент. С ключами-индексами React решает: «индекс 0 теперь содержит „Позвонить маме” — это тот же элемент, что был, поменялся только текст пропса». И переиспользует DOM-узел со введённым пользователем текстом (если инпут неконтролируемый) или внутренним состоянием. Текст «Купить молоко» привязан к первому DOM-узлу, но элемент с этим ключом теперь описывает другую строку данных — состояние и данные расходятся. В продакшене это выглядит как «я удалил одну строку, а поломалась совсем другая».

// ✅ Хорошо: стабильный уникальный id из данных
{todos.map((todo) => (
<li key={todo.id}>
<TodoInput initialText={todo.text} />
</li>
))}

Теперь при удалении первого элемента React видит: элемента с ключом todo-42 больше нет — его поддерево уничтожается целиком, а остальные элементы (с их состоянием) просто сдвигаются. Состояние никогда не «приклеивается» к чужим данным.

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

Батчинг: почему стейт «не обновляется сразу»

Заголовок раздела «Батчинг: почему стейт «не обновляется сразу»»

Батчинг — это группировка нескольких обновлений состояния в один рендер. Классический пример «неожиданного» поведения:

function Counter() {
const [count, setCount] = useState(0);
const incrementTwice = () => {
setCount(count + 1); // count здесь всё ещё 0
setCount(count + 1); // и здесь тоже 0
};
// результат: 1, а не 2
}

Оба вызова используют значение count, захваченное на момент рендера — то есть 0. React выполняет обновления асинхронно и не даёт прочитать «текущее» состояние между вызовами сеттера. Решение — функциональная форма обновления, где React передаёт актуальное значение из очереди:

const incrementTwice = () => {
setCount((prev) => prev + 1); // 0 -> 1
setCount((prev) => prev + 1); // 1 -> 2
};

До React 18 батчинг работал только внутри нативных обработчиков React (onClick и т.п.). Вне их — в таймаутах, промисах, обработчиках собственных подписок — каждый setState вызывал отдельный рендер:

// React 17: ДВА рендера — батчинга вне React-обработчиков не было
setTimeout(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
}, 1000);

React 18 ввёл автоматический батчинг (automatic batching): все обновления — везде, включая таймауты, промисы, нативные обработчики — группируются в один рендер. Это убрало класс хитрых багов вида «в проде (React 17) всплывает два рендера, в dev-режиме React 18 — один». Отменить батчинг для конкретного случая можно через ReactDOM.flushSync() — но это редкость, нужная в основном библиотекам-интеграциям.

Два следствия автоматического батчинга, о которых стоит помнить:

  1. Нельзя читать обновлённое DOM сразу после setState — рендер ещё не случился. Для этого есть эффекты (см. следующую главу) или callback-форма ref.
  2. flushSync оборачивает синхронный рендер и вызывает ререндер прямо сейчас — внутри него нельзя вызывать setState родительских компонентов (React ругается), и это ломает планировщик. Используй только если без синхронного DOM обновления буквально не работает интеграция со сторонней библиотекой.

Рендер в терминах React — это не «нарисовал в браузере». Это только вызов твоих компонентов и вычисление, что должно измениться (официальный разбор — Render and Commit). Полный цикл обновления:

Событие → setState → Render (вызов компонентов, diff) → Commit (изменения в DOM) → Browser paint

Фаза render — чистая и прерываемая. Здесь React вызывает твои компоненты, сравнивает результат с предыдущим деревом, помечает изменения. Важно: эта фаза может выполняться, откладываться, перезапускаться (Concurrent Mode) — и поэтому код рендера обязан быть чистым. Никаких сайд-эффектов в теле компонента: подписок, запросов, мутаций DOM, изменения внешних переменных. Нарушил — получишь непредсказуемое поведение в строгом режиме (где React намеренно рендерит дважды, чтобы ловить именно такие нарушения) и ломаную конкурентность.

Фаза commit — синхронная и необратимая. Здесь React применяет вычисленные изменения к реальному DOM: вставки, удаления, изменения атрибутов. Сразу после этого React вызывает эффекты (useEffect — асинхронно, через макрозадачу, useLayoutEffect — синхронно, до paint браузера). Последний этап — отрисовка браузером (paint), на который React не влияет.

// ❌ Нарушение чистоты рендера — сайд-эффект прямо в теле
function BadComponent({ userId }: { userId: string }) {
localStorage.setItem('lastUser', userId); // мутирует внешний мир во время рендера!
return <div>{userId}</div>;
}
// ✅ Побочный эффект — в useEffect (фаза commit)
function GoodComponent({ userId }: { userId: string }) {
useEffect(() => {
localStorage.setItem('lastUser', userId);
}, [userId]);
return <div>{userId}</div>;
}

Оптимизация без профиля — это пальцем в небо. Встроенный в React DevTools Profiler отвечает на вопросы: «какой компонент рендерится чаще всего», «сколько времени уходит на рендер», «зачем вообще случился этот рендер».

Практический сценарий: интерфейс «подтормаживает» при вводе в поисковую строку. Открываешь Profiler, записываешь десяток нажатий клавиш, смотришь flame-график. Видишь, что на каждый введённый символ рендерится DataTable с 500 строками — 60 мс на рендер. Вот твой виновник. Дальше — решение: мемоизация строк, виртуализация списка, useDeferredValue (гл. 4) или перенос фильтрации.

Свежие версии DevTools умеют показывать и причину рендера (why did this render?): «props changed», «state changed», «parent rerendered». Это экономит часы дебага: вместо чтения всего дерева компонентов ты сразу видишь цепочку.

Мини-напоминание по порядку действий: сначала профиль, потом оптимизация, потом профиль снова. Никогда не оптимизируй «на всякий случай» — мемоизация сама по себе стоит ресурсов.

  1. Сайд-эффекты в теле компонента. Запросы, подписки, мутации DOM прямо в рендере ломаются под StrictMode и конкурентным рендерингом. Правило: рендер чист, эффекты — в useEffect/useLayoutEffect.
  2. key={index} для динамических списков. Статичный список выведенных из константы элементов — допустимо, но как только элементы добавляются, удаляются или сортируются — состояние начнёт «прилипать» к чужим строкам. Используй стабильные id из данных.
  3. Чтение state сразу после setState. Значение обновится только в следующем рендере. Если нужна цепочка — функциональные обновления; если нужен код после применения — эффект.
  4. Рендер как «дорогая операция, которую надо глушить всегда». Лишний рендер пустого компонента — микросекунды. Преждевременная мемоизация всего подряд — читаемость в минус, выигрыша ноль. Оптимизируй измеренное.
  5. Пересоздание ключей в рендере (key={Math.random()}) — полный ремаунт списка на каждый рендер: потеря фокуса, сброс введённых данных, дёрганье анимаций.
  6. Ожидание частичного применения при не-автоматическом батчинге в старом коде. Если поддерживаешь код на React 17 — помни, что в промисах и таймаутах батчинга нет, и setState-пары там дают два рендера (что само по себе не страшно, но объясняет «лишние» рендеры в профиле).

Что такое виртуальный DOM и зачем он нужен? JS-представление UI, которое дёшево создавать и сравнивать. React вычисляет новое дерево в памяти, диффит со старым и применяет к реальному DOM минимальный набор изменений — вместо перерисовки всего. Бонус: один механизм рендеринга для DOM, SSR и React Native.

Почему нельзя использовать индекс массива как key? React сопоставляет элементы списка по ключам между рендерами. При вставке/удалении в начале списка ключи-индексы «съезжают»: элемент с индексом 2 становится бывшим элементом с индексом 1, и React переиспользует его DOM со всем внутренним состоянием (ввод в инпутах, фокус, локальные хуки) для других данных. Результат — рассинхрон состояния и данных.

Что такое reconciliation? Алгоритм сравнения двух деревьев элементов за O(n) благодаря двум эвристикам: элементы разных типов заменяются целиком, а списки сопоставляются по ключам. Результат — минимальный набор операций над реальным DOM.

В чём разница между фазами render и commit? Render — вызов компонентов и вычисление diff; фаза чистая, может прерываться и перезапускаться (поэтому без сайд-эффектов). Commit — синхронное применение изменений к DOM и вызов эффектов; необратимая.

Что изменил автоматический батчинг в React 18? Раньше батчинг работал только в нативных обработчиках React; в таймаутах и промисах каждый setState давал отдельный рендер. Теперь батчинг везде по умолчанию, уменьшая количество рендеров. Отключить точечно можно через flushSync.

Почему стейт нельзя прочитать сразу после setState? Сеттер ставит обновление в очередь; значение переменной в текущем рендере не меняется. Компонент перерендерится позже с новым значением. Для цепочек — функциональные обновления (prev) => ..., для кода после применения — эффекты.

Зачем StrictMode вызывает рендер/эффекты дважды? Чтобы в dev-режиме обнаружить нарушения чистоты рендера и небезопасные эффекты. Если код корректен, двойной вызов безвреден; если нет — баг всплывёт сразу, а не в проде с конкурентными фичами.

  1. Воспроизведи баг с индексами. Сделай список todo с инпутами (key={index}), введи текст в два пунтка, удали первый. Зафиксируй рассинхрон. Исправь ключи на id и повтори. Объясни разницу словами «что решил reconciliation».
  2. Поймай двойной рендер. Напиши компонент с мутацией внешней переменной в теле (нарушение чистоты), оберни приложение в <React.StrictMode> и посмотри, как счётчик мутаций растёт. Вынеси мутацию в useEffect.
  3. Батчинг-эксперимент. В обработчике клика вызови два setState с разными полями, залогируй количество рендеров (счётчик в useEffect без зависимостей). Повтори то же в setTimeout. На React 18+ оба кейса дадут один рендер — проверь.
  4. Профилировка реального тормоза. Сгенерируй список из 1000 элементов с сортировкой в рендере. Открой Profiler, измерь. Затем перенеси фильтрацию в useDeferredValue (глава 4) и сравни измерения.
  5. «Почему рендер». Возьми любой рабочий компонент, найди в DevTools опцию why-did-this-render и проследи цепочку причин для трёх случайных рендеров.