open atlas
↑ К треку
React с нуля до senior RCT · 01 · 01

Render и commit: почему React вызывает компонент чаще, чем рисует

Рендер — это чистый вызов ваших компонентов, дающий дерево элементов; DOM меняется только в фазе commit. Конкурентный React может повторить или выбросить рендер, поэтому побочный эффект в рендере срабатывает непредсказуемое число раз — рендер обязан быть чистым.

RCT Middle ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Команда воронки завела тикет: показы на странице поиска выросли на 40% за неделю при том же трафике, а конверсия «обвалилась» соответственно. Маркетинг уже переписывал квартальный отчёт. Фронтенд-изменение той недели выглядело невинно — страницу поиска обернули в startTransition, чтобы ввод оставался отзывчивым, пока рендерятся результаты. Виновником оказалась одна строка, два года жившая в кодовой базе: analytics.track("impression") прямо в теле компонента. В React 17 это было неаккуратно, но невидимо — каждый рендер коммитился ровно один раз, так что число рендеров совпадало с числом отрисовок. Внутри transition React начинает рендерить, выбрасывает недоделанную работу, когда приходит следующее нажатие клавиши, и начинает заново — и каждая брошенная попытка дёргала трекер. На экране всё было правильно. DOM был идеален. Команда просто никогда не различала момент, когда React вызывает их функцию, и момент, когда React обновляет страницу, потому что два года эти моменты совпадали. StrictMode в dev-режиме вызывает рендер дважды ровно затем, чтобы разорвать это совпадение заранее — но трекер в dev был заглушкой, и никто ничего не увидел.

Рендер — это вызов функции, а не обновление экрана

Каждое обновление UI в React проходит один и тот же конвейер: триггер → render → commit. Триггер — это либо первый монтаж, либо обновление состояния (setState планирует ре-рендер, а не выполняет его). Фаза render — это React, вызывающий функции ваших компонентов. Возвращаемое значение — дерево элементов: простых, неизменяемых JavaScript-объектов, которые порождает JSX (под капотом — react.createElement) и которые описывают, как должен выглядеть UI: тип, пропсы, дети. Создание элементов дёшево — DOM не трогается, ничего не рисуется и не измеряется. React рендерит компонент, чьё состояние изменилось, затем рекурсивно его детей, собирая полное описание следующего UI.

Именно здесь спотыкается большинство: «рендер» порождает описание, а описание можно сравнить, закешировать или выбросить в корзину без какой-либо цены для пользователя. Фаза commit — это когда React берёт разницу между новым описанием и тем, что сейчас на экране, и применяет минимальный набор реальных мутаций DOM — appendChild, обновления атрибутов, удаления — одним синхронным, непрерываемым проходом. После commit браузер рисует кадр, затем выполняются пассивные эффекты (useEffect). Layout-эффекты (useLayoutEffect) выполняются внутри commit, после мутаций, но до отрисовки — поэтому они могут измерять DOM без видимого мерцания, и поэтому медленный код в них напрямую блокирует отрисовку.

Fiber: единица работы, делающая рендер прерываемым

Внутри каждый экземпляр компонента опирается на fiber (волокно — внутренний узел планировщика React) — изменяемый узел, хранящий тип компонента, его текущие пропсы, состояние хуков (связный список — вот почему порядок хуков должен быть стабильным) и флаги изменений. Когда ты смотришь на компонент, видишь функцию, возвращающую JSX, — fiber — это то, что React держит за кадром между рендерами. Фаза render — это work loop (цикл обработки работы) по fiber-узлам: обработать один fiber, отрендерить его компонент, согласовать детей, перейти к следующему. Один fiber — одна единица работы, и между единицами React может остановиться. Срочные обновления он по-прежнему прогоняет синхронно до конца, но внутри transition и других конкурентных механизмов уступает браузеру управление примерно каждые 5 мс — и долгий рендер дерева на 2 000 строк больше не замораживает ввод на 300 мс: обработчик нажатия выполняется между единицами работы.

Второй структурный трюк — двойная буферизация: React держит текущее fiber-дерево (то, что на экране) и строит отдельное work-in-progress-дерево (дерево «в работе») во время рендера. Commit, по сути, — это перестановка указателя с текущего дерева на work-in-progress. Это разделение и делает выбрасывание работы безопасным: если посреди рендера приходит более приоритетное обновление, React бросает work-in-progress-дерево — текущее никто не трогал, экран консистентен — и начинает свежий рендер от нового состояния.

Почему это работает

Почему commit синхронен и непрерываем, если render так старается быть прерываемым? Потому что наполовину применённый DOM — это видимая пользователю порча: список, в котором переехали три строки из семи, форма, у которой обновился label, но не input. Render работает с описаниями, и его прерывание не стоит ничего видимого; commit работает с живым DOM и обязан быть атомарным. Поэтому React выносит всю долгую, паузабельную работу в render, а commit держит коротким залпом мутаций. Отсюда же асимметрия правила: render обязан быть чистым, а эффекты — которые выполняются после commit — это ровно то место, где побочным эффектам и положено жить.

Почему render обязан быть чистым — и как StrictMode держит вас честными

Чистота — не вопрос стиля, а контракт, который делает планировщик легальным. При одних и тех же пропсах и состоянии компонент обязан вернуть те же элементы и не изменить ничего больше — никаких мутаций существовавших до вызова объектов, сетевых запросов, записи в ref, никакого analytics.track. Причина арифметическая: render может выполниться ноль, один или много раз на один закоммиченный кадр. Рендер внутри transition может быть выброшен и перезапущен; будущий offscreen-рендер может выполняться для UI, который никогда не станет видимым. Побочный эффект в рендере поэтому срабатывает непредсказуемое число раз — 40% фантомных показов из пролога были ровно этим: эффектами рендеров, которые так и не закоммитились.

function SearchResults({ items, query }) {
  // Баг: побочный эффект в фазе рендера. Срабатывает на каждую *попытку* рендера —
  // включая рендеры, которые React отбрасывает, — а не один раз на отрисовку.
  analytics.track("impression", { query });

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
}

function SearchResultsFixed({ items, query }) {
  // Эффекты запускаются после коммита — один раз на *закоммиченное* изменение query.
  useEffect(() => {
    analytics.track("impression", { query });
  }, [query]);

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.title}</li>
      ))}
    </ul>
  );
}

StrictMode — инструмент принуждения: в dev-режиме он намеренно вызывает render каждого компонента дважды (и прогоняет лишний цикл setup/cleanup эффектов при монтаже), чтобы любая нечистота дала видимое удвоение — два лога, два запроса, массив, растущий вдвое. Чистый компонент двойной вызов не замечает — в этом и есть тест. Компромисс: чистота запрещает делать работу в самом «очевидном» месте и выталкивает её в обработчики событий и эффекты, что в первый день кажется бюрократией — но именно это бесплатно покупает прерываемый рендеринг, кеширование рендеров и серверный рендеринг. Сценарий отказа: команды, выключающие StrictMode «потому что логи дублируются», отключают единственную сигнализацию, которая срабатывает до продакшен-инцидента.

Викторина

В dev-режиме под StrictMode тело компонента выполняется дважды на каждый рендер. Зачем React делает это намеренно?

Викторина

Во время transition React отрендерил примерно половину большого work-in-progress fiber-дерева, и тут пользователь нажал ещё одну клавишу. Что происходит с недоделанной работой?

Вспомните перед уходом
  1. 01
    Проследите, что происходит между вызовом setState и изменением пикселей на экране, назвав обе фазы и что каждой разрешено делать.
  2. 02
    Почему именно побочный эффект в фазе render даёт продакшен-баги при конкурентном рендеринге и как StrictMode вскрывает это в разработке?
Итог

React обновляет UI в двух строго разделённых фазах. Фаза render — это вызов функций ваших компонентов: триггер (монтаж или setState) планирует работу, React вызывает компонент и его детей, и результатом становится дерево объектов-элементов — чистое описание, DOM не тронут. За каждым экземпляром компонента стоит fiber, хранящий его пропсы, состояние хуков и флаги изменений; фаза render — это work loop по fiber-узлам, по одной единице работы за раз, и именно это позволяет конкурентному React уступать браузеру управление примерно каждые 5 мс внутри transition и сохранять отзывчивость ввода при тяжёлом рендере. Следующий UI React строит как отдельное work-in-progress-дерево (двойная буферизация), поэтому когда срочное обновление вытесняет рендер transition, полупостроенное дерево просто выбрасывается и строится заново — текущее дерево на экране никто не трогал. Фаза commit — противоположный контракт: один синхронный атомарный проход, применяющий минимальные мутации DOM и меняющий деревья местами, затем layout-эффекты до отрисовки и пассивные эффекты после. Поскольку render легально может выполниться ноль, один или много раз на закоммиченный кадр, он обязан быть чистым — побочный эффект в теле компонента стреляет по разу на каждую попытку рендера, включая выброшенные, и именно так трекер в рендере раздул показы на 40% в неделю выкатки transition. StrictMode дублирует рендер в dev-режиме, чтобы сделать ровно эту нечистоту видимой, пока её исправление бесплатно; побочным эффектам место в обработчиках событий и эффектах, выполняющихся предсказуемое число раз после commit. Теперь, когда встретишь аналитику или сетевой вызов прямо в теле компонента, ты точно знаешь, почему это стреляет лишний раз при конкурентном рендеринге — и куда это перенести.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.