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

Компоновщик и бюджет кадра: какие свойства бесплатны

transform и opacity анимируются на потоке компоновщика и переживают занятый главный поток; width и top гоняют layout и paint каждый кадр. Бюджет: ~10 мс кода при 60 Гц. setState на кадр сжигает его на render и reconcile — rAF + ref или CSS. will-change стоит памяти.

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

Демо релиза шло на MacBook Pro и выглядело идеально. На средних ноутбуках команды поддержки новая выезжающая панель ордеров еле ползла: анимация открытия заикалась на 9 fps всякий раз, когда перерисовывался график цены — то есть всегда. Панель была анимирована «по-реактовски»: цикл requestAnimationFrame вызывал setState со следующим смещением left, шестьдесят рендеров в секунду, каждый платил за render, reconcile, commit и запись layout-свойства. Собственный reconcile графика занимал 40–70 мс несколько раз в секунду, и каждый такой проглатывал четыре кадра анимации целиком. Первая попытка лечения была фольклором: will-change: transform, рассыпанный по панели, графику и каждой строке. Память вкладки выросла на 180 МБ, а скролл на самых дешёвых машинах стал хуже: каждый продвинутый элемент превратился в собственный композитный битмап, который нужно растеризовать, загрузить и держать. Настоящее лечение заняло двадцать строк: панель поехала через CSS-переход на transform, React переключал один класс, и анимация выполнялась на потоке компоновщика — идеально гладко, пока главный поток проводил 70 мс в reconcile, потому что анимации главный поток больше не был нужен. У конвейера рендеринга есть быстрый выход. Этот урок — о том, как в него целиться.

Пять стадий, три выхода

Каждое визуальное изменение проходит некоторый префикс одного конвейера, раз на выходной кадр: JS (обработчики событий, колбэки rAF, render и commit React) → style (пересчитать, какие правила применяются и каковы вычисленные значения) → layout (геометрия: где стоит каждый бокс и какого он размера — одно изменение width каскадирует по соседям, предкам и строкам текста) → paint (растеризовать боксы в битмапы слоёв: текст, цвета, тени, рамки) → composite (расставить уже растеризованные слои друг относительно друга и показать кадр).

Дорогая правда: свойства различаются не «тяжестью», а тем, где они входят в конвейер. Измените width, top, margin, font-size — вы пачкаете layout, и всё ниже перезапускается: layout, paint, composite, на каждом кадре вашей анимации. Измените color, background, box-shadow — layout выживает, но элемент перерисовывается. Измените transform или opacity у элемента с собственным композитным слоем — не пересчитывается ничего, кроме итогового размещения готового битмапа: ни layout, ни paint. Компоновщик сдвигает текстуру. Вот вся причина, по которой «анимируй transform и opacity» — правило, а не вкусовщина: это два повсеместно анимируемых свойства, чьи обновления могут целиком миновать главный поток (плюс filter в современных движках, с оговорками).

Поток компоновщика — ваш единственный союзник вне главного потока

Composite — не просто последняя стадия: она живёт на другом потоке. Поток компоновщика держит растеризованные слои и может заново расставлять их на экране, не обращаясь к главному потоку. Декларативные анимации — CSS-переходы, CSS-анимации и вызовы Web Animations API — на transform и opacity передаются ему: после передачи главный поток может блокироваться на сотни миллисекунд в reconcile или GC, а анимация не уронит ни кадра, потому что производство её кадров целиком происходит вне главного потока. Это и есть финал Hook: панель на CSS-переходе проплыла сквозь 70-миллисекундный reconcile, который раньше съедал четыре кадра целиком.

Обратная сторона: любая анимация, которую JavaScript ведёт по кадрам — цикл rAF, spring-библиотека, мутирующая стили, состояние React, — по построению живёт в главном потоке. Она всё ещё может выдавать 60 fps, но за каждый кадр конкурирует со всем остальным на этом потоке и по умолчанию проигрывает всякий раз, когда React коммитит что-то большое. И CSS-анимация layout-свойства — тоже работа главного потока: декларативная форма не спасает; спасает класс свойства.

Викторина

CSS-переход на transform продолжает плавно ехать, пока главный поток проводит 400 мс в React reconcile, но тот же переход, переписанный на left, замирает на эти 400 мс. Почему?

Арифметика бюджета кадра

Числа здесь важны: пропущенный дедлайн — не медленный кадр, а выброшенный кадр. При 60 Гц кадр — 16,7 мс. При 120 Гц — всё чаще это умолчание на телефонах и ноутбуках, на которых вы не тестируете, — 8,3 мс. Бюджет не весь ваш: часть каждого кадра браузер тратит на обработку ввода, пересчёт стилей, layout, paint и передачу компоновщику. Остаток для вашего JS — примерно 10 мс при 60 Гц и 3–5 мс при 120 Гц. Числа, которые стоит носить с собой: пересчёт стилей на нескольких тысячах узлов — 1–4 мс; layout сложной страницы — 4–12 мс; средний React-коммит — 2–8 мс. Каждое — допустимо раз на взаимодействие. Ни одно — недопустимо на каждом кадре при 120 Гц. Промахнитесь мимо дедлайна — и кадр не опоздает, он будет выброшен: предыдущий кадр останется на экране лишние 16,7 (или 8,3) мс, а в непрерывном движении глаз надёжно читает один выброшенный кадр как рывок.

Специфический грех React: рендер на каждый кадр

// ❌ рендер на каждый кадр: rAF → setState → render → reconcile → commit, 60×/с
useEffect(() => {
  let raf;
  const tick = () => {
    setX((x) => x + dx);              // планирует полный React-рендер каждый кадр
    raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);
}, []);

// ✅ React рендерит один раз; кадры мутируют ref — никакого reconcile в горячем цикле
useEffect(() => {
  let raf, x = 0;
  const tick = () => {
    x += dx;
    el.current.style.transform = `translateX(${x}px)`;
    raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);
}, []);

Первый цикл платит за полную машинерию React на каждом кадре: отрендерить компонент, реконсилировать поддерево, закоммитить — а затем style, layout и paint, если записанное свойство их требует. Коммит в 3 мс — это треть бюджета в 8,3 мс до того, как браузер сделал хоть какую-то работу по отрисовке. Второй цикл стоит микросекунды JS и пачкает только то, что пачкает мутируемое свойство — с transform на продвинутом слое это ничего из того, что волнует главный поток.

Лестница, по убыванию предпочтения: сначала CSS-переходы и анимации (декларативны, годятся компоновщику, прерывание берёт на себя браузер); WAAPI el.animate(), когда нужны динамические значения или управляемый из JS тайминг (всё ещё годится компоновщику); rAF + мутация ref, когда значение действительно вычисляется по кадрам в JS — жесты, физика — главный поток, но без reconcile; setState на каждый кадр — никогда. Работа React — состояние на границах: запустить анимацию и один раз закоммитить финальное значение, когда она закончилась.

will-change, честно

will-change: transform просит браузер продвинуть элемент в собственный композитный слой заранее, чтобы первый кадр анимации не платил за продвижение (которое само может вызвать paint). Прежде чем добавить его, спроси себя: действительно ли нужна экономия на первом кадре, или ты добавляешь его по всему дереву на всякий случай? Цена — то, что обнаружило фольклорное лечение из Hook: слой — это битмап, примерно width × height × 4 байта × devicePixelRatio² — один полноэкранный слой при 2× DPR весит порядка 30 МБ, а четыреста продвинутых строк таблицы — четыреста растеризованных текстур, которые GPU обязан держать и компоновать каждый кадр. Больше слоёв — медленнее растеризация и длиннее кадры компоновщика, так что ковровое продвижение делает медленные машины ещё медленнее. Дисциплина: продвигайте точно вовремя — ставьте will-change по намерению нажатия или прямо перед анимацией и убирайте по её окончании, — и никогда в wildcard-правиле стилей. И помните: браузеры и так автоматически продвигают элементы с идущими анимациями transform или opacity — will-change покупает вам первый кадр, а не анимацию.

Викторина

Чтобы починить дёргающийся скролл, коллега раскатывает will-change: transform на все 400 строк таблицы. Память прыгает на сотни МБ, и на слабых устройствах скролл становится хуже. Что произошло на самом деле?

Чтение флеймов — и связь с INP

В панели Performance в DevTools запишите дёргающееся взаимодействие и читайте три вещи. Трек Frames: выброшенные и частично показанные кадры показывают, когда именно сломался бюджет. Флейм-чарт Main: фиолетовый блок Layout, повторяющийся каждые 16 мс во время вашей анимации, — подпись анимируемого layout-свойства; широкие зелёные блоки Paint — шторм перерисовок (непродвинутая анимация или тяжёлое для paint свойство вроде box-shadow); длинные жёлтые JS-задачи с красными треугольниками — ваша покадровая работа React. Вкладка Rendering с paint flashing и layer borders вживую отвечает «что перерисовывается» и «что продвинулось», пока вы воспроизводите проблему.

Связь с INP делает это больше, чем косметикой: работа анимации и обработка ввода делят главный поток. Событие указателя, пришедшее, пока выполняется ваш 24-миллисекундный кадр анимации, ждёт его; задержка взаимодействия за 200 мс — и INP оценивается как «плохо»: дёргающаяся анимация теперь регрессия Core Web Vitals. Анимация, ведомая компоновщиком, освобождена по построению: она не может блокировать ввод на потоке, который никогда не делит.

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

Почему layout нельзя унести с главного потока так же, как унесли компоновку? Потому что layout спутан со скриптом по спецификации: любой JS может синхронно прочитать offsetWidth или getBoundingClientRect и обязан увидеть геометрию, согласованную с каждой только что сделанной мутацией DOM, — значит, вычисление геометрии должно жить там же, где DOM: на единственном потоке, который им владеет. Компоновка сбежала, потому что её входы неизменяемы: растеризованные битмапы плюс список трансформаций, без доступа к DOM, — поэтому размещение может работать на собственном потоке (там же живёт скролл — потому и существуют passive-слушатели: обещание браузеру, что ваш touch-обработчик не вызовет preventDefault и не вернёт скролл на главный поток). transform и opacity быстры по замыслу, а не случайно — их намеренно определили не влияющими на layout, поэтому это единственные входы анимации, которые может потреблять поток без DOM.

Вспомните перед уходом
  1. 01
    Проведите один кадр через конвейер рендеринга и классифицируйте свойства по точке входа. Почему анимация transform переживает 400-мс блокировку главного потока, а анимация left замирает?
  2. 02
    Сделайте арифметику бюджета кадра при 60 и 120 Гц и объясните, почему анимация через setState-на-кадр его ломает — и какова лестница альтернатив.
Итог

Конвейер рендеринга — JS, style, layout, paint, composite — выполняется раз на выходной кадр, и свойство платит за все стадии ниже своей точки входа. Layout-свойства вроде width, top и margin перезапускают весь столбец в главном потоке на каждом кадре; paint-свойства минуют layout, но растеризуют; transform и opacity на композитном слое минуют всё, кроме размещения, а размещение принадлежит потоку компоновщика, который держит растеризованные битмапы и не нуждается в DOM. Этот поток — единственный союзник вне главного: CSS-переходы, CSS-анимации и WAAPI на transform и opacity передаются ему и продолжают выдавать кадры, пока главный поток проводит 70 мс в React reconcile, — панель из Hook проплыла ровно сквозь ту блокировку, которая съедала её предшественницу на setState на 9 fps. Арифметика бюджета неумолима: 16,7 мс при 60 Гц и 8,3 мс при 120 Гц, из которых вашему коду достаётся примерно 10 и 4 мс после накладных расходов браузера; React-коммит в 2–8 мс на каждом кадре — канонический анимационный грех React: render, reconcile и commit, покупаемые шестьдесят раз в секунду ради движения одного числа. Лестница альтернатив: сначала CSS, затем WAAPI, rAF с мутацией ref для действительно вычисляемых в JS значений, setState — никогда. will-change — подсказка о продвижении с ценником — width × height × 4 байта × DPR² на слой, плюс время растеризации и компоновки, растущее с числом слоёв, — поэтому её ставят точно вовремя и убирают, а не рассыпают. Проверка эмпирична: трек Frames в панели Performance показывает выброшенные кадры, повторяющиеся фиолетовые блоки Layout выдают анимацию layout-свойства, paint flashing и layer borders показывают, что перерисовывается и что продвинулось. И ставки выше гладкости: обработчики ввода делят главный поток с кадрами вашей анимации, так что джанк — это задержка взаимодействия, INP за 200 мс оценивается как «плохо», — тогда как движение, ведомое компоновщиком, по построению не может блокировать поток, которого никогда не касается. Теперь, когда встретишь анимацию, которая заикается только во время тяжёлого reconcile, ты будешь знать: это не телефон виноват — это CSS-свойство входит не в ту стадию конвейера.

Практика

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

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

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

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

Примени это

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

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

Trademarks belong to their respective owners. Editorial reference only.