Сначала измерить, потом сокращать работу в таком порядке: отправлять меньше JavaScript (code splitting, более лёгкие зависимости), меньше рендерить (стейт пониже в дереве, виртуализация списков, мемоизация там, где Profiler показывает лишнюю работу) и держать главный поток свободным для ввода (transitions, отложенные значения).
Измерение: React DevTools Profiler для ререндеров, панель Performance для длинных задач, Lighthouse и полевые Core Web Vitals для того, что чувствуют пользователи.
Загрузка: lazy на уровне роутов, маленький входной чанк, серверный рендер или пререндер для контентных страниц, картинки с явными размерами.
Рендер: держать часто меняющийся стейт в компоненте, который его использует, передавать тяжёлые поддеревья через children, виртуализировать длинные списки, использовать стабильные ключи.
Мемоизация: memo, useMemo и useCallback вручную или React Compiler, который расставляет их автоматически.
Отзывчивость: useTransition и useDeferredValue для несрочных обновлений, Web Workers для тяжёлых вычислений.
Данные: кэшировать серверное состояние (TanStack Query), избегать водопадов запросов, делать prefetch по наведению или фокусу.
«Обернуть всё в useMemo» считается слабым ответом; сильный начинается с профилирования и устраняет самую дорогую часть.
ReactSenior
Производительность
React.memo, useMemo и useCallback, React Compiler, разбиение кода и lazy, виртуализация списков, Profiler, Core Web Vitals, размер бандла
Закрыто вопросов
0 / 20
- 0Повторить
- 8Часто спрашивают
Вопросы
React.memo оборачивает компонент так, что React пропускает его ререндер, если родитель перерисовался с пропсами, поверхностно равными прежним (Object.is для каждого пропса). Компонент всё равно ререндерится, когда меняется его собственный стейт или контекст, который он читает.
Поверхностное сравнение: объект, массив или стрелочная функция, созданные в рендере родителя, каждый раз получают новую ссылку, поэтому memo видит изменённый пропс и рендерит компонент.
Своё сравнение: второй аргумент arePropsEqual(prev, next) заменяет стандартную проверку; он обязан сравнивать все пропсы, включая функции, иначе компонент покажет устаревшие данные.
**children**: JSX, переданный как children, создаётся заново при каждом рендере родителя, поэтому мемоизированный компонент с children почти никогда не пропускает рендер.
Подсказка, а не гарантия: корректность не должна зависеть от того, пропущен ли рендер.
С включённым React Compiler большинство ручных memo становятся не нужны: компилятор сам мемоизирует JSX.
Dashboard при каждом рендере создаёт новый объект options и новую стрелочную функцию onPointClick, поэтому поверхностное сравнение в memo всегда видит изменённые пропсы и никогда не пропускает рендер.
Каждое нажатие клавиши меняет query, и Dashboard ререндерится; { ... } и () => ..., написанные прямо в JSX, каждый раз дают новые ссылки.
useMemo сохраняет options тем же объектом, пока не изменится data, а useCallback делает то же для обработчика.
Подойдёт и структурное решение: вынести инпут и его стейт в компонент SearchBox, тогда ввод вообще не будет ререндерить Dashboard.
С включённым React Compiler исходный код мемоизируется автоматически, и проблема исчезает.
Мемоизация меняет процессорное время на память и сложность: при каждом рендере всё равно выполняется сравнение пропсов или зависимостей, а старое значение остаётся в памяти. Она окупается только тогда, когда пропущенная работа дороже этого, и тихо перестаёт работать, если хотя бы один вход при каждом рендере получает новую ссылку.
Сравнение не бесплатно: memo сравнивает все пропсы при каждом рендере родителя; для компонента, который рендерится за микросекунды, эта проверка становится чистыми накладными расходами.
Разорванная цепочка: один немемоизированный объект или колбэк в пропсах ломает memo, и наполовину мемоизированное дерево платит цену, ничего не получая взамен.
Память: закэшированные значения и замыкания живут, пока не изменятся зависимости, что заметно для больших производных массивов.
Баги: неверный массив зависимостей даёт устаревшие значения; своя arePropsEqual, которая игнорирует пропс-функцию, вызывает устаревший колбэк.
Читаемость: если обернуть всё подряд, теряются те немногие места, где мемоизация действительно важна.
Сначала профилируйте, исправьте структуру (опустите стейт ниже, передайте children) и только потом мемоизируйте; с React Compiler пишите обычный код и доверьте мемоизацию ему.
Обновление стейта ререндерит только компонент, которому этот стейт принадлежит, и его потомков, поэтому часто меняющийся стейт стоит держать в самом маленьком компоненте, которому он нужен. Элементы, переданные как children, создал родитель, поэтому они не ререндерятся, когда меняется собственный стейт обёртки.
Опустить стейт ниже: если query нужен только инпуту, храните query в SearchBox, а не на странице; тогда ввод ререндерит поле, а не всю страницу.
Поднять контент выше: когда обёртке нужен стейт (позиция скролла, hover, флаг открытия), а внутри неё тяжёлое поддерево, принимайте это поддерево как children. Объекты элементов остаются теми же ссылками, что и в прошлый раз, и React их пропускает.
Обоим приёмам не нужны массивы зависимостей, и они не ломаются, когда кто-то добавит инлайн-колбэк.
Пробуйте их до memo; к мемоизации переходите, когда структуру изменить нельзя.
React Compiler (стабилен с версии 1.0 в октябре 2025) представляет собой Babel-плагин времени сборки, который анализирует компоненты и хуки и автоматически расставляет точечную мемоизацию. В новом коде он заменяет большинство ручных useMemo, useCallback и memo; они остаются как запасной выход.
Как он работает
Он переписывает каждый компонент так, чтобы кэшировать JSX, производные значения и колбэки по их входам, точнее, чем ручной useMemo, и умеет мемоизировать код после раннего return, куда хуки поставить нельзя.
Поставляется как babel-plugin-react-compiler; в Next.js (reactCompiler: true), Vite и Expo он включается опцией конфига. Для React 17 и 18 нужен ещё react-compiler-runtime.
Что он требует
Код должен соблюдать Rules of React: чистый рендер, никаких мутаций пропсов и стейта во время рендера, хуки вызываются безусловно. Если компилятор не может доказать, что компонент безопасен, он оставляет его некомпилированным.
eslint-plugin-react-hooks содержит диагностику компилятора в виде правил линтера, поэтому нарушения видны прямо в редакторе.
Директива "use no memo" в начале функции исключает этот компонент из компиляции на время отладки.
Что остаётся ручным
Существующие useMemo или useCallback стоит оставить там, где значение служит зависимостью эффекта: изменение мемоизации может поменять момент срабатывания эффекта.
Структурные проблемы он не решает: список на 10 000 строк всё равно нужно виртуализировать, а тяжёлый бандл всё равно нужно разбивать.
Запишите медленное взаимодействие в React DevTools Profiler и разберите flame graph каждого коммита: серые полосы не рендерились, цветные рендерились, и для каждой видно время. Включите «Record why each component rendered while profiling», чтобы увидеть причину: пропсы, стейт, хуки, контекст или ререндер родителя.
Flame graph: один коммит за раз; ширина полосы показывает время рендера вместе с детьми, поэтому ищите широкие жёлтые полосы.
Ranked chart: тот же коммит, отсортированный по собственному времени компонента; начинать стоит с верхних строк.
Полоса коммитов (справа вверху): высокие столбцы означают медленные коммиты; клик переносит к нужному.
«Highlight updates when components render» подсвечивает ререндерящиеся компоненты в реальном времени; так быстрее всего заметить инпут, который ререндерит всю страницу.
Профилируйте production-сборку (точка входа react-dom/profiling), чтобы получить реальные цифры; режим разработки в разы медленнее.
React 19.2 добавил Performance Tracks в панель Performance Chrome DevTools: приоритеты планировщика и рендеры компонентов видны рядом с сетью и JavaScript.
В коде <Profiler id onRender> сообщает actualDuration и baseDuration для каждого коммита, что удобно для логирования медленных экранов.
Code splitting разбивает бандл на чанки, которые загружаются по мере надобности, а не сразу. React.lazy(() => import('./Settings')) превращает динамический импорт в компонент: его первый рендер приостанавливается (suspend), пока чанк скачивается, а ближайший Suspense показывает fallback.
Бандлер (Vite, Rollup, webpack) видит import() и выносит модуль в отдельный чанк; модулю нужен default-экспорт, иначе его надо смапить: lazy(() => import('./x').then((m) => ({ default: m.X }))).
Сначала делите по роутам: каждая страница служит естественной границей; React Router v7 (lazy у роута) и Next.js делают это за вас.
Потом выносите тяжёлые и редко нужные виджеты: редакторы текста, графики, карты, админские экраны.
Чанк, который не загрузился (деплой заменил файлы), бросает ошибку, поэтому оберните ленивые части в error boundary и предложите перезагрузку.
Предзагрузка по намерению: вызывайте тот же import() по наведению или фокусу, чтобы к моменту клика чанк уже был в кэше.
Не дробите крошечные компоненты: каждый чанк означает лишний запрос и мелькание fallback.
lazy вызывается внутри компонента, поэтому каждый рендер создаёт новый тип компонента. React видит на том же месте другой тип, размонтирует старый Editor вместе с его стейтом и снова приостанавливается на новом.
lazy должен выполняться один раз, на уровне модуля, тогда тип компонента и загруженный модуль переиспользуются между рендерами.
Нажатие «сохранить» вызывает setSaved, это ререндерит NotePage и запускает сброс.
То же правило действует для любого компонента, объявленного внутри тела другого компонента: новая функция на каждом рендере означает новое монтирование на каждом рендере.
Исправление переносит вызов lazy в начало модуля, больше ничего менять не нужно.
Dashboard ещё и статически импортирует CHART_COLORS из ./RevenueChart, поэтому этот модуль вместе с библиотекой графиков, которую он импортирует, обязан оказаться в одном чанке с Dashboard. Динамический import() не может вынести модуль, который где-то импортирован статически.
Vite и Rollup предупреждают об этом при сборке: модуль «is dynamically imported by ... but also statically imported by ..., dynamic import will not move module into another chunk».
Достаточно одной общей константы, хелпера или реэкспорта из barrel-файла, чтобы весь модуль вернулся в основной чанк.
Исправление: вынести то, что нужно обеим сторонам, в маленький модуль (chartColors.ts), который не импортирует библиотеку.
Импорты только типов (import type) стираются при сборке и такой проблемы не создают.
Проверяйте результат визуализатором бандла, а не только чтением кода.
Виртуализация рендерит только строки, видимые в области прокрутки, плюс небольшой запас (overscan), и размещает их внутри распорки высотой во весь список. Тогда список на 50 000 строк обходится React и DOM в несколько десятков строк.
Когда: сотни и тысячи непростых строк, история чата, таблицы данных, бесконечные ленты; меньше чем на сотне простых строк сложность не окупается.
Как: при скролле по scrollTop и размерам строк вычисляются первый и последний видимые индексы, рендерится этот срез и сдвигается через transform или top.
Библиотеки: TanStack Virtual (useVirtualizer, headless, измеряет динамические размеры), react-window, react-virtuoso (разная высота строк и перевёрнутые списки чатов из коробки).
Цена: поиск по странице в браузере и скринридеры не видят строки за экраном; строки разной высоты нужно измерять; ключи строк должны быть стабильными, а сами строки дешёвыми.
Альтернатива на CSS: content-visibility: auto позволяет браузеру пропускать layout и отрисовку секций за экраном, оставляя их в DOM.
Core Web Vitals представляют собой три полевые метрики Google, которые оценивают по 75-му перцентилю реальных визитов: LCP (показ основного контента, хорошо до 2,5 с), INP (реакция на ввод, хорошо до 200 мс, в 2024 году заменила FID) и CLS (стабильность вёрстки, хорошо до 0,1).
LCP
SPA с клиентским рендером не рисует ничего полезного, пока бандл не скачается, не выполнится и не загрузит данные; помогают SSR или пререндер, маленький входной чанк и fetchpriority="high" у главной картинки.
Ленивая загрузка главной картинки или её вставка из JavaScript задерживает LCP.
INP
Длинные задачи в главном потоке: большой синхронный ререндер после клика или нажатия клавиши, тяжёлые эффекты, сторонние скрипты.
Лечится ререндерами поменьше (стейт ниже в дереве, memo, виртуализация), transitions для несрочных обновлений и дроблением долгой работы.
CLS
Картинки без width и height или aspect-ratio, контент, вставленный над уже показанным после загрузки данных, веб-шрифты, которые подменяются с другими метриками.
Резервируйте место скелетонами финального размера и используйте font-display с подстройкой метрик запасного шрифта.
Они заранее сообщают браузеру о запросах, которые он нашёл бы позже: preload загружает файл, нужный текущей странице, prefetch загружает файл, который может понадобиться при следующей навигации, с самым низким приоритетом, а preconnect заранее открывает соединение с origin. В React 19 для большинства из них есть функции из react-dom.
**preload** нужен для критичных файлов, которые находятся поздно: шрифт, указанный в CSS, или LCP-картинка, заданная фоном в CSS. Неверный или пропущенный as, а для шрифта отсутствие crossorigin, приводят к двойной загрузке файла.
**prefetch заполняет HTTP-кэш для следующей страницы, не конкурируя с текущей.
preconnect экономит раунд-трипы DNS, TCP и TLS до стороннего origin (API, CDN картинок). Ограничьтесь несколькими origin: неиспользуемые соединения стоят CPU и закрываются примерно через 10 секунд.
React 19**: preconnect, prefetchDNS, preload, preloadModule и preinit(href, { as: 'script' | 'style' }), который ещё и выполняет скрипт или применяет стили. Функции для prefetch нет: используйте элемент <link> или предзагрузку в роутере. Вызывайте их во время рендера, в Server или Client Components, или в обработчике события; React убирает дубли по href.
Почему функции: компонент глубоко в дереве объявляет, что ему нужно, а при SSR React пишет подсказки в HTML-поток, и загрузка начинается рано.
Избыток вредит: preload обязателен к выполнению и конкурирует за канал, поэтому десяток таких задерживает LCP-картинку. У предзагруженной картинки приоритет остаётся низким, пока не добавить fetchPriority: 'high', как в коде для баннера.
Показывайте текст запасным шрифтом сразу с font-display: swap (или optional), получайте файл шрифта пораньше, размещая у себя небольшой WOFF2 и предзагружая его, и подгоняйте метрики запасного шрифта через size-adjust, чтобы подмена не сдвигала текст.
Невидимый текст: браузеры прячут текст, пока шрифт загружается (до 3 секунд в Chrome и Firefox). swap сразу показывает запасной шрифт; optional даёт шрифту около 100 мс, а потом оставляет запасной до конца просмотра страницы: нет ни подмены, ни сдвига, но первый визит может обойтись без шрифта.
Сдвиг вёрстки: @font-face для запасного шрифта с size-adjust, ascent-override, descent-override и line-gap-override заставляет его занимать место веб-шрифта. Значения считают инструменты вроде Fontaine или Capsize, а next/font делает это автоматически.
Предзагружайте один или два файла, нужных на первом экране, потому что шрифт запрашивается только после разбора CSS. crossorigin обязателен даже на своём домене, потому что шрифты загружаются в режиме CORS; без него файл скачается дважды.
Размещайте шрифты у себя: Google Fonts добавляет ещё два origin для соединения, а браузеры теперь разделяют HTTP-кэш по сайтам (Chrome с версии 86), поэтому копия, закэшированная для другого сайта, уже не помогает.
Меньше байт: только WOFF2, вариативный шрифт вместо файла на каждое начертание и подмножества через unicode-range, чтобы английская страница никогда не скачивала кириллический файл.
Когда чистые вычисления блокируют главный поток дольше примерно 50 мс: разбор большого CSV или JSON, сортировка или фильтрация десятков тысяч строк, построение поискового индекса, обработка изображения. Компонент отправляет входные данные через postMessage и обновляет состояние, когда приходит сообщение с результатом.
Чего он не умеет: у воркера нет DOM, React и window. Он помогает, когда дорого стоят именно вычисления; медленный рендер множества компонентов лечат виртуализацией или transition.
Сообщения копируются: structured clone отнимает время на больших объектах и теряет функции и прототипы классов. Передайте ArrayBuffer через transfer (postMessage(buf, [buf])), чтобы переместить его без копирования.
Создание: Vite и webpack 5 собирают new Worker(new URL('./sort.worker.ts', import.meta.url)) в отдельный чанк; нужна именно такая форма с URL, а в документации Vite добавлено { type: 'module' }.
Comlink превращает сообщения в вызовы на промисах: expose(api) в воркере, await wrap<Api>(worker).sort(rows) в приложении.
Жизненный цикл: создавайте воркер в эффекте и вызывайте terminate() в функции очистки. При частых входных данных держите один воркер и помечайте каждый запрос id, чтобы медленный старый ответ не перезаписал более новый.
Сначала измерьте: запуск и копирование стоят несколько миллисекунд, поэтому задача на 5 мс быстрее выполнится в главном потоке.
INP измеряет задержку от ввода до следующей отрисовки, и он плохой, когда главный поток блокирует длинная задача: обычно большой синхронный ререндер, тяжёлая работа в обработчике события или сторонние скрипты. Решение в том, чтобы сначала отрисовать срочную обратную связь, а остальное убрать с дороги.
Сначала срочное: сразу обновите нажатую кнопку или инпут, а дорогое обновление стейта оберните в startTransition; React рендерит его прерываемыми кусками и уступает новому вводу примерно каждые 5 мс.
Отложите производный UI: useDeferredValue сначала рендерит медленный дочерний компонент со старым значением, а новое обрабатывает в фоне.
Уступайте в обработчиках: долгую работу вне React делите через await scheduler.yield() (Chromium; запасной вариант setTimeout), чтобы браузер успевал рисовать между кусками.
Уберите из главного потока: парсинг, сортировка или сравнение больших данных должны идти в Web Worker, а React только рендерит результат.
Эффекты тоже считаются: эффекты, вызванные кликом, выполняются до отрисовки, поэтому тяжёлый код в эффекте увеличивает INP.
Рендерите меньше: transition всё равно выполняет всю работу; уменьшают её виртуализация и мемоизация.
<Activity mode="hidden"> скрывает поддерево, не размонтируя его: React сохраняет его стейт и DOM, выполняет очистку эффектов и рендерит обновления для него с самым низким приоритетом. Переключение обратно в visible происходит мгновенно и сохраняет позицию скролла, введённый текст и загруженные данные.
Возврат назад: прошлая вкладка или экран остаётся скрытым, а не размонтированным, поэтому при возврате не нужно заново загружать данные и рендерить всё с нуля.
Пререндер: вероятный следующий экран рендерится в режиме hidden; его код и данные, умеющие работать с Suspense, загружаются в фоне.
Эффекты: у скрытых детей выполняется очистка эффектов, а при показе эффекты запускаются снова, поэтому подписки и таймеры на время скрытия останавливаются.
Цена: скрытые деревья по-прежнему занимают память и DOM-узлы, поэтому держите несколько, а не все когда-либо открытые экраны.
До 19.2 выбор был между условным рендером (стейт теряется) и CSS display: none (стейт сохраняется, но эффекты продолжают работать).
Собирать метрики реальных пользователей (RUM): библиотека web-vitals сообщает LCP, INP и CLS из браузера каждого посетителя, вы отправляете их через navigator.sendBeacon на эндпоинт аналитики и следите за 75-м перцентилем по страницам и классам устройств.
Лаборатория и поле: Lighthouse и панель Performance в DevTools дают воспроизводимый результат, но на одной быстрой машине; полевые данные показывают медленные Android-телефоны и плохие сети.
Атрибуция: сборка web-vitals/attribution сообщает, какой элемент оказался LCP и какое взаимодействие и скрипт вызвали медленный INP.
CrUX (Chrome UX Report) и Search Console показывают те же метрики для публичных сайтов без собственного пайплайна, за окно в 28 дней.
Специфика React: <Profiler onRender> в профилирующей сборке или PerformanceObserver для длинных задач позволяют пометить медленные коммиты по экранам.
Бюджеты: роняйте CI при росте входного бандла, потому что полевые метрики покажут регрессию только через несколько дней после релиза.
Сначала измерить визуализатором бандла (rollup-plugin-visualizer для Vite, webpack-bundle-analyzer или source-map-explorer по собранным source map), потом убрать самое крупное: тяжёлые зависимости, код, которому место в ленивых чанках, и код, который не удалил tree shaking.
Тяжёлые зависимости: заменить moment на date-fns или Intl, импортировать lodash по одной функции или использовать нативные методы; проверять вес пакета до того, как его добавить.
Tree shaking требует ES-модулей и пакетов без побочных эффектов ("sideEffects": false); barrel-файлы, которые реэкспортируют всё подряд, и CommonJS-пакеты часто его ломают.
Дробление: lazy на уровне роутов, тяжёлые виджеты по требованию, import() для библиотек, нужных только после действия пользователя (экспорт в PDF, графики).
Современный JavaScript: собирать под современные браузеры (build.target в Vite), а не транспилировать и полифилить под старые.
Защита: бюджет размера в CI (size-limit или скрипт по gzip-размерам) ловит регрессии на ревью, а не в продакшене.
Оценивайте размер после gzip или brotli и время парсинга и выполнения на телефоне среднего уровня, а не байты на диске.
Воспроизвести с замедлением CPU, записать нажатие клавиши в React Profiler и панели Performance, чтобы понять, уходит ли время на рендер React, на layout или на собственный код, и исправить самое дорогое; обычно это рендер всех 5000 строк на каждое нажатие.
Диагностика
Profiler с «why did this render»: ререндерится ли вся таблица и каждая её строка?
Панель Performance с замедлением CPU в 4 раза: длинные задачи и как время делится между скриптами и style или layout.
Сам фильтр: работа на каждую строку при каждом нажатии (парсинг дат, toLowerCase для каждого поля) умножается на 5000.
Исправление, от самого выгодного
Рендерить меньше строк: виртуализировать таблицу, тогда нажатие рендерит около 30 строк.
Оставить ввод срочным: держать стейт инпута вне родителя таблицы и рендерить строки из useDeferredValue(query).
Пропускать неизменённые строки: обернуть компонент строки в memo, передавать стабильные пропсы, ключом делать id, никогда индекс.
Дешёвая фильтрация: один раз заранее посчитать нормализованные ключи поиска через useMemo; если фильтрация ходит на сервер, добавить debounce.
Проверка
Записать профиль снова и сравнить длительность коммита и INP; для критичного экрана добавить проверку производительности, чтобы исправление не откатилось.