React Compiler: мемоизация уходит из кода
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Мемоизация из прошлой статьи — рукопись: вы сами решаете, что обернуть, и своей рукой перечисляете зависимости. React Compiler делает ту же работу на сборке: читает компонент, выводит зависимости из кода и дописывает кеш, которого в исходнике не видно. Взамен он требует, чтобы код соблюдал правила React, — и это, по сути, вся цена.
React Compiler ничего не делает в браузере: это шаг сборки. Сборка и так переписывает ваш код — превращает <h3> в вызов функции (статья «JSX — это не HTML»; в Next.js это делает SWC, в Expo — Babel). React Compiler — ещё одно такое преобразование поверх. На вход идёт код без единого useMemo, на выходе — код с кешем.
Кеш устроен незамысловато: массив ячеек, который живёт при компоненте столько же, сколько его состояние. В одних ячейках лежат входы, при которых считали, в других — результаты. Перед каждым вычислением стоит проверка: вход тот же — берём готовое, вход другой — считаем заново и кладём в ячейку.
Соберём такой массив руками — просто чтобы увидеть механику. Кнопка меняет состояние Page и к заголовку отношения не имеет; жмём её трижды. Сколько раз напечатается «рендер Title» и сколько — «считаем заголовок»?
Четыре отрисовки и один расчёт. Функция Title вызывалась при каждой перерисовке родителя — React не спрашивает разрешения, как показано в статье «Триггер, рендер, коммит», — но внутри неё работа выполнилась однажды: вход не менялся. Ровно это и делает компилятор, только ячейки заводит сам и не для одной строки, а для каждого значения в компоненте: вычислений, обработчиков, объектов и элементов разметки.
Писать в useRef во время рендера нельзя — это одно из правил, о которых пойдёт речь ниже. Здесь мы вручную изображаем внутренний механизм React; в вашем коде эти строки пишет компилятор.
Компилятор не переписывает вашу логику — он расставляет вокруг неё проверки «менялось или нет». t0, t1 — его собственные имена для промежуточных значений, $ — тот самый массив ячеек, _c — функция из react/compiler-runtime, которая заводит массив при первой отрисовке (в React 19 этот рантайм уже внутри пакета react).
Читать такой код не нужно — но полезно один раз увидеть, чтобы понимать, что происходит: никакой магии, обычные if и сравнения по !==. И заодно это способ проверить, что компилятор действительно включён: в собранном файле появляются вызовы _c.
Ручная мемоизация держится на массиве зависимостей, а массив — это текст, который пишет человек. Система типов следит за ним лишь наполовину: без второго аргумента useMemo не вызвать.
Это всё, что типы могут сказать: массив на месте. Что в нём лежит — вопрос не типов, а данных, которыми пользуется вычисление. Забыли одну переменную — и значение застынет. Цена сто рублей, штук становится две; что напечатает последняя строка?
count новый, а sum остался от первой отрисовки: React честно исполнил договор — price не менялся, значит, пересчитывать нечего. Ошибка не в React и даже не в useMemo; ошибка в списке, который написал человек и забыл обновить, когда добавлял в формулу второй множитель.Компилятор списков не пишет. Он смотрит, из чего вычислено значение, и ставит проверку ровно на эти входы — на price и на count. Забыть зависимость невозможно, потому что её никто не перечисляет: она выводится из кода вычисления, а код и есть источник правды.
С useCallback та же болезнь протекает злее: устаревает не число на экране, а замыкание внутри обработчика — то самое замыкание из части I. Дважды нажимаем «плюс один», потом «показать». Что скажет обработчик и что будет на экране?
На экране двойка, в обработчике ноль. Функция создана при первой отрисовке и замкнула тогдашний count; пустой массив зависимостей запретил создавать её заново. Такую ошибку не видно ни в разметке, ни в тестах на рендер — она всплывает в проде, когда кнопка отправляет на сервер устаревшее значение.
Правило exhaustive-deps в eslint ругалось бы на оба примера. Но правило только подсказывает, а решает человек — и строчка eslint-disable-next-line стоит ровно одно движение пальцем.
Кеш компилятора не ограничивается числами и функциями: в ячейку попадает и результат JSX-выражения. Это важнее, чем кажется. Элемент — обычный объект-описание; если при новой отрисовке родитель вернул тот же самый объект, React сравнивает ссылки и внутрь компонента не заходит вовсе.
Проверим руками, без всякого компилятора: Heavy печатает строку при каждой отрисовке, а его элемент положен в ячейку. Клик меняет состояние Panel. Сколько строк добавит один клик и какие именно?
Одну — «рендер Panel». Heavy не перерисовался, хотя никакого memo вокруг него нет и родитель отработал заново. React дошёл до места, где стоит элемент, увидел тот же объект, что и в прошлый раз, и остановился: описание не менялось — значит, и поддерево менять не из чего.
Отсюда следствие, ради которого всё и затевалось. memo сравнивает пропсы уже после того, как родитель их пересоздал; компилятор просто не пересоздаёт — ни объект пропсов, ни элемент. Поэтому под компилятором memo в большинстве мест лишний: он остаётся полезен разве что на границе с кодом, который компилятор не обработал, — со сторонним пакетом или с файлом, который он пропустил.
| Кто | Что кеширует | Откуда берутся зависимости |
|---|---|---|
useMemo, useCallback | одно значение или одну функцию | из массива, который пишете вы |
memo | результат компонента, если пропсы совпали | сравнение пропсов по ссылке при каждой отрисовке родителя |
| React Compiler | все значения компонента, включая элементы разметки | выводятся из кода на сборке |
Приём старше компилятора, и в этой главе он попадается третий раз: элемент создаётся не там, где меняется состояние, а выше, и приходит пропсом children — объект-описание переживает перерисовку сам собой.
Вывод тот же, что в двух прошлых статьях: App заявку не подавал, элемент <Heavy /> тот же объект, внутрь React не заходит. Разница — в цене: приём требует перестраивать дерево под оптимизацию, а компилятор даёт то же самое, не трогая структуру.
Кеш безопасен ровно настолько, насколько предсказуем код. Компилятор исходит из того, что компонент — чистая функция: те же пропсы и то же состояние дают тот же результат, а во время отрисовки наружу ничего не течёт. Требование не новое — это правила React, о которые вы уже спотыкались в статьях про рендер и про эффекты. Новое здесь то, что за нарушение платят сразу.
Компонент Stamp считает свои вызовы во внешней переменной — обычная «безобидная» отладочная строчка. Его элемент положен в ячейку, как это сделал бы компилятор. Что окажется на экране после двух кликов?
| Правило | Чего нельзя в теле компонента | Куда это переносится |
|---|---|---|
| Отрисовка чиста | менять переменные снаружи, править DOM, отправлять запросы | обработчик события или эффект |
| Пропсы и состояние не меняют | items.push(x), props.user.name = "..." | новый массив или объект (статья «Объекты и массивы в состоянии») |
| Ссылки не читают и не пишут при отрисовке | ref.current в теле функции | обработчик или эффект (статья про useRef) |
| Хуки — только на верхнем уровне | вызов хука в условии, цикле или после раннего return | разделить компонент (статья «Правила хуков и почему они такие») |
Что будет, если правило всё-таки нарушено? Компилятор чаще всего это видит и такой компонент просто не трогает: оставляет как был, сборка проходит, приложение работает — только ускорения не будет. В Next.js это поведение по умолчанию, за него отвечает настройка panicThreshold: "none" — «пропускать то, что не компилируется».
Но видит он не всё. Мутацию, спрятанную в функции из соседнего модуля, статический анализ не поймает — и тогда кеш вступит в силу поверх кода, который на него не рассчитан. Поэтому правила остаются вашей ответственностью, а не заботой сборки. Смотреть за ними глазами не нужно: свежие версии eslint-plugin-react-hooks включают правила самого компилятора, и редактор подчёркивает ровно те места, из-за которых компонент будет пропущен.
Строгий режим React не случайно вызывает компоненты дважды: он ловит ровно эти нарушения заранее — до того, как их найдёт компилятор.
Компилятор выпущен плагином Babel, пакетом babel-plugin-react-compiler, но работает так уже не везде. В Next.js его не приходится прописывать руками: пакет ставится в зависимости разработки, а в конфигурации поднимается флаг верхнего уровня.
В Vite флага reactCompiler нет, а @vitejs/plugin-react Babel больше не везёт: для компилятора его либо подключают отдельно, либо берут экспериментальный порт на Rust. Путь через Babel — тот, что в документации React: в зависимости разработки ставят @rolldown/plugin-babel, @babel/core и babel-plugin-react-compiler, а в конфиг добавляют готовый пресет.
В собранном App тот же кеш, что в начале статьи: $ на 24 ячейки заводит функция c из рантайма компилятора, а memo_cache_sentinel — знак пустой ячейки, как наш EMPTY. Второй путь обходится без Babel. Порт компилятора на Rust — пакет oxc-transform-react; ставят его той версии, которую допускает peer-диапазон plugin-react, — самая свежая в него может не попасть. Включается порт одной опцией.
Строки те же, что у сборки через Babel, хотя Babel в ней не участвовал, а без compiler: true у той же функции нет ни ячеек, ни проверок. В Next порт на Rust работает внутри Turbopack и включается второй настройкой рядом с первой — пакет Babel-плагина тогда не нужен.
Лог честно предупредил об эксперименте, а Price в куске собран с кешем: o=(0,r.c)(8) — восемь ячеек, o[0]!==_ — проверка входа. Без второй настройки тот же флаг ищет Babel-плагин, а раз пакета нет, сборка падает. Оба порта пока экспериментальные, а настройка Next работает только под Turbopack. Какой путь где действует и с какой версии, сведено в таблицу с датой в статье про компилятор файла.
Рантайм-часть в React 19 уже внутри пакета react; для проектов на 17 и 18 ставят отдельный react-compiler-runtime. По умолчанию компилятор работает в режиме infer: сам решает, что в файле компонент и хук, и обрабатывает всё, что нашёл.
Один компонент можно вывести из-под компилятора директивой — строкой в начале тела функции, как "use strict" из части I. Это временная мера для места, которое пока не привели в порядок.
Существующие useMemo и useCallback компилятор не выбрасывает: он старается сохранить обещанное ими, а если сохранить не может — не трогает такой компонент целиком. Поэтому включать компилятор можно, не устраивая чистку: сначала флаг, потом, попадая в файл по другому делу, снимаете обёртки. Снимать стоит — чем меньше рукописных массивов, тем меньше мест, где значение застынет.
| Было в коде | Под компилятором |
|---|---|
useMemo вокруг вычисления | удалить: то же самое сделает компилятор, и зависимость не забудет |
useCallback ради стабильной ссылки | удалить |
memo вокруг компонента | обычно не нужен; оставить на границе с необработанным кодом |
useRef, чтобы значение пережило отрисовку | оставить: это память, а не мемоизация |
useMemo, без которого компонент врёт | чинить логику: правильность не должна зависеть от кеша |
Кеш не бесплатен: сборка идёт дольше, а в готовый файл добавляются те самые ячейки и проверки. Для обычного приложения обмен выгодный, но «включили — и везде стало быстрее» не обещает никто: компонент, который тормозит из-за десяти тысяч узлов в разметке, от кеша не оживёт.
Последняя строка таблицы — правило, которое переживает любой компилятор. Мемоизация не входит в договор React: и useMemo, и компилятор имеют право пересчитать значение когда угодно. Если компонент ломается без кеша, он сломан и с кешем — просто реже. Кеш про скорость, а не про поведение; что именно тормозит на самом деле, разбиралось в первой статье этой главы.
- React Compiler — шаг сборки: он читает компоненты и дописывает кеш из ячеек, который вы раньше писали руками через
useMemoиuseCallback. - Зависимости он выводит из кода, поэтому забытая зависимость и застывшее значение перестают быть возможными.
- В ячейки попадают и элементы JSX: тот же объект-описание — React не заходит в поддерево, и
memoнужен куда реже. - Плата — правила React: чистая отрисовка, никакой мутации пропсов и состояния, ссылки и побочные действия вне тела компонента.
- Нарушение компилятор обычно замечает и просто пропускает компонент: сборка не падает, но и ускорения не будет.
- Правильность не должна зависеть от мемоизации — ни ручной, ни автоматической: кеш про скорость, а не про поведение.
1/4Внутри компонента написано const sum = React.useMemo(() => price * count, [price]). Пропс price остаётся равным 100, а count меняется с 1 на 2. Что покажет компонент?
2/4Компилятор встретил компонент, который во время отрисовки увеличивает переменную из внешнего модуля. Что произойдёт при сборке?
3/4Что, кроме чисел, строк и функций, компилятор кладёт в ячейки кеша?
4/4В проекте включили компилятор. Нужно ли сразу удалить все useMemo и useCallback, чтобы он заработал?
Зависимость, которая устарела
Список должен отфильтроваться по запросу, но после клика печатает всё подряд. Найдите причину и почините двумя способами — с массивом зависимостей и без него. Какой из двух переживёт следующую правку кода?
Вычисление читает items и query, а в массиве перечислен только items. Пока пропс items тот же объект, значение берётся из кеша — фильтрация с новым запросом не выполняется.
Первый способ — дописать зависимость:
Второй — убрать мемоизацию совсем:
Фильтрация трёх строк дешевле, чем сравнение зависимостей вокруг неё; useMemo здесь никогда не окупался. Переживёт правку второй способ: у него нет списка, который можно забыть обновить, добавив в формулу ещё один пропс. Под компилятором так и пишут — а кеш, если он нужен, появится на сборке.
И отдельно про строку const ITEMS над компонентами. Если бы массив создавался прямо в разметке — items={["кот", "код", "кодер"]}, — в List при каждой отрисовке приходил бы новый объект, зависимость items менялась бы всегда, и useMemo честно пересчитывал бы всё заново. Ошибка бы спряталась, а мемоизация перестала бы работать вовсе — заметить такое можно разве что с секундомером. Ручная мемоизация чувствительна к подобным мелочам; зависимости, выведенные из кода, — нет.
Почему компилятор пропустит этот компонент
В Row два нарушения правил React — из-за них компилятор оставит компонент без оптимизации. Назовите оба и перепишите компонент так, чтобы нумерация и текст пришли снаружи, а тело функции стало чистым.
Первое нарушение — lastNumber += 1: отрисовка меняет переменную вне компонента, и результат зависит от того, сколько раз функцию вызвали. Второе — seen.current += 1: запись в ссылку во время отрисовки, та же побочность, только спрятанная в useRef.
Оба счётчика не нужны: номер строки — это данные, и они приходят от родителя.
Теперь Row — чистая функция от пропсов: компилятор его оптимизирует, а число вызовов перестаёт быть видимым снаружи. Считать отрисовки для отладки по-прежнему можно — но console.log, а не записью в состояние программы.
Убрать ручную мемоизацию
Проект перевели на компилятор. Перепишите Card так, как его написали бы сегодня, и скажите, что изменится в поведении.
Уходят все три обёртки — и вместе с ними три массива зависимостей:
В поведении не меняется ничего: компилятор заведёт ячейки под title, style, handlePick и под сам элемент кнопки — с теми же зависимостями, только выведенными из кода. Разница в другом: строка [onPick, user.name] больше не может разойтись с телом функции при следующей правке.
Пока компилятор не включён, такой код тоже верен — просто объект стиля и обработчик создаются заново при каждой отрисовке. Это и есть нормальная цена работы React, о которой шла речь в первой статье главы.