awesome-everything EN
↑ Обратно к восхождению

Браузер и фронтенд-рантайм

Bailout, мемоизация и tearing

Суть React делает bailout для компонента, если props и state не изменились по ссылке. Ссылочная стабильность (useMemo, useCallback, React.memo) — рычаг bailout. useSyncExternalStore предотвращает tearing при конкурентном рендеринге.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 16 min

Компонент, обёрнутый в React.memo, всё равно рендерится при каждом рендере родителя. Ничего не изменилось. Memo на месте. Открываете DevTools Profiler — причина говорит «props changed», но prop — это объект с идентичным содержимым. Проблема не в memo. Проблема — инлайновый объектный литерал как prop: каждый рендер новая ссылка, и Object.is всегда сообщает об изменении.

Bailout: как React пропускает работу. Не каждый компонент в перерендеривающемся поддереве действительно перезапускается. Когда React достигает fiber во время render, он проверяет: изменились ли props (через Object.is на каждом prop)? Изменилось ли state? Есть ли pending-обновление контекста, которое потребляет этот fiber? Если ничего из этого не верно, React делает bailout — клонирует fiber из alternate, переиспользует всё существующее дочернее поддерево по ссылке и вообще не вызывает функцию компонента.

Именно поэтому ссылочная стабильность так важна: дочерний компонент, обёрнутый в React.memo, делает bailout только если его props ссылочно равны. Передайте инлайновый {} или стрелочную функцию как prop — и bailout не будет работать при каждом рендере, потому что свежий литерал никогда не равен предыдущему через Object.is. useMemo и useCallback существуют именно для сохранения этих ссылок, чтобы bailout мог сработать.

Ментальная модель: рендеринг родителя не автоматически перерендеривает дочерние компоненты — он предлагает им шанс сделать bailout, а стабильные ссылки — это то, что позволяет им этим шансом воспользоваться.

Решение bailout на fiber
Props равны (Object.is) И state не изменилось И нет обновления контекста → bailout: клонировать fiber, переиспользовать дочернее поддерево, пропустить вызов компонента
Хоть один prop отличается по ссылке ИЛИ state изменилось ИЛИ контекст обновился → перезапустить функцию компонента, диффить новое дерево элементов
Типичная ловушка: инлайновый {} или () => {} как prop → новая ссылка каждый рендер → bailout никогда не срабатывает, даже с React.memo

State хранится на fiber, а не в компоненте. Функциональный компонент не имеет экземпляра — это просто функция, которую React вызывает. Так где же useState хранит значение между рендерами? На fiber. Каждый fiber держит связный список записей хуков — по одной на каждый вызов useState/useReducer/useEffect, в порядке вызова. React продвигает cursor через этот список, пока ваш компонент вызывает хуки.

Вот глубинная причина Правил Хуков: хуки должны вызываться безусловно и в одном и том же порядке при каждом рендере, потому что React идентифицирует каждый хук исключительно по его позиции в последовательности вызовов, а не по имени. Поместите useState под if — и при рендере, где условие переключилось, каждый последующий хук читает неправильную запись. Это также объясняет сохранение состояния: state переживает рендер, потому что fiber переживает; state уничтожается при уничтожении fiber — при размонтировании, при смене типа или при смене ключа.

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

Почему хуки не именованные? Записи хуков могли бы храниться по имени вместо позиции, устраняя требование порядка. Но имена должны быть уникальными в компоненте, превращая каждый вызов хука в lookup по строковому ключу вместо продвижения cursor. Lookup по позиции — O(1) на хук и не требует дополнительного рантайма. Трейдофф: обязательный порядок вызовов в обмен на минимальные накладные расходы на рендер.

Tearing и useSyncExternalStore. Прерываемый рендеринг вводит опасность под названием tearing: если внешний store меняется между двумя слайсами, компоненты первого слайса видели старое значение, а компоненты позднего слайса — новое, поэтому закоммиченный UI внутренне несогласован — «разорван». Собственное состояние React не может рваться, потому что React контролирует, когда оно меняется; внешние stores могут, потому что React этим не управляет.

useSyncExternalStore исправляет это: он даёт React функцию subscribe и функцию getSnapshot. React читает снимок, и если обнаруживает, что снимок изменился во время конкурентного рендера, перезапускает рендер, гарантируя, что каждый компонент в одном commit наблюдал одно и то же значение store. Именно поэтому любая библиотека состояния, интегрирующаяся с React 18 (Redux, Zustand, Jotai), маршрутизирует через useSyncExternalStore.

Контекст и каскад перерендеривания. Provider контекста рядом с корнем дерева, чьё значение — свежий объектный литерал при каждом рендере, вызывает перерендеривание всего поддерева потребителей. Потребители контекста перерендериваются при каждом изменении значения провайдера по ссылке — а свежий литерал всегда отличается. Исправление: мемоизировать значение контекста через useMemo, чтобы его ссылка была стабильной, когда содержимое не меняется. Это наиболее частая причина перформанс-проблем в реальных React-приложениях, обнаруживаемая Profiler-ом как массовый перерендеринг несвязанных компонентов.

Проследи
1/4

Дашборд медленно перерендеривается при каждом нажатии клавиши в несвязанном поле поиска. React Profiler показывает почти каждый компонент на странице перерендеривающимся — даже те, чьи props не изменились. Где проблема?

1
Step 1 of 4
Провайдер контекста рядом с корнем дерева имеет значение — свежий объектный литерал при каждом рендере — каждый потребитель перерендеривается, потому что значение контекста — новая ссылка
2
Locked
У поля поиска нет debounce
3
Locked
Алгоритм диффа React имеет сложность O(n³)
4
Locked
Список нуждается в виртуализации
Викторина

Компонент, обёрнутый в `React.memo`, всё равно рендерится каждый раз, когда рендерится родитель. Props выглядят неизменёнными. Какова наиболее вероятная причина?

Викторина

Почему нельзя прочитать обновлённое значение state синхронно сразу после вызова `setState`?

Какой RFC?

Какой API React существует специально для предотвращения 'tearing' — когда компоненты в одном commit наблюдают разные значения внешнего store во время конкурентного рендера?

Вспомните перед уходом
  1. 01
    Компонент обёрнут в React.memo, но рендерится при каждом рендере родителя. Опишите диагностику и исправление.
  2. 02
    Почему хуки должны вызываться в одном и том же порядке при каждом рендере?
  3. 03
    Что такое tearing и как useSyncExternalStore предотвращает его?
Итог

React делает bailout для компонента, когда Object.is находит все его props и state неизменёнными — клонирует fiber и полностью пропускает вызов функции. React.memo делает bailout условным на props; useMemo и useCallback сохраняют стабильные ссылки, позволяющие bailout срабатывать. Хуки хранятся как позиционный связный список на fiber, именно поэтому порядок вызовов должен быть безусловным — пропуск хука разрушает cursor для каждого последующего. State живёт на fiber, а не в функции компонента, поэтому state сохраняется между рендерами и исчезает при размонтировании, смене типа или смене ключа. В конкурентном режиме внешние stores могут вызывать tearing — компоненты в одном commit видят разные значения — потому что store может измениться между слайсами рендера. useSyncExternalStore обнаруживает это и перезапускает рендер, поэтому каждая серьёзная библиотека состояния React маршрутизирует через него.

Связанные уроки
встречается в143
Продолжить восхождение ↑React Profiler, компилятор и продакшн-наблюдаемость
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.