Kodokon kodokon.com

Под капотом: согласование, батчинг, конкурентный рендеринг

Разберись, как Fiber согласует дерево, группирует обновления и делает работу рендеринга прерываемой.

11 мин · 3 вопросов

Открыть этот урок в Kodokon

Начиная с React 16 внутренний движок называется Fiber: каждый элемент твоего дерева представлен узлом fiber, единицей работы. На самом деле React держит два дерева (приём двойной буферизации): дерево current, которое отражает то, что на экране, и дерево workInProgress, которое строится во время рендеринга. Работа идёт в двух разных фазах: фаза рендеринга (вызов твоих компонентов, вычисление различий), прерываемая и воспроизводимая заново, затем фаза коммита (применение мутаций к DOM, запуск эффектов), которая всегда синхронна и непрерываема. Согласование опирается на две эвристики, сводящие сравнение к линейной стоимости: если тип элемента меняется в данной позиции, React уничтожает всё поддерево и строит его заново, вместе с состоянием; для списков проп key позволяет React сопоставить элементы между двумя рендерами.

JSX
function List({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <input defaultValue={item.label} />
        </li>
      ))}
    </ul>
  );
}
// With key={index}, deleting the first item
// would shift the content of the remaining inputs.
Стабильный key сохраняет состояние и DOM каждой строки.

Второй фундаментальный механизм - батчинг. Когда несколько обновлений состояния запускаются в одном и том же контексте, React объединяет их в один рендер. До React 18 такая группировка работала только внутри обработчиков событий React; начиная с createRoot она автоматическая везде: таймеры, промисы, нативные слушатели. Именно поэтому чтение состояния сразу после setState возвращает старое значение: обновление запланировано, а не применено.

JSX
function handleTick() {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // React 18: a single render, even here.
}

setTimeout(handleTick, 100);
// Before React 18, this timer would trigger
// two successive renders.
Автоматический батчинг теперь охватывает любой контекст.

Конкурентный рендеринг идёт ещё дальше: фаза рендеринга может быть прервана, возобновлена или даже отброшена и запущена заново, если приходит более срочное обновление. React ранжирует обновления по приоритету (lanes): нажатие клавиши срочно, а фильтрация списка, помеченная через startTransition, нет. Прямое следствие: функции твоих компонентов должны быть чистыми, потому что React может вызвать их несколько раз, так и не закоммитив результат. Ещё один крайний случай: внешнее хранилище (Redux, Zustand, самописный синглтон), прочитанное во время прерываемого рендера, может измениться посреди рендера, порождая несогласованный интерфейс - это называется tearing. API useSyncExternalStore решает эту проблему, форсируя синхронный рендер при изменении хранилища.

JAVASCRIPT
import { useSyncExternalStore } from 'react';

function useWindowWidth() {
  return useSyncExternalStore(
    (notify) => {
      window.addEventListener('resize', notify);
      return () =>
        window.removeEventListener('resize', notify);
    },
    () => window.innerWidth
  );
}
Чтение внешнего хранилища без риска tearing.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Что делает React при согласовании, если элемент <div> становится <span> в той же позиции?
    • Он обновляет тег, сохраняя потомков
    • Он уничтожает всё поддерево вместе с состоянием и строит его заново
    • Он рекурсивно сравнивает потомков, прежде чем решить
    • Он выбрасывает ошибку согласования
  2. С React 18 и createRoot сколько рендеров вызовут два вызова setState внутри setTimeout?
    • Два, потому что батчинг применяется только к событиям React
    • Всего один, благодаря автоматическому батчингу
    • Ноль, обновления вне событий игнорируются
  3. Какая фаза работы React прерываема в конкурентном режиме?
    • Только фаза коммита
    • Только фаза рендеринга
    • Обе фазы
    • Ни одна, React остаётся полностью синхронным