Profiler, React Compiler 1.0, мемоизация, виртуализация, разделение кода, Web Vitals и доказуемая оптимизация.
Оптимизация начинается с пользовательской метрики и воспроизводимой трассировки, а заканчивается повторным измерением.
Вы найдёте узкое место, отличите вычисление React от фиксации DOM, сети и разметки, включите React Compiler и докажете улучшение без регрессии.
Понадобятся модель отрисовки, настройка React Compiler, Suspense и загрузка в браузере. Итог урока — отчёт до и после для тяжёлой панели с бюджетом производительности и регрессионным тестом.
Зафиксируйте устройство, сборку, набор данных и действие. Метрика должна отражать пользовательский опыт: LCP, INP, CLS, смену маршрута или задержку ввода. Среднее скрывает худшие случаи; смотрите распределение и p75 вместе с лабораторной трассировкой и данными реальных пользователей (RUM).
Исходное измерение — это запись, к которой можно вернуться через месяц и повторить её один в один:
Сценарий: /dashboard, ввод в поиск, 5 000 строк в наборе
Сборка: production, React 19.2, Compiler выключен
Условия: CPU throttling 4x, Fast 3G, окно 1280×800
Метрика: INP p75 = 420 мс (цель ≤ 200 мс), LCP p75 = 2.9 с
Данные: 20 повторов, отброшены первые 2 прогонаБез записанных условий сравнение «до и после» ничего не доказывает: разница может объясняться другой машиной, другим набором данных или прогретым кешем. Один console.time даёт единичное наблюдение из режима разработки — то есть измеряет сборку, которую пользователь никогда не увидит.
Проверьте себя. Запишите исходное значение p75 и точный сценарий.
Частая ошибка. Оптимизировать по одному случайному console.time.
React DevTools Profiler показывает фиксации, компоненты и причины обновлений. <Profiler> даёт программный обратный вызов. Режим разработки отличается от рабочей сборки, поэтому используйте сборку для профилирования и реальные трассировки. Сначала найдите дорогую ветвь и частоту вызовов, затем разбирайте владельца данных и свойства.
Программный обратный вызов полезен, когда нужно измерять сценарий автоматически, а не глазами:
// src/app/dashboard/DashboardProfiler.tsx
<Profiler
id="dashboard"
onRender={(id, phase, actualDuration, baseDuration) => {
// actualDuration — фактическая работа, baseDuration — оценка без мемоизации
reportRenderMetric({ id, phase, actualDuration, baseDuration });
}}
>
<Dashboard />
</Profiler>Число вызовов компонента само по себе ничего не значит: сто вызовов по 0.05 мс дешевле одного вызова на 30 мс. Смотреть надо на длительность фиксации и на то, какая ветвь в неё вносит вклад. phase при этом отличает первое монтирование от обновления — оптимизировать их приходится по-разному.
Проверьте себя. Найдите нестабильное значение провайдера.
Частая ошибка. Судить только по числу вызовов компонента без длительности.
React Compiler автоматически мемоизирует код во время сборки, если соблюдаются правила React. Он не исправляет последовательные сетевые запросы, огромный DOM или чередование чтения и записи разметки. Диагностика помогает найти мутации и нечистые вычисления. Версию фиксируют при постепенном внедрении и обновляют вместе со сквозными тестами.
Включение — это настройка сборки, а не правка компонентов:
// vite.config.ts
export default defineConfig({
plugins: [
react({
babel: { plugins: [['babel-plugin-react-compiler', { target: '19' }]] },
}),
],
});Но компилятор не тронет код, нарушающий правила React, — и это к лучшему, потому что такой код нельзя мемоизировать безопасно:
// ❌ компилятор пропустит компонент: свойство мутируется во время отрисовки
function Row({ item }: { item: Item }) {
item.viewedAt = Date.now(); // мутация входных данных
return <td>{item.title}</td>;
}Отчёт компилятора («bailout») — это список мест, где нарушена чистота: мутация свойств, чтение и запись ref во время отрисовки, обращение к изменяемой внешней переменной. Их исправляют, а не обходят. И ни одна мемоизация не ускорит страницу, которая ждёт четыре последовательных запроса.
Проверьте себя. Сравните результат и трассировку скомпилированного поддерева.
Частая ошибка. Считать React Compiler универсальным исправлением производительности.
memo пропускает вызов компонента при равных свойствах, useMemo кеширует вычисление, useCallback сохраняет ссылку на функцию. Кеш не делает вычисление корректным и сам имеет стоимость. Стабильность нужна для дорогого потомка или внешней зависимости, а не ради отключения предупреждения линтера.
Самая частая бесполезная мемоизация выглядит так: memo есть, но каждый новый объект в свойствах его отменяет.
// ❌ memo не срабатывает: config и onSelect новые на каждой отрисовке родителя
const HeavyTable = memo(Table);
<HeavyTable
config={{ dense: true }}
onSelect={(id) => setSelected(id)}
/>Стабилизировать нужно именно то, что участвует в сравнении:
// ✅ значения стабильны между отрисовками
const config = useMemo(() => ({ dense: true }), []);
const handleSelect = useCallback((id: string) => setSelected(id), []);
<HeavyTable config={config} onSelect={handleSelect} />Ещё лучше — вынести константу за пределы компонента: тогда мемоизация не нужна вовсе. И помните, что сам memo не бесплатен: поверхностное сравнение свойств дешёвого компонента может стоить дороже его повторного вызова.
Проверьте себя. Измерьте мемоизированного потомка до и после стабилизации свойств.
Частая ошибка. Новый объект в свойствах делает memo бесполезным.
Если тысячи строк DOM дорого отрисовывать и размещать, виртуализация оставляет видимую область и небольшой запас вокруг неё. Нужно сохранить ключи, позицию прокрутки, клавиатуру, фокус, динамическую высоту и работу программы чтения с экрана. Постраничный вывод может быть полезнее бесконечной виртуализации.
Виртуализатор отдаёт индексы видимых элементов и общую высоту, а разметку строите вы:
// src/features/tickets/VirtualTicketList.tsx
const virtualizer = useVirtualizer({
count: tickets.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
overscan: 8, // запас, чтобы прокрутка не мигала пустотой
});
<div ref={scrollRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={tickets[row.index].id} /* ключ доменный, не row.index */
style={{ position: 'absolute', top: row.start, height: row.size, width: '100%' }}
>
<TicketRow ticket={tickets[row.index]} />
</div>
))}
</div>
</div>Ключ по индексу здесь особенно опасен: при прокрутке индексы переиспользуются, и React сопоставит состояние строки с другой заявкой. Для двадцати строк вся эта механика — чистый вред: она добавляет абсолютное позиционирование, ломает поиск по странице и усложняет доступность без выигрыша.
Проверьте себя. Измерьте число DOM-узлов и прокрутку списка из 10 000 строк.
Частая ошибка. Виртуализировать список из 20 строк.
Измеряйте переданные сжатые байты, разбор, выполнение и последовательность запросов. Разделение по маршрутам и возможностям уменьшает начальный JavaScript, но слишком мелкие части добавляют запросы. preload оставляйте для скоро нужного критичного ресурса, prefetch — для вероятного будущего. Удаление неиспользуемого кода (tree shaking) зависит от ESM и sideEffects.
Тяжёлую и редкую часть интерфейса выносят в отдельный фрагмент:
// src/features/reports/ReportChart.tsx
const Chart = lazy(() => import('./HeavyChart')); // ~180 КБ уходят из начального пакета
<Suspense fallback={<ChartSkeleton />}>
<Chart data={data} />
</Suspense>Импорт целой библиотеки ради одной функции работает в обратную сторону — и sideEffects в её package.json может помешать удалить неиспользуемое:
// ❌ в пакет попадает вся библиотека
import _ from 'lodash';
const unique = _.uniqBy(items, 'id');
// ✅ либо точечный импорт, либо своя короткая функция
import uniqBy from 'lodash-es/uniqBy';Разделение стоит проверять по факту: анализатор пакета показывает, что действительно попало в начальный фрагмент, а панель сети — сколько запросов добавилось. Сто мелких частей по 2 КБ хуже одной на 200 КБ.
Проверьте себя. Проанализируйте пакет и лениво загружаемую диаграмму.
Частая ошибка. Импортировать целую библиотеку утилит ради одной функции.
Компоненты React могут вычисляться быстро, а браузерная разметка — медленно из-за DOM и CSS. Резервируйте размеры изображений, не чередуйте принудительное чтение и запись разметки (layout thrashing), осознанно анимируйте transform и opacity. CLS измеряет неожиданные сдвиги, а не любое движение после действия пользователя.
Чередование чтения и записи внутри одного цикла заставляет браузер пересчитывать разметку на каждой итерации:
// ❌ N принудительных пересчётов разметки
rows.forEach((row) => {
const height = row.offsetHeight; // чтение — требует актуальной разметки
row.style.height = `${height + 8}px`; // запись — делает разметку устаревшей
});
// ✅ сначала все чтения, потом все записи
const heights = rows.map((row) => row.offsetHeight);
rows.forEach((row, index) => {
row.style.height = `${heights[index] + 8}px`;
});Сдвиг разметки от изображений устраняется резервированием места до загрузки:
<img src={cover} alt="" width={320} height={180} style={{ aspectRatio: '16 / 9' }} />Без width, height или aspect-ratio браузер не знает размер до загрузки файла и подставляет нулевую высоту — весь текст под картинкой прыгает вниз в момент её появления.
Проверьте себя. Уберите сдвиг разметки у изображений карточек.
Частая ошибка. Изображение без width/height/aspect-ratio.
Бюджет производительности задаёт пороги размера пакета, задержки маршрута, Web Vitals и критического взаимодействия в контролируемой среде. Лабораторная метрика в CI шумна, поэтому нужны допуски и динамика; наблюдение в эксплуатации показывает реальные устройства. Не блокируйте слияние по одному нестабильному запуску.
Бюджет размера удобно держать в конфигурации сборки — он проверяется на каждом прогоне и не требует ручной дисциплины:
{
"budgets": [
{ "path": "dist/assets/index-*.js", "maxSize": "180 kB", "compression": "gzip" },
{ "path": "dist/assets/vendor-*.js", "maxSize": "240 kB", "compression": "gzip" }
]
}Метрики взаимодействия шумнее размера, поэтому их сравнивают по нескольким прогонам и с допуском:
// perf/interaction.spec.ts
const runs = await measureInputLatency({ repeats: 7 });
expect(median(runs)).toBeLessThan(BASELINE_MS * 1.15); // допуск 15% на шум CIМедиана по семи прогонам и явный допуск дают сигнал, на который можно опираться; порог по одному запуску даёт «мигающий» тест, который команда быстро научится игнорировать. Разовый аудит перед запуском не защищает вообще: регрессия придёт со следующим релизом.
Проверьте себя. Добавьте бюджет пакета и сохранение трассировки.
Частая ошибка. Ручной аудит один раз перед запуском.
Профилируйте панель: медленный селектор, нестабильный контекст, 10 000 строк, отдельную часть с диаграммой и сдвиг разметки. Включите React Compiler, исправьте владение данными, виртуализируйте список, лениво загрузите диаграмму и измерьте взаимодействие и Web Vitals.
Работа готова, когда есть исходное измерение, трассировка, гипотеза, изменение и повторное измерение, а тесты подтверждают прежнее поведение.
Далее: Безопасность React-приложений