Что на самом деле тормозит

скорость≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

«React тормозит» почти всегда означает другое: тормозит работа, которую React выполняет по вашей просьбе. Один клик способен вызвать два десятка функций компонентов — и стоить это может доли миллисекунды, а может и сотню. Разница видна только измерением, и самый честный измерительный прибор у вас уже есть — console.log.

Разделы темы:·

В статье «Триггер, рендер, коммит» три шага уже разложены: React принимает заявку, вызывает функции компонентов и только потом правит DOM. Слово «перерисовка» склеивает их обратно, а вместе с ними склеивает и цену: вызов небольшой функции стоит микросекунды, правка DOM — на порядки больше. Начнём с примера, который показывает, чего в перерисовке нет. Три клика подряд, а потом сверяем узел <p>, запомненный до кликов, с тем, что лежит в документе сейчас.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Узел тот же самый — React поменял в нём только текст. И вызовов функции оказалось два, а не четыре: три клика подряд попали в одну очередь обновлений («Функция-обновитель и очередь обновлений»), поэтому рендер после них был один — зато сразу с тройкой, потому что каждый клик отправлял функцию-обновитель, а не готовое число. Сравните с частью II: там ваш render() собирал разметку строкой и вставлял её в innerHTML, после чего все узлы были новыми — вместе с потерянным фокусом в поле и сброшенным выделением («Рендер из состояния и делегирование»). React вызывает вашу функцию, чтобы узнать, что должно быть на экране, и правит ровно расхождение.

«Компонент перерисовался» в React значит «его функцию вызвали». Это не то же самое, что «браузер что-то перерисовал».

Отсюда установка, с которой стоит подходить к любой оптимизации: сам факт лишнего вызова компонента — не беда. Беда — работа, которую этот вызов делает. Дальше учимся видеть и то и другое.

Первый вопрос при любом торможении — кто вообще выполняется. Отвечает на него не догадка, а счётчик: общий объект, куда каждый компонент отмечается в начале своего вызова. Ниже 24 карточки каталога и кнопка лайков в шапке; карточки о лайках не знают ничего. Сколько раз выполнится Card после одного клика?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сорок восемь. Что React не сравнивает пропсы перед вызовом, разобрано в статье про три шага; ново здесь не правило, а его цена в числах. При этом в DOM карточек не поменялось ни байта: описания совпали, коммит их не тронул. Двадцать четыре вызова ради нуля изменений — вот та «лишняя работа», о которой идёт речь во всей главе.

Клик подаёт заявку, а не рисует: счётчик читаем следующим тактом, через вложенный setTimeout. Иначе увидим числа до перерисовки.

Сравнение тоже стоит времени: чтобы понять, что пропсы не изменились, их нужно перебрать и сличить, а результат в большинстве случаев будет «изменились» — обработчики и объекты, созданные в теле родителя, на каждом рендере новые. React выбрал предсказуемую цену: вызвать компонент дешевле, чем гадать. Механизм, который включает сравнение там, где оно окупается, называется memo, и ему посвящена следующая статья.

Сколько стоят те 24 вызова? Функция Card создаёт один объект-описание и возвращает его: на все двадцать четыре уходят доли миллисекунды. Теперь дадим каждой карточке немного настоящей работы — такой, какой в жизни бывает сортировка, разбор дат или пересчёт итогов.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Точные числа на вашей машине будут другими, но соотношение важнее их. Ориентир простой: при 60 кадрах в секунду у браузера есть примерно 16 мс на кадр. Клик по кнопке, которая меняет одно число в шапке, съел здесь весь этот бюджет целиком — и это на двух дюжинах карточек и на ноутбуке. Цена растёт вместе со списком и падает вместе с быстротой машины: сотня карточек на технике послабее — и тот же клик уходит за сто миллисекунд, а сто миллисекунд читатель уже чувствует как задержку.

Обратите внимание, что изменилось между разделами: количество вызовов осталось прежним — 24 на каждую перерисовку. Дорогим стало не «перерисовалось много компонентов», а «на каждый вызов приходится тяжёлый расчёт». Ускорять здесь нужно не React, а свой код: убрать расчёт из тела компонента или считать его один раз.

Что видноЧто за этим стоитКуда смотреть
Один клик подвешивает страницу на десятые доли секундытяжёлый расчёт в теле компонентаубрать расчёт из рендера или закешировать (следующая статья)
Список дёргается при каждом нажатии клавишисостояние поля лежит выше, чем нужнораздел «Состояние живёт слишком высоко»
Тормозит только на списке в тысячи строкдорог не рендер, а коммит и сам DOMпоказывать окно списка (виртуализация) или страницы
Тормозит только у вас в разработкеdev-сборка React делает много лишнего, а StrictMode вызывает компоненты дваждымерить на прод-сборке

Перед оптимизацией задавайте вопрос «сколько миллисекунд?». Без числа улучшение не отличить от суеверия.

Чаще всего лишняя работа берётся не из React, а из схемы состояния. После статьи «Подъём состояния» легко привыкнуть держать всё в App: так удобнее передавать вниз. Плата — любое изменение перерисовывает всё дерево. Здесь флажок подсказки лежит в App; сколько раз выполнится Card после одного переключения?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Шесть карточек выполнились второй раз, чтобы вернуть ровно то же самое. React не «не догадался» — состояние open просто лежит не там: читает его только сама подсказка. Переносим состояние вниз, в отдельный компонент.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тот же клик, тот же результат на экране — и ни одного лишнего вызова: App вообще не перерисовался, потому что его состояние не менялось, а рендер идёт вниз от того, кто подал заявку. Это правило из раздела «Как высоко поднимать» той же статьи о подъёме состояния: состояние поднимают до ближайшего общего родителя тех, кому оно нужно, — и ни на шаг выше.

Бывает, что состояние обязано быть в обёртке: панель сама решает, свёрнута она или нет, а внутри неё — чужое содержимое. Тогда содержимое отдают через children, и создаёт его тот, кто не перерисовывается.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Panel выполнился дважды, карточки — по разу. Элемент, который лежит в children, создан в App, а App заявку не подавал: React видит в пропсе тот же самый объект-описание, что и в прошлый раз, и целиком пропускает это поддерево. Никакого memo — только перестановка.

Расставлять console.log руками надоедает на третьем компоненте, а счётчик хочется видеть по каждому компоненту отдельно. Измерение — это логика без разметки, то есть ровно тот случай, ради которого пишут свой хук («Свой хук: логика без разметки»).

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Профайлер React DevTools и галочка «Highlight updates when components render» в настройках вкладки Components знакомы по той же статье про три шага: там ими отвечали на вопрос «кто перерисовался». На вопрос «сколько это стоило» отвечает тот же профайлер: он записывает коммиты и показывает, сколько миллисекунд занял каждый компонент и почему он перерисовался. Вкладка Performance браузера смотрит грубее, зато на всё сразу — по ней видно, когда упёрлось не в React, а в стили, разметку или картинки.

Мерить нужно на сборке для прода. В разработке React проверяет ваш код и ведёт подробные предупреждения, а <StrictMode> вдобавок намеренно вызывает функции компонентов дважды, чтобы поймать нечистый рендер: счётчик покажет 48 там, где в проде будет 24. Про примеры этой статьи это стоит сказать прямо: <StrictMode> в песочнице нет, поэтому счётчикам верить можно — двадцать четыре вызова здесь и есть двадцать четыре. А React в ней собран отладочный, нарочно: учебнику нужны полные тексты его предупреждений. Поэтому миллисекунды выше — это миллисекунды отладочной сборки, и в проде они будут меньше.

И порядок действий, а не набор приёмов: измерить → найти работу, которой могло не быть → убрать её структурой (состояние ниже, поддерево через children, расчёт за пределами компонента) → и только если после этого числа всё ещё плохие, браться за memo и useMemo, о которых следующая статья.

  • «Перерисовался» в React значит «его функцию вызвали»; DOM трогает только коммит и только там, где описания разошлись.
  • React вызывает детей, не сравнивая пропсы: вызов дешёвой функции обычно дешевле, чем проверка, нужен ли он.
  • Считать вызовы можно объектом-счётчиком и console.log; читать счётчик надо следующим тактом, потому что клик лишь подаёт заявку.
  • Дорого не количество вызовов, а работа внутри них: ориентир — 16 мс на кадр, и без миллисекунд разговор об оптимизации бессмыслен.
  • Самая частая причина лишней работы — состояние, поднятое выше, чем нужно; лечится переносом состояния вниз или передачей поддерева через children.
  • Мерить нужно на прод-сборке: в разработке React медленнее, а под StrictMode компоненты вызываются дважды.

1/4Функция компонента выполнилась заново, и значение на экране изменилось. Что произошло с его узлом <p> в документе?

2/4Счётчик показал: один клик по кнопке в шапке — 24 вызова Card, и ни одного изменения в DOM карточек. Что это значит?

3/4Из-за чего клик чаще всего становится заметно медленным?

4/4Флажок open лежит в App, и каждое переключение перерисовывает список из шести карточек. Что уберёт лишнюю работу, не прибегая к memo?

Посчитайте вызовы

Кнопка переключает тему, список из трёх строк к теме отношения не имеет. Добавьте счётчик рендеров и напечатайте его после клика: сколько раз выполнятся App и Row? Сначала запишите свой ответ, потом проверьте.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Счётчик — обычный объект вне компонентов и функция, которая отмечается в начале вызова. Печатать его нужно следующим тактом: клик только подаёт заявку.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Шесть — это три строки на первую отрисовку и три после клика. Ни одна строка не изменилась, но React вызвал их все: он не сравнивает пропсы перед вызовом. Пока в Row ничего не считается, эти три вызова ничего не стоят.

Опустите состояние

Запустите пример и наберите в поле пару букв, глядя на вывод: каждое нажатие клавиши перерисовывает весь список. Переставьте состояние так, чтобы ввод не трогал Item. Разметка на экране должна остаться прежней.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Черновик читает только форма — значит, там ему и место. Список остаётся в App, потому что его меняет добавление.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь при вводе перерисовывается только AddForm: в выводе не появляется ни одной новой строки, пока вы не нажали «Добавить». Обратите внимание, что списку не понадобился ни memo, ни какая-либо обёртка — хватило того, что состояние переехало на свой этаж.

Работа не на своём месте

Список часов собирается заново при каждом рендере Picker, хотя не зависит ни от пропсов, ни от состояния. Уберите эту работу из рендера, не прибегая к мемоизации.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Если значение не зависит ни от чего внутри компонента, ему незачем жить внутри компонента. Модульная константа считается один раз за всю жизнь страницы.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

useMemo здесь был бы хуже: он тоже не бесплатен — хранит значение, сравнивает зависимости и живёт ровно столько, сколько живёт компонент. Двадцать четыре строки — не тот случай, где нужна тяжёлая артиллерия; сначала смотрят, нельзя ли работу просто вынести.