Profiler и измерения: найдите коммит на 600 мс прежде, чем трогать React.memo
Профилируйте до оптимизации: флеймграф React DevTools показывает цену каждого коммита, Profiler API превращает её в числа для мониторинга, а бюджет пользователя — это INP, а не миф о 16 мс на кадр. Команда два спринта мемоизировала 4 мс и не заметила 620.
Команда дашборда потратила два спринта на кампанию мемоизации: сорок компонентов обёрнуты в React.memo, каждый колбэк — в useCallback, для пропсов графика написана своя функция сравнения. В описании PR красовалось «ре-рендеров меньше на 60%» с гифкой в доказательство. А потом всё равно загорелся канал дежурных — саппорт пересылал одну и ту же жалобу: «фильтры замирают». Полевой мониторинг показывал INP 750 мс на выпадающем списке фильтров — ровно столько же, сколько до кампании. Час с профайлером React DevTools рассказал настоящую историю. Лишние ре-рендеры, которые команда убрала, стоили около 4 мс на коммит. А зависание было одним коммитом на 620 мс: смена диапазона дат заставляла SVG-график на 9 000 точек размонтироваться и смонтироваться заново, потому что родитель передавал ему свежий key при каждой смене фильтра. Два спринта оптимизировали шум; сигналом была одна полоса флеймграфа, видная через всю комнату. Никто не профилировал перед тем, как оптимизировать.
Чтение флеймграфа: коммиты, а не компоненты
Профайлер React DevTools записывает сессию взаимодействий и нарезает её на коммиты — каждый раз, когда React реально донёс изменения до DOM, в таймлайне появляется один столбик. Выбрав коммит, вы видите флеймграф: каждая строка дерева — компонент, ширина его полосы — сколько он (вместе с детьми) рендерился в этом коммите, а серые полосы — компоненты, которые не рендерились вовсе. Эта рамка принципиальна: вопрос никогда не звучит «какой компонент медленный» вообще, он звучит «какой коммит медленный и что внутри него сожгло время». Один коммит на 600 мс с одной широкой полосой — совсем другая болезнь, чем шестьдесят коммитов по 10 мс от болтливой подписки, и лечатся они противоположно.
Две настройки превращают инструмент из картинки в диагноз. «Record why each component rendered» подписывает каждую полосу причиной: изменились пропсы (и какие именно), изменился стейт, сработал хук или просто отрендерился родитель. Одна эта подпись убивает большую часть гаданий — ремаунт графика из истории выше отобразился как «key changed», чего никакое разглядывание кода компонента не выявило бы. Вторая — галочка «Highlight updates», которая в реальном времени подсвечивает всё, что ре-рендерится: самый быстрый способ заметить провайдер контекста, перерисовывающий полстраницы на каждое нажатие клавиши. Профилируйте сначала в development ради богатых данных, но помните: dev-сборка React заметно медленнее — любое число, на котором строится решение, перепроверяйте на продакшен-сборке с профилированием (react-dom/profiling), её накладные расходы — единицы процентов.
Profiler API: числа, которые можно отправить в мониторинг
DevTools интерактивен; компонент <Profiler> программируем. Оберните поддерево — и React после каждого коммита внутри него вызовет onRender с таймингами, которые можно логировать, агрегировать или слать в систему мониторинга:
import { Profiler } from "react";
function onRender(id, phase, actualDuration, baseDuration, startTime, commitTime) {
// actualDuration: сколько рендерился этот коммит (с учётом мемоизации)
// baseDuration: оценка худшего случая — все потомки рендерятся, memo нет
metrics.send("react-commit", {
id, // "FilterPanel"
phase, // "mount" | "update" | "nested-update"
actual: actualDuration, // напр. 4.2 (мс)
base: baseDuration, // напр. 38.7 (мс)
});
}
<Profiler id="FilterPanel" onRender={onRender}>
<FilterPanel />
</Profiler>Пара, которую стоит усвоить, — actualDuration против baseDuration. actualDuration — реальная цена этого коммита; baseDuration — сумма последних времён рендера каждого потомка, то есть оценка худшего случая, где ничто не мемоизировано. Когда actualDuration сильно ниже baseDuration, мемоизация отрабатывает своё; когда числа идут вровень — обёртки React.memo стали декорацией. Именно этих доказательств команда из истории так и не собрала: их memo-кампания сдвинула actualDuration с 42 до 38 мс на коммите, который имел значение, — в пределах шума, потому что ремаунт на 620 мс обходил мемоизацию целиком. Компромисс: <Profiler> дёшев, но не бесплатен (React ведёт дополнительный учёт на каждый коммит внутри профилируемых деревьев), поэтому в продакшене оборачивайте две-три реально отслеживаемые интеракции, а не корень.
В данных onRender для коммита actualDuration равен 39 мс, а baseDuration — 41 мс, хотя поддерево плотно обёрнуто в React.memo. О чём это говорит?
INP и миф о 16 мс
Число, которое цитируют все, — 16,7 мс, один кадр при 60 Гц — это бюджет непрерывного движения: анимации, скролла, перетаскивания. Промахнётесь во время драга — пользователь увидит рывок. Но клик — не анимация. Для дискретных взаимодействий пользовательская метрика — INP (Interaction to Next Paint): от нажатия до отрисовки следующего кадра, включая input delay (главный поток был занят, когда пришло событие), processing (ваши обработчики, рендер и коммит React) и presentation delay (стили, лейаут, отрисовка). Пороги: 200 мс — «хорошо», свыше 500 мс — «плохо», то есть примерно от 12 до 30 кадровых бюджетов, причём INP страницы — это примерно её худшее взаимодействие за визит, а не среднее.
Поэтому рамка «16 мс» сбивает оптимизацию в обе стороны. Она выставляет коммит в 30 мс по клику на кнопку нарушением, достойным рефакторинга, хотя пользователь не отличит 30 мс от мгновенного. И она молчит о настоящем убийце: любая задача в главном потоке дольше 50 мс — это long task, добавляющий input delay всему, что пользователь сделает следом; коммит на 620 мс не просто тормозит своё взаимодействие — он замораживает каждый клик, прилетевший во время него. Рабочий цикл, по порядку: прочитать INP из полевых данных (метрика привязана к конкретным взаимодействиям, поэтому лабораторные замеры на быстром ноутбуке её регулярно не видят), найти худшее взаимодействие, спрофилировать его, починить самую широкую полосу, измерить снова. Команда из истории прошла этот цикл задом наперёд — чинить сразу, не измерять никогда.
Обработчик клика вызывает коммит React длительностью 35 мс. Коллега бьёт тревогу: «это больше двух кадров, надо мемоизировать». Какое решение верное?
- 01Мониторинг показывает INP 700 мс на выпадающем списке. Опишите полный диагностический цикл до любых правок кода — с инструментами и числами, которые вы будете читать.
- 02Объясните, что означают actualDuration и baseDuration в колбэке onRender и как их сравнение показывает, окупилась ли кампания мемоизации.
У работы над производительностью React есть необсуждаемый первый шаг: измерить, потому что интуиция стабильно указывает не на то поддерево. Профайлер React DevTools записывает взаимодействия как серию коммитов; флеймграф каждого коммита показывает цену рендера шириной полосы, серым — компоненты с bail-out, а при включённом «record why each component rendered» — причину каждой полосы: изменившиеся пропсы, стейт, рендер родителя или смена key (тихий триггер ремаунта, который обесценил двухспринтовую memo-кампанию команды из истории, пока ремаунт графика на 620 мс прятался у всех на виду). Программируемый напарник — компонент Profiler: его onRender сообщает actualDuration (цена коммита с мемоизацией) против baseDuration (оценка худшего случая без неё), и зазор между ними — единственная честная мера того, зарабатывает ли React.memo хоть что-нибудь; оборачивайте несколько отслеживаемых интеракций, а не корень — профилирование имеет небольшую цену на каждый коммит. Влияние меряйте в единицах пользователя, а не React: дискретными взаимодействиями правит INP — input delay, processing, presentation delay; хорошо — до 200 мс, плохо — после 500, и метрику определяет худшее взаимодействие визита, — а знаменитые 16,7 мс относятся к непрерывному движению вроде скролла и анимации. Коммиту клика в 35 мс не нужно ничего; коммит в 620 мс — это long task, замораживающий заодно каждый клик, прилетевший во время него. Цикл такой: полевой INP называет взаимодействие, флеймграф — коммит, чините самую широкую полосу, измеряете снова. Теперь, когда коллега потянется к React.memo ещё до открытия профайлера, вы знаете, что спросить: какой коммит, какая полоса и что полевой INP говорит об этом взаимодействии?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.