javascript

React Fiber: как React научился прерывать рендер и расставлять приоритеты

  • вторник, 1 сентября 2026 г. в 00:00:06
https://habr.com/ru/companies/tbank/articles/1076774/

Всем привет! Я Артем, фронтенд-разработчик в команде мессенджера Т-Банка. Работаю с React не первый год, участвовал в миграции на разные версии реакта, в том числе в переходе с классовых компонентов на функциональные. Мне всегда было интересно, зачем will*-методы были помечены как deprecated и  как устроены переходы на функциональные компоненты.

Банальный пример — когда печатаешь в input, а интерфейс начинает лагать на большом списке: классическая боль старого React. Я решил разобраться, как и почему React переписал свою архитектуру: отказался от рекурсивного обхода дерева, изобрел новую структуру данных и научился прерывать рендер ради нашего ввода.

В статье рассмотрю проблемы синхронной отрисовки и переход к новой архитектуре, почему пришлось отказаться от методов жизненного цикла с приставкой *will, что такое Fiber, почему первый вариант приоритизации с ExpirationTime не сработал и как пришли к React Lanes.

Stack Reconciler: как работал React до Fiber

Причинно-следственная цепочка, почему старая архитектура была проблемной и не справлялась с плавностью на больших интерфейсах.

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: новая структура данных для прерываемого рендера

Основная цель 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-узлами — это просто чтение ссылок:

  1. Спуск к ребенку: fiber = fiber.child.

  2. Переход к брату: fiber = fiber.sibling.

  3. Подъем к родителю: 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;

Всего одна замена указателя — и интерфейс обновлен.

Почему componentWill* стал небезопасным

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.

Scheduler: как React «договаривается» с браузером

Мы разобрали, почему 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.

Эволюция приоритетов: от ExpirationTime к Lanes

Кто решает, что нужно рендерить, — выяснили. Но что, если одновременно приходят два обновления с разным приоритетом? Мы нажали кнопку «Показать детали» (тяжелая операция), а через 50 ms начали печатать в input. Как React решает, кого обработать первым?

Scheduler — отдельный пакет, «диспетчер задач». Отвечает за то, когда выполнять работу: какие задачи в очереди, когда браузер свободен, когда пора уступить управление.

Lanes — модель внутри React. Отвечает за то, какие именно обновления накопились и насколько они важные.

ExpirationTime в небольших приложениях спокойно работал, пока не возникало нескольких срочных обновлений. Апдейт был с дедлайном, после которого его нужно было срочно обработать. Старая система работала, пока сложность задач была ниже.

Например, срочный ввод получал дедлайн «через 50 ms», transition — «через 5 000 ms». Чем ближе дедлайн — тем выше приоритет. На практике для простых сценариев это работало нормально.

Что не устраивало в системе приоритетов через ExpirationTime. Трудно выразить несколько независимых типов работ одновременно в одном месте: 

  • срочные (взаимодействие пользователя с нашим приложением);

  • тяжелые операции (transitions);

  • отработки suspense (когда данные пришли или нужно отобразить ошибку);

  • фоновое обновление;

  • сложнее группировать связанные обновления;

  • не хватало статусов состояния работ.

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

Lanes: модель «нескольких дорожек» приоритетов:

  1. Lane = категория/канал работы с приоритетом и правилами объединения.

  2. Одновременно могут быть pending Lanes разных типов.

  3. 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+ отзывчивым.