← Все статьи

Компилятор React: убрали половину useMemo и оставили нужные

В панели на React 19 было 214 вызовов useMemo и useCallback. Компилятор снял большую часть; два края всё равно остались ручными.

Компилятор React: убрали половину useMemo и оставили нужные
Содержание

Коротко

В панели на React 19 за два года накопилось 214 вызовов useMemo и useCallback.

Автор пересчитал их поиском по коду в пятницу и долго смотрел на цифру. Никто не садился и не планировал столько обёрток: они наросли сами.

После включения компилятора React ручных обёрток осталось около 80. Там, где запоминание уже было аккуратным, скорость почти не изменилась. Заметно быстрее стали экраны, до которых руки раньше не доходили.

Компилятор молча пропускает компонент, если код нарушает правила React. Он и не чинит объект, который заново создают ещё до входа в скомпилированный код.

Частые обновления от перетаскивания — отдельная история. Это никогда не было задачей запоминания.

Что произошло

Приложение — панель на React 19. В ней большая виртуализированная таблица, несколько графиков и ползунок шкалы времени, который таскают мышью.

Команда продуктовая, не команда производительности. Обёртки ставили «на всякий случай», когда что-то уже дёргалось.

Медленная таблица получила useMemo. График перестал мигать после useCallback. На рецензии просили обернуть значение, потому что каждый раз приходили новые входные данные.

Через два года половина компонентов выходила за хлебом в бронежилете. Защита стояла на каждом шаге, хотя поход был обычный.

Включение компилятора оказалось скучным, и автор считает это лучшим отзывом.

В проекте на Next.js это флаг в настройках. В Vite — один плагин сборки, babel-plugin-react-compiler.

Сразу на всё приложение флаг не ставили. Сначала режим пометки: компилятор трогал только компоненты с директивой "use memo", область за областью.

Узкая дверь была нарочно. Скомпилировали одну область, посмотрели, не разъехалось ли поведение, и только потом открыли следующую.

Через две недели перешли на обычный режим. Компоненты, которым ещё не доверяли, пометили "use no memo" и оставили жить по-старому рядом с уже скомпилированными.

Полезнее всего был шаг до этого. Прогнали npx react-compiler-healthcheck@latest и правила компилятора в eslint-plugin-react-hooks.

Проверка сказала, сколько компонентов компилятор вообще берёт. Линтер объяснил, почему остальные он пропускает. Список, по словам автора, был неловким.

Типичный список заказов до чистки фильтровал и сортировал внутри двух useMemo, оборачивал выбор строки в useCallback и экспорт в React.memo.

После чистки остались обычные .filter() и .sort() по копии. Обработчик уходит в строку как есть.

Нет массива зависимостей, который легко забыть. Нет устаревшего замыкания внутри useCallback. Не нужно копаться, зачем значение вообще обернули.

Именно читаемость автор считает главной победой, важнее любого замера. Код снова похож на тот, что написали бы в первый день.

За три небольших изменения убрали около 60% ручных обёрток. Каждое изменение — одна область продукта и замер до и после.

Если что-то просело, видно, какое удаление виновато. Крупная чистка сразу по всей панели такой след не оставляет.

С 214 вызовов осталось около 80.

Компилятор запоминает и то, что руками в этом месте не обернуть. Хук нельзя вызвать после раннего выхода.

Экран деталей всегда был неловким. Данные загрузили, пустое состояние вернули сразу, а сводку собирают уже ниже.

Ручной useMemo туда по правилам хуков не вставить. Компилятор видит функцию целиком и может запомнить работу ниже выхода, не ломая запрет.

Почему это важно

Четырёхкратного ускорения не было. Автор прямо пишет, что на панели, которую уже оборачивали вручную, такому числу не поверил бы.

Замеряли три вещи и не публиковали более мелкую таблицу результатов.

В профилировщике React DevTools гоняли один и тот же записанный сценарий до и после каждого изменения: сколько раз интерфейс фиксировал обновление и сколько это длилось.

Один и тот же сценарий нужен, чтобы не сравнивать разные жесты и не принять другой путь по экрану за эффект чистки.

INP брали из мониторинга реальных пользователей: неделя до и неделя после. Неделя с каждой стороны, чтобы дневной шум не сошёл за победу.

Третье — размер сборки. Компилятор добавляет свой код, и выигрыш на экране не должен прятать более тяжёлый основной фрагмент.

Там, где запоминание уже было аккуратным, ничего не сдвинулось. Число обновлений то же, длительности в пределах шума.

Автор считает это правильным исходом, а не провалом. Компилятор не обязан ускорять то, что и так не перерисовывалось лишний раз.

Сборка чуть выросла: на пару процентов у основного фрагмента. Отдельной разбивки по экранам автор не приводит.

Выигрыш пришёл с экранов, которые никто не оптимизировал. Панель настроек и несколько других мест заново рисовали целые ветки на каждое нажатие клавиши.

На средних телефонах Android показатель INP на этих экранах заметно улучшился. На сколько именно — в статье нет, и выдумывать величину не из чего.

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

Честный итог замеров такой. Где были осторожны — ускорения нет. Где не были — оно есть. Кода стало меньше.

Бронежилет сняли не ради рекорда на секундомере. Большая часть обёрток больше ничего не защищала.

На стенде всплыли два места, которые компилятор не заменяет.

Первое — тихий пропуск. Компилятор не падает, если компонент нарушает правила React. Он пропускает его и идёт дальше.

В таблице лидеров массив очков сортировали прямо на месте. .sort() меняет исходный массив, а здесь это входные данные.

Такое безопасно не оптимизировать, поэтому компонент остался как был. Правка — одна строка: сортировать копию, [...scores].sort(...).

Тихий пропуск опасен тем, что сборка не падает и не пишет ошибку. Легко решить, что компилятор уже везде, хотя таблицу лидеров он обошёл стороной.

Пока список линтера не прочитан, эта дыра не видна по поведению экрана. Экран работает, просто без автоматического запоминания.

Второе — родитель вне досягаемости компилятора. Старая обёртка графика на каждом обновлении отдаёт дочернему компоненту новый объект настроек.

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

Компилятор не чинит значение, которое каждый раз оказывается новым объектом ещё до входа в скомпилированный код. На этой границе вернули ручной useMemo.

useMemo встал не внутрь графика, а там, где гарантии компилятора ещё не начались. Где они кончаются, снова начинается ручная работа.

Отдельно от этих двух случаев — ползунок шкалы времени. Он двигает указатель на каждое событие pointermove.

Почти все обёртки убрали, а рывки при перетаскивании остались. Компилятор удешевляет отрисовку, но не делает её бесплатной.

В состояние React уходило больше 60 обновлений в секунду. Никакая обёртка не отменяет сам факт, что дерево узнаёт о каждом сдвиге пальца.

Жест вынесли из состояния. Переменную CSS двигают из requestAnimationFrame, а React узнаёт о значении только когда жест закончился.

Пока указатель едет за пальцем, картинка обновляется без нового прохода по дереву компонентов. В состояние попадает только итог, когда кнопку мыши отпустили.

Частый ввод никогда не был задачей запоминания. Это третий сюжет статьи, не третья поломка компилятора.

На практике

Автор советует не начинать с удаления. Компилятор уживается с уже написанными useMemo, так что спешить некуда.

Сначала проверка готовности и список замечаний линтера. Это и есть очередь на перенос, а не повод сразу вычистить файл.

Дальше — мелкие изменения с замером, а не одна большая чистка. Крупное удаление сразу по всей панели не с чем сравнить, если замер просел.

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

Порядок, который он сам выбрал бы на существующей кодовой базе:

  1. Ничего не удалять. Включить компилятор, прогнать проверку готовности и закрыть замечания линтера.
  2. Катить постепенно: режим пометки "use memo" или явный отказ "use no memo" на рискованных компонентах.
  3. Снимать замер до и после каждого удаления. Нет сдвига — удалять. Есть откат — это граница или нарушение правил.
  4. Ручное запоминание оставить на краях: нескомпилированный родитель и чужие компоненты, которым нужна та же ссылка на объект.
  5. Частый ввод убрать из состояния React. Движение указателя, прокрутка и перетаскивание живут в ссылке на значение, в переменной CSS или в requestAnimationFrame.

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

Итог

С 214 ручных обёрток осталось около 80, и у каждой выжившей есть причина.

Пока лишние useMemo и useCallback стояли рядом, эти причины было не разглядеть. После чистки они видны сразу.

Компилятор React забирает защитные обёртки внутри своих правил. Он не обещает ускорение там, где панель и так была аккуратной.

Два края он не закрывает. Компонент, который меняет входные данные на месте, он пропускает. Объект, который заново собирают до входа в скомпилированный код, остаётся вашей заботой.

Ползунок шкалы времени напоминает другое. Бронежилет тут ни при чём: больше 60 обновлений состояния в секунду надо вынести из React, а не обернуть ещё раз.

Комментарии

Загрузка комментариев…