React Compiler: когда useMemo можно убрать, а когда компилятор тихо отказывается оптимизировать
Включил компилятор в проде и пошёл удалять свои useMemo. Где это сработало, где он тихо пропустил компонент и почему селекторы и кэши остаются на мне.
Прошлой весной я включил React Compiler в монорепе файлового менеджера Облака Mail.ru. Он был ещё до стабильной версии: Babel-плагин, раскатка через gating-флаг, чтобы включать по пакетам, а не разом. Первый вопрос на ревью оказался предсказуемым. Раз компилятор сам всё мемоизирует, удаляем ручные useMemo и useCallback? Я открыл виртуализированный список файлов, тот, который мы в своё время выводили на 60fps руками, и начал снимать memo с FileRow и useCallback с onSelect. Почти отправил это в ревью. Потом посмотрел, что компилятор вообще сделал с этим компонентом, и оказалось, что ничего. Он его пропустил.
Это короткий ответ на вопрос из заголовка. Удалять useMemo можно во многих местах, но там, где мемоизация решала больше всего, компилятор чаще всего и отступает. Прошёл год: компилятор стабилен и стоит почти во всех наших пакетах. Вопрос с того ревью никуда не делся. Дальше разбираю, как компилятор принимает решение, что я в итоге удалил, что оставил и на какие правила всё это опирается.
Что он на самом деле делает
React Compiler разбирает код на этапе сборки. Он строит граф потока данных внутри компонента, понимает, какое значение от чего зависит, и вставляет мемоизацию там, где может доказать, что вход не менялся между рендерами. На выходе это примерно то, что вы написали бы руками через React.memo, useMemo и useCallback, только расставленное компилятором и спрятанное в кэш: компилятор заводит на компонент массив слотов (раньше это был хук useMemoCache, в 1.0 это c() из react/compiler-runtime) и на каждом рендере сверяет входы со слотами, переиспользуя прошлый результат, когда вход тот же.
Зависимости он выводит из того, что видит в теле функции. Не из массива, который вы дописали, а из самого кода: какие пропсы читает выражение, какие переменные в него входят. Для чистого компонента это надёжнее ручного списка зависимостей, потому что компилятор не забывает зависимость и не дописывает лишнюю. Вся механика держится на одном условии: тело компонента должно честно отражать, от чего оно зависит. Как только в рендере появляется то, чего компилятор не видит или не может счесть стабильным, он не угадывает. Он отказывается оптимизировать компонент целиком.
До и после на том же списке
В прошлом тексте про виртуализацию я разбирал, из чего складывается гладкий скролл папки на двенадцать тысяч файлов. Структурную часть делает windowing. Остаток пути до 60fps добирает render-работа вокруг: memo на строку, стабильный onSelect, мемоизированный селектор. По этим трём пунктам проще всего показать, где проходит граница компилятора.
memo на FileRow и useCallback на onSelect он забирает. Это ровно та работа, ради которой он сделан: строка перестаёт перерендериваться, пока её данные не изменились, обработчик держит ссылку между рендерами. Я снял ручные обёртки, прогнал тот же сценарий (6× троттлинг CPU, заскриптованный скролл по самой тяжёлой папке) и сравнил коммит на кадре скролла с тем, что давала ручная версия. Разница в пределах шума профайлера: коммит лёг в те же ~3 мс, что я приводил в прошлой статье для ручной версии, против ~40 без мемоизации. На этом участке компилятор действительно заменяет ручную работу.
// было: обёртки расставлены руками
const FileRow = memo(function FileRow({ file, onSelect }: FileRowProps) {
return <div className="row" onClick={() => onSelect(file.id)}>{file.name}</div>;
});
// стало: компилятор мемоизирует и строку, и onSelect сам
function FileRow({ file, onSelect }: FileRowProps) {
return <div className="row" onClick={() => onSelect(file.id)}>{file.name}</div>;
}
// const onSelect = useCallback(...) тоже больше не нужен — ссылка стабильна и без него
Дальше начинается то, чего он не трогает.
Селектор живёт вне React. Список читает производный срез стора: файлы текущей папки, отсортированные и отфильтрованные. Мемоизация этого среза (createSelector из RTK поверх нормализованного стора) живёт в Redux, в коде стора. Компилятор её не видит и видеть не должен. Если селектор отдаёт новый массив на каждый вызов, список получает новый проп на каждый рендер, и автомемоизация компонента честно его пересчитывает, потому что вход действительно изменился. Строки внутри при этом могут устоять, у них пропсы стабильные, но контейнер пересоберётся. Компилятор не отменяет reselect. Он работает поверх него и точно так же страдает, если про него забыли.
Мутабельный кэш он принимает за стабильный вход, и это тоньше, чем кажется. В той статье был капкан: офсеты строк считаются из кэша замеренных высот, кэш мутабельный, замер дописывается в Map по id файла, не трогая массив ids. Ручной useMemo с зависимостью [ids] этого не видел и протухал, поэтому в зависимости приходилось заводить счётчик версии. Компилятор выводит зависимости из того же тела функции и приходит ровно к тому же [ids]. Модульную Map он в зависимости не берёт, считает её стабильной, и мемоизация протухает на первом же позднем замере, ровно как ручная. Это не bail-out, и путать их не стоит: компилятор отказывается компилировать, только когда видит нарушение, которое ловит статически — чтение рефа в рендере, мутацию значения после того, как оно ушло в JSX. Чтение из внешней Map под это не попадает. Закрывать это всё равно мне, средствами из той же статьи: версия в зависимостях или вынос мутации из рендера.
Виртуализация лежит совсем вне его поля. Число DOM-нод, overscan, scroll anchoring при позднем замере, ResizeObserver на строку, доступность с aria-setsize на полный набор — всё это компилятор не трогает. Он работает только в том блоке, который я в прошлой статье называл render-работой вокруг окна. Структурную половину по-прежнему пишу руками; до неё компилятор просто не дотягивается.
На что он опирается и где отступает
Вся автомемоизация держится на «правилах React», и компилятор не столько добавляет требования, сколько начинает наказывать за старые. Рендер должен быть чистым: одинаковые входы дают одинаковый выход, без побочных эффектов по дороге. Пропсы и стейт не мутируются. Значение, которое вы уже отдали в JSX или в хук, дальше не меняется на месте. Рефы не читаются и не пишутся во время рендера. Хуки вызываются на верхнем уровне. Раньше нарушение этих правил давало плавающий баг раз в месяц. Теперь оно ещё и выключает оптимизацию на этом компоненте.
В рантайме пропущенный компонент работает как обычный React-компонент, просто без мемоизации, никакой ошибки нет. Сигналов о пропуске на самом деле три. Вывод сборки и ESLint-правило: правила компилятора теперь часть eslint-plugin-react-hooks (отдельный eslint-plugin-react-compiler можно удалять), и в пресете recommended они подсвечивают и нарушения чистоты, и компоненты, которые компилятор трогать отказался. И React DevTools: оптимизированные компоненты он помечает бейджем «Memo ✨», так что отсутствие бейджа там, где он ожидается, и есть пер-компонентный признак пропуска.
У бейджа есть нюанс. Он говорит только, что компилятор компонент обработал, а не что мемоизация держится в рантайме. Компонент с бейджем всё равно перерисуется, если родитель сверху раздаёт нестабильные пропсы; это уже вопрос к профайлеру. Линтер при этом я считаю обязательным к включению: без него «компилятор всё мемоизирует» остаётся догадкой.
Выглядит пропуск примерно так. Берёшь компонент, который читает реф в рендере, и три поверхности говорят об этом разом:
function FileGrid({ ids }: FileGridProps) {
const rowRef = useRef<HTMLDivElement>(null);
const rowH = rowRef.current?.offsetHeight ?? 48; // чтение рефа в рендере
// react-hooks/refs ругается на эту строку, компилятор пропускает FileGrid целиком,
// и в DevTools у FileGrid нет бейджа «Memo ✨», хотя у соседних строк он есть.
return <FileGridView ref={rowRef} rowHeight={rowH} ids={ids} />;
}
Отдельная ловушка: частично мигрированный компонент. Компилятор сверяет вашу ручную мемоизацию со своей выведенной, и если они расходятся, он отказывается оптимизировать компонент. То есть забытый useMemo с неправильными зависимостями не просто бесполезен, он может выключить компиляцию всего компонента. Поэтому либо удаляйте ручную мемоизацию и отдавайте её компилятору, либо держите её строго корректной; промежуточное состояние, когда забытый useMemo расходится с выводом, выключает компиляцию ровно там, где вы её ждали.
Так удалять useMemo или нет
В большинстве мест да. Но решение «удалить» я перестал принимать на глаз и привязал к двум проверкам.
Первая: компонент реально компилируется. Снять ручной memo с компонента, который компилятор пропустил, значит вернуть тот самый баг производительности, против которого memo и стоял, причём на горячем пути, где это больнее всего. Поэтому перед удалением я сверяюсь с выводом компилятора или с ESLint. Чутью тут доверять нельзя. Случай с FileRow из начала ровно про это: я чуть не снял мемоизацию с компонента, который не компилировался, и не заметил бы этого до профайлера.
Вторая: useMemo держал стабильную ссылку, на которой завязан контракт. Иногда значение мемоизируют ради идентичности: от ссылки зависит зависимость useEffect, ключ, значение, уходящее в код, который компилятор не компилирует (сторонняя библиотека, контекст, граница без компилятора). Компилятор стабилизирует ссылку для рендера, но это другое обещание. Такие useMemo я проверяю по отдельности и не сметаю вместе с мемоизацией ради скорости.
Лазейки на этот случай есть. Директива 'use no memo' в начале функции выключает компиляцию для конкретного компонента, это рабочий способ обойти библиотеку, несовместимую с автомемоизацией, или временно потушить подозрительный компонент при отладке. Есть обратный, аннотационный режим, когда компилируются только функции с 'use memo'. И свою ручную мемоизацию компилятор не срывает: если useMemo корректен и совпадает с выводом, его можно оставить, ничего не сломается.
Что изменилось в повседневном коде
Рефлекс писать useCallback и React.memo на всякий случай я снял. Новый код в скомпилированных пакетах их почти не содержит и читается заметно чище. ESLint-правило теперь работает на два фронта: следит за правилами React и тут же говорит, что не скомпилируется. Оптимизация ради скорости по сути переехала в линтер чистоты.
Ревью сместилось. Раньше в виртуализированных и тяжёлых местах я искал глазами, где забыли memo или где в мемоизированный компонент улетает свежая ссылка. Теперь я ищу другое: нарушения чистоты и компоненты, которые тихо не скомпилировались. Менторство туда же. Вопрос «где здесь надо мемоизировать» я задаю сильно реже, чем «почему этот рендер обязан быть чистым». Джунам так, кажется, полезнее: правило чистоты переносится на любой код, а ручная расстановка memo была навыком про конкретный инструмент.
Что меня всё ещё смущает
Компилятор убрал сигнал. Раньше useMemo в диффе что-то значил: кто-то это профилировал, место горячее, к нему стоит присмотреться. Мемоизация была меткой на карте. Теперь она равномерная и невидимая, и код больше не показывает свои горячие пути сам. Для ревьюера и ментора это потеря, и хорошей замены метке я пока не нашёл. Профайлер показывает, что тормозит сейчас, но не то, что когда-то стоило труда и держится на этом труде.
Второе: слепая зона на холодных leaf-компонентах. В прошлой статье у меня был тезис про карго-культ. Мемоизировать leaf-компонент, который перерендеривается дважды за сессию, дороже, чем оставить его как есть, и я такие места сознательно держал голыми. Компилятор мемоизирует всё подряд, включая их. Команда React говорит, что накладные расходы пренебрежимо малы, и в моих замерах на файловом менеджере они тонули в шуме. Но честно доказать, что это бесплатно на всех очень холодных и очень многочисленных leaf-компонентах, я не могу, а возможности выбирать у меня больше нет.
И отладка. Когда мемоизировано всё, а руками не написано ничего, ссылочный или stale-баг труднее локализовать: в исходнике нет строчки, на которую можно показать. 'use no memo' на подозрительном компоненте стал у меня обычным шагом при отладке, чтобы понять, кто виноват, компилятор или логика. Помогает, но необходимость такого шага мне не нравится: часть цены за удобство переехала в отладку.
Так что useMemo не умер. Я просто перестал писать его по умолчанию. Остаётся он в двух местах: компонент, который компилятор пропускает, и значение, чья стабильная ссылка кому-то нужна как контракт. Каждый раз это теперь решение, и я сверяюсь с выводом компилятора.