Как React рендерит: виртуальный DOM, reconciliation и батчинг
Ты нажал кнопку, состояние изменилось, и React «перерисовал» интерфейс. Всё просто, правда? Но за этим словом «перерисовал» скрывается цепочка решений: что вообще считается изменением, как React понимает, какие части дерева тронуть, почему два вызова setState подряд не дают два рендера, и почему список из ста элементов может либо мгновенно обновиться, либо подвесить вкладку на полсекунды. Краткая версия учебника давала тебе API. Здесь — механизм.
Понимание рендеринга — это не академический интерес. Почти каждый «мистический» баг в React упирается в непонимание одного из четырёх пунктов: что запускает рендер, как React сравнивает деревья, что такое батчинг и что происходит в фазе commit. Разберём их по порядку.
Виртуальный DOM: UI как данные
Заголовок раздела «Виртуальный DOM: UI как данные»Виртуальный 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 запускает его в трёх случаях:
- Первый рендер (монтирование) — всегда.
- Изменение состояния этого компонента (
setState,useState-сеттер,dispatch). - Изменение пропсов родителя — родитель перерендерился, и 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 — но об этом в следующей главе.
Reconciliation: как React сравнивает деревья
Заголовок раздела «Reconciliation: как React сравнивает деревья»Итак, у React есть два дерева элементов: старое (отражающее текущий DOM) и новое (результат свежего рендера). Reconciliation — алгоритм сравнения, который решает, что изменилось. Он работает за O(n), а не за O(n³) (как полное сравнение всех пар узлов), за счёт двух эвристик:
Эвристика 1. Разные типы — разные поддеревья. Если элемент был <div>, а стал <span> — React не сравнивает их детей, а разрушает старое поддерево целиком и строит новое. То же с компонентами: было <Comments />, стало <SearchBox /> — внутреннее состояние Comments уничтожается. Отсюда важное следствие: не меняй тип корневого элемента компонента «на лету», если не хочешь потерять состояние детей.
Эвристика 2. Список сопоставляется по ключам. Когда у соседних элементов списка есть key, React понимает: «тот же ключ — тот же элемент, просто переместился». Ключей нет — React сопоставляет по позиции, и вот тут начинаются проблемы. Подробный разбор того, как ключи и позиция влияют на сохранение состояния, — в официальном гайде Preserving and Resetting State.
Почему key={index} опасен
Заголовок раздела «Почему key={index} опасен»Антипаттерн 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() — но это редкость, нужная в основном библиотекам-интеграциям.
Два следствия автоматического батчинга, о которых стоит помнить:
- Нельзя читать обновлённое DOM сразу после
setState— рендер ещё не случился. Для этого есть эффекты (см. следующую главу) или callback-формаref. flushSyncоборачивает синхронный рендер и вызывает ререндер прямо сейчас — внутри него нельзя вызыватьsetStateродительских компонентов (React ругается), и это ломает планировщик. Используй только если без синхронного DOM обновления буквально не работает интеграция со сторонней библиотекой.
Фазы render и commit
Заголовок раздела «Фазы render и commit»Рендер в терминах 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». Это экономит часы дебага: вместо чтения всего дерева компонентов ты сразу видишь цепочку.
Мини-напоминание по порядку действий: сначала профиль, потом оптимизация, потом профиль снова. Никогда не оптимизируй «на всякий случай» — мемоизация сама по себе стоит ресурсов.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Сайд-эффекты в теле компонента. Запросы, подписки, мутации DOM прямо в рендере ломаются под StrictMode и конкурентным рендерингом. Правило: рендер чист, эффекты — в
useEffect/useLayoutEffect. key={index}для динамических списков. Статичный список выведенных из константы элементов — допустимо, но как только элементы добавляются, удаляются или сортируются — состояние начнёт «прилипать» к чужим строкам. Используй стабильные id из данных.- Чтение
stateсразу послеsetState. Значение обновится только в следующем рендере. Если нужна цепочка — функциональные обновления; если нужен код после применения — эффект. - Рендер как «дорогая операция, которую надо глушить всегда». Лишний рендер пустого компонента — микросекунды. Преждевременная мемоизация всего подряд — читаемость в минус, выигрыша ноль. Оптимизируй измеренное.
- Пересоздание ключей в рендере (
key={Math.random()}) — полный ремаунт списка на каждый рендер: потеря фокуса, сброс введённых данных, дёрганье анимаций. - Ожидание частичного применения при не-автоматическом батчинге в старом коде. Если поддерживаешь код на 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-режиме обнаружить нарушения чистоты рендера и небезопасные эффекты. Если код корректен, двойной вызов безвреден; если нет — баг всплывёт сразу, а не в проде с конкурентными фичами.
Практика
Заголовок раздела «Практика»- Воспроизведи баг с индексами. Сделай список todo с инпутами (
key={index}), введи текст в два пунтка, удали первый. Зафиксируй рассинхрон. Исправь ключи наidи повтори. Объясни разницу словами «что решил reconciliation». - Поймай двойной рендер. Напиши компонент с мутацией внешней переменной в теле (нарушение чистоты), оберни приложение в
<React.StrictMode>и посмотри, как счётчик мутаций растёт. Вынеси мутацию вuseEffect. - Батчинг-эксперимент. В обработчике клика вызови два
setStateс разными полями, залогируй количество рендеров (счётчик вuseEffectбез зависимостей). Повтори то же вsetTimeout. На React 18+ оба кейса дадут один рендер — проверь. - Профилировка реального тормоза. Сгенерируй список из 1000 элементов с сортировкой в рендере. Открой Profiler, измерь. Затем перенеси фильтрацию в
useDeferredValue(глава 4) и сравни измерения. - «Почему рендер». Возьми любой рабочий компонент, найди в DevTools опцию why-did-this-render и проследи цепочку причин для трёх случайных рендеров.
Что почитать
Заголовок раздела «Что почитать»- React Dev: Render and Commit — официальное описание фаз рендеринга.
- React Dev: Preserving and Resetting State — как ключи и позиция влияют на сохранение состояния, с наглядными примерами.
- React Dev: You Might Not Need an Effect — про чистоту рендера и производные состояния (продолжение в следующей главе).
- React Dev: Automatic Batching — анонс автоматического батчинга в React 18.
- The React Fiber Architecture (Claudiо Ciferri’s notes) — оригинальные заметки разработчика React о Fiber и прерываемом рендеринге.