React Fiber: как React научился прерывать рендер и расставлять приоритеты
- вторник, 1 сентября 2026 г. в 00:00:06

Всем привет! Я Артем, фронтенд-разработчик в команде мессенджера Т-Банка. Работаю с React не первый год, участвовал в миграции на разные версии реакта, в том числе в переходе с классовых компонентов на функциональные. Мне всегда было интересно, зачем will*-методы были помечены как deprecated и как устроены переходы на функциональные компоненты.
Банальный пример — когда печатаешь в input, а интерфейс начинает лагать на большом списке: классическая боль старого React. Я решил разобраться, как и почему React переписал свою архитектуру: отказался от рекурсивного обхода дерева, изобрел новую структуру данных и научился прерывать рендер ради нашего ввода.
В статье рассмотрю проблемы синхронной отрисовки и переход к новой архитектуре, почему пришлось отказаться от методов жизненного цикла с приставкой *will, что такое Fiber, почему первый вариант приоритизации с ExpirationTime не сработал и как пришли к React Lanes.
Причинно-следственная цепочка, почему старая архитектура была проблемной и не справлялась с плавностью на больших интерфейсах.
UX-требование: отзывчивость → Нужно прерывать рендер → Нельзя хранить прогресс в call stack → Нужна новая структура данных → Fiber → Render ≠ Commit → Render должен быть чистым → will* становятся unsafe → Нужна модель приоритетов → ExpirationTime → Lanes.
До React 16 использовался Stack Reconciler — синхронный непрерывный проход. Его основными проблемами были:
непрерывный проход — как только React начинал обновление, он проходил дерево до конца за один вызов (depth-first), не мог прерваться посередине и отдать управление браузеру;
отсутствие приоритизации — нельзя было эффективно прервать уже идущий рендер ради более срочного обновления (ввод пользователя, навигация);
проблема UX на больших деревьях — долгий синхронный JS-блок приводил к input lag и пропуску кадров;
состояние выполнения = call stack — прогресс обхода и «куда возвращаться» хранились в стеке вызовов, поэтому «пауза/возобновление» были архитектурно неудобны.
Именно эти ограничения стали одной из причин поиска новой архитектуры для построения деревьев и обновления клиента.
React рекурсивно обходил дерево, вычислял изменения и применял их к DOM единым непрерывным блоком. Только после этого вызывались методы вроде componentDidMount/DidUpdate. Поэтому методы componentWillMount/WillReceiveProps/WillUpdate часто воспринимались как шаг перед обновлением DOM и туда нередко помещали побочные действия.
Формально сетевой fetch сам по себе не блокировал рендер, но любые синхронные операции внутри will* удлиняли и без того длинный непрерывный апдейт и усиливали фризы. После применения изменений к DOM вызывались componentDidMount/DidUpdate — это был этап «после-commit», где эффекты соответствовали реально опубликованному UI. С появлением Fiber большинство методов с приставкой will* стали небезопасны и получили статус UNSAFE_, так как фаза может быть прервана/переиграна/отменена.
Кратко о методах жизненного цикла и аналоги в других фреймворках
React (класс) | Angular | Vue 3 | Назначение |
componentWillMount ⚠️ | — | beforeMount | Перед вставкой в DOM |
componentDidMount | ngOnInit / ngAfterViewInit | mounted | Загрузка данных, работа с DOM |
componentWillReceiveProps ⚠️ | ngOnChanges | watch (старый API) | Реакция на новые props |
getDerivedStateFromProps | ngOnChanges | watch / computed | Синхронизация state с props |
componentWillUpdate ⚠️ | — | beforeUpdate | Перед обновлением DOM |
componentDidUpdate | ngOnChanges / ngAfterViewChecked | updated / watch | Реакция на обновление |
componentWillUnmount | ngOnDestroy | unmounted / beforeUnmount | Очистка: таймеры, подписки |
⚠️ — устаревшие
Основная цель Fiber по версии React Fiber Architecture — сделать React более подходящим для таких задач, как анимации, раскладка (layout) и жесты. Его ключевая особенность — инкрементальный рендеринг: возможность разбивать работу на части и выполнять ее инкрементально, не блокируя основной поток. Это позволяет React делать паузы для отрисовки кадров браузером, а еще прерывать или вовсе отменять текущий рендер, если появилось более приоритетное обновление.
Среди других важных возможностей — умение ставить работу на паузу, отменять ее или переиспользовать при поступлении новых обновлений. А еще можно назначать разные приоритеты разным типам обновлений и новые примитивы конкурентности.
По итогу Fiber принес новую модель исполнения рендера, которая сделала возможным прерывание и возобновление работы. А затем стали возможны и массовые пользовательские фичи, завязанные на эту модель: Transitions и тому подобные. Новые фичи пришли в React 18, а модель приоритетов внутри React развилась от expirationTime до Lanes.
Fiber на уровне идеи — это представление части дерева так, чтобы работу можно было разбить на малые юниты и переходить по ним без рекурсивного стека, с рядом ссылок на соседние элементы для возможности приостановки работы в конкретной точке.
В старом Stack Reconciler прогресс обхода дерева жил в JS call stack (рекурсия). Чтобы прервать работу в середине, нужно было бы уметь сохранить или восстановить стек — в JS этого практически не сделать.
В Fiber прогресс обхода хранится в самих узлах через ссылки и поля состояния. Reconciler может быть написан как обычный цикл, который шаг за шагом перемещается по дереву, а между шагами можно выполнить какую-то другую работу. В нашем случае — проверить наличие более важных обновлений.

Ключевые ссылки у Fiber:
child → первый ребенок (спуск «вниз»);
sibling → следующий брат (переход «вправо» в пределах одного уровня);
return → родитель (подъем «вверх»).

Название свойства return историческое: это буквально «куда возвращаться», аналог return address в стеке.
Ключевые ссылки позволяют сделать DFS-обход без рекурсии: React всегда может понять, куда идти дальше, не полагаясь на call stack.
Переход между fiber-узлами — это просто чтение ссылок:
Спуск к ребенку: fiber = fiber.child.
Переход к брату: fiber = fiber.sibling.
Подъем к родителю: fiber = fiber.return.
То, что раньше делал JS call stack («вызвал функцию для child → вернулся»), теперь делает явная структура ссылок. Поэтому reconciler не обязан быть рекурсивным, и его можно прерывать между итерациями цикла.
```tsx // Упрощенная идея — НЕ реальный код React let current = rootFiber; while (current !== null) { // Обработать текущий узел performUnitOfWork(current); // Проверить: есть ли время продолжать? if (shouldYield()) { // Сохраняем указатель — вернемся позже saveProgress(current); return; // отдаем управление браузеру } // Навигация по ссылкам вместо рекурсии if (current.child) { current = current.child; continue; } while (current) { if (current.sibling) { current = current.sibling; break; } current = current.return; } } ```
Главное следствие механизма Fiber и его обработки элементов — reconciler может останавливаться между шагами. Именно это сделало реальными concurrent‑возможности React 18: useTransition, Suspense, selective hydration и так далее.
Render и Commit: две фазы, которые все меняют. Чтобы понять, как React Fiber управляет деревьями, проще всего провести аналогию с видеоиграми или графическими движками. Там используется техника Double Buffering.
Чтобы зритель не видел, как видеокарта попиксельно закрашивает экран, она рисует новый кадр в скрытом буфере. Как только кадр готов, он мгновенно подменяется, становясь текущим.
В React Fiber все работает так же, как и в прошлом механизме:
1. current-дерево: то, что сейчас отображается в DOM. React не трогает его во время вычислений.
2. workInProgress-дерево: «черновик», который строится параллельно во время Render-фазы.
Когда мы вызываем setState, React начинает строить workInProgress-дерево. Он берет существующий узел из current, копирует его (или переиспользует, если ничего не менялось) и применяет изменения.
Если рендер тяжелый и React решит уступить поток браузеру (shouldYield), пользователь не увидит полупустой экран или список, отрисованный наполовину. Он продолжит видеть старое current-дерево, пока новое workInProgress не будет полностью готово к свопу.
Момент свопа — Commit-фаза: как только работа над workInProgress завершена, React в одну короткую синхронную фазу (Commit) меняет ссылку в корне приложения.
root.current = workInProgress;
Всего одна замена указателя — и интерфейс обновлен.
componentWillMount/WillUpdate/WillReceiveProps воспринимались как часть одного прохода перед обновлением DOM. Там делали эффекты: подписки, запросы, синхронизацию.
Если render-phase повторится или отменится:
побочный эффект может выполниться дважды;
побочный эффект может выполниться и «утечь», даже если commit не случился;
это приведет к рассинхрону между реальным DOM и тем, что реально в CurrentTree.
```tsx // В concurrent mode может вызваться дважды componentWillMount() { // В concurrent mode render может прерваться ДО того, // как компонент попадет в DOM. // subscribe вызовется, но componentWillUnmount НЕ вызовется // (ведь компонент не смонтирован!) — утечка. this.subscription = DataSource.subscribe(this.handleChange); } componentWillUnmount() { // Отпишет только одну из двух подписок DataSource.unsubscribe(this.subscription); } ```
React доносил эти изменения постепенно: в 16.3 добавил новые lifecycle-замены и StrictMode, выпустил официальный пост с разбором причин и сценариев миграции. В 16.9 переименовал старые методы в UNSAFE_ и дал codemod react-codemod rename-unsafe-lifecycles. В 17 оставил это как «мост» для экосистемы, а в 18 concurrent-возможности сделали проблемы will* уже не теоретическими, а практическими. Команда React не просто объявила старые методы устаревшими, а дала документацию, предупреждения, инструменты автоматической миграции и несколько версий запаса на переход.

В dev-режиме React намеренно может:
вызвать render дважды;
вызвать cleanup + effect повторно;
смонтировать и размонтировать компонент подряд.
Так React проверяет, безопасен ли код в условиях прерываемого render. Если эффект ломается при повторном вызове, значит, в нем есть скрытая зависимость от порядка выполнения или неочищенные побочные эффекты.
StrictMode — тренировочный полигон для concurrent-архитектуры. Он делает потенциальные проблемы видимыми еще до того, как они проявятся в production.
Мы разобрали, почему React разделил фазу обновления на два этапа. А теперь представим: пользователь печатает в поисковую строку, а React в это время рендерит таблицу на 10 000 строк.
Кто решает, что ввод важнее? И как остановить уже начатый рендер таблицы? За это отвечает планировщик: распределяет время на работу и приоритизацию, чтобы не блокировать main thread надолго. Так как все разбито на небольшие UoW благодаря Fiber, после выполнения каждого такого кусочка или раз в n ms планировщик проверяет, не появилось ли более приоритетное обновление, через битовые маски — React Lanes.
Чем scheduler не занимается:
не дает параллельность как в потоках ОС, все в одном JS потоке. React создает иллюзию многозадачности, а не реальную параллельность;
кооперативное планирование. React уступает управление;
не может отнять поток у выполняющегося JS (нет preemption), поэтому он работает кооперативно.
Внутри React есть проверка типа shouldYield() (уровень идеи). Она отвечает на вопрос: мы слишком долго держим JS, не пора ли отдать контроль?

Реализован планировщик через MessageChannel, а не через requestIdleCallback: он не дает достаточно предсказуемых таймингов, поэтому React использует MessageChannel.
Кто решает, что нужно рендерить, — выяснили. Но что, если одновременно приходят два обновления с разным приоритетом? Мы нажали кнопку «Показать детали» (тяжелая операция), а через 50 ms начали печатать в input. Как React решает, кого обработать первым?
Scheduler — отдельный пакет, «диспетчер задач». Отвечает за то, когда выполнять работу: какие задачи в очереди, когда браузер свободен, когда пора уступить управление.
Lanes — модель внутри React. Отвечает за то, какие именно обновления накопились и насколько они важные.
ExpirationTime в небольших приложениях спокойно работал, пока не возникало нескольких срочных обновлений. Апдейт был с дедлайном, после которого его нужно было срочно обработать. Старая система работала, пока сложность задач была ниже.
Например, срочный ввод получал дедлайн «через 50 ms», transition — «через 5 000 ms». Чем ближе дедлайн — тем выше приоритет. На практике для простых сценариев это работало нормально.
Что не устраивало в системе приоритетов через ExpirationTime. Трудно выразить несколько независимых типов работ одновременно в одном месте:
срочные (взаимодействие пользователя с нашим приложением);
тяжелые операции (transitions);
отработки suspense (когда данные пришли или нужно отобразить ошибку);
фоновое обновление;
сложнее группировать связанные обновления;
не хватало статусов состояния работ.
Представьте: много одновременно срочных и тяжелых обновлений и повторная попытка после Suspense. У всех разные числовые дедлайны, но они никак не связаны между собой. React не может сказать: «Эти два обновления — одного типа, обработай их вместе». Нужна была другая модель.
Lanes: модель «нескольких дорожек» приоритетов:
Lane = категория/канал работы с приоритетом и правилами объединения.
Одновременно могут быть pending Lanes разных типов.
React выбирает, что рендерить сейчас, что отложить, что объединить.
Несколько примеров Lanes по убыванию приоритета:
SyncLane — синхронные обновления самого высокого приоритета (классическая семантика «срочно, прямо сейчас»);
InputContinuousLane — обновления от pointermove/scroll/drag-подобных событий;
DefaultLane — дефолтные обновления (setState без transition);
OffscreenLane — работа, связанная со скрытыми/вынесенными деревьями (Offscreen/некоторые сценарии Suspense/пререндеринга);
IdleLane — самый низкий приоритет (когда-нибудь потом).
Конкретные имена констант и количество lane могут немного меняться между версиями.
Lanes реализованы как битовые маски: 31 бит, чтобы влезть в SMI в популярных JS-движках (например, V8). Это важно для понимания, почему именно \~16 transition-lanes и \~5 retry-lanes: общая сумма ограничена. Из-за такой реализации проверка приоритета превращается в одну процессорную операцию (a & b) !== 0, что на порядки быстрее, чем сравнение объектов или проход по массивам.

Пример: startTransition:
```tsx import { useState, useTransition } from "react"; function App({ items }: { items: string[] }) { const [query, setQuery] = useState(""); const [filteredQuery, setFilteredQuery] = useState(""); const [isPending, startTransition] = useTransition(); return ( <> <input value={query} onChange={(e) => { const next = e.target.value; // SyncLane — мгновенный отклик, ввод не лагает setQuery(next); // TransitionLane — React может прервать этот рендер // ради более срочного обновления startTransition(() => { setFilteredQuery(next); }); }} /> {isPending && <div>Фильтруем…</div>} <List items={items} filter={filteredQuery} /> </> ); } // Тяжесть именно здесь — в рендере большого списка. // Этот рендер React будет прерывать через startTransition. function List({ items, filter }: { items: string[]; filter: string }) { const q = filter.toLowerCase(); const filtered = items.filter((x) => x.toLowerCase().includes(q)); return ( <ul> {filtered.map((x) => ( <li key={x}>{x}</li> ))} </ul> ); } ```
Откроем React DevTools → Profiler. Запишем рендер тяжелого списка и увидим, как React разбивает работу на куски. Если видим долгие блоки (больше 16 мс), лучше обернуть setState в useTransition — и Lanes изменят приоритет.
React Fiber — это фундаментальный сдвиг парадигмы: от «обновить все разом» к «обновлять по частям, в порядке важности». Этот сдвиг потребовал сломать обратную совместимость (will*-методы), изобрести новую структуру данных (Fiber), написать собственный планировщик и дважды переделать систему приоритетов. Но именно благодаря этому мы имеем useTransition, Suspense, автоматический batching — все, что делает React 18+ отзывчивым.