На протяжении десятилетия экосистема React полагалась на ручную оптимизацию рендеринга с помощью триады React.memo, useMemo и useCallback. В промышленной разработке этот подход породил системный кризис производительности и поддержки:
Object.is) в тысячах хуков часто превышают выгоду от предотвращения повторного рендера легковесных узлов.React Compiler (первоначально носивший кодовое название Project Forget) радикально меняет архитектуру выполнения кода. Это оптимизирующий опережающий (AOT-компилятор), который глубоко анализирует семантику JavaScript и Rules of React.
Компилятор трансформирует компоненты и хуки, автоматически встраивая мемоизацию на ультрагранулярном уровне — от простых констант и функций-обработчиков до сложных объектов и самих возвращаемых виртуальных JSX-деревьев.
В React 19 ручное использование useMemo и useCallback признается устаревшей практикой: компилятор выполняет оптимизацию на уровне графа промежуточного представления точнее, эффективнее и без риска человеческой ошибки.
В основе React Compiler лежит классическая архитектура оптимизирующих компиляторов (сходная с LLVM и V8 TurboFan), адаптированная под реактивную парадигму React.
[ Исходный JSX/TSX Код ]
│
▼ (Babel / SWC Parser)
[ Синтаксическое дерево (AST) ]
│
▼ (Построение Control Flow Graph)
[ HIR (High-level Intermediate Representation) ]
│
▼ (Приведение к форме SSA)
[ SSA Form: однозначные переменные v0, v1, v2 ]
│
├───► [ Liveness Analysis (Время жизни переменных) ]
├───► [ Escape Analysis (Анализ утечек ссылок) ]
└───► [ Infer Mutation Ranges (Анализ мутаций) ]
│
▼ (Вычисление границ реактивности)
[ Выделение Reactive Scopes (Реактивных скоупов) ]
│
▼ (Кодогенерация с runtime-кэшем c())
[ Оптимизированный JS: $ = c(slotCount) ]Компилятор деконструирует AST в HIR (High-level Intermediate Representation) — представление на базе ориентированного графа потока управления (Control Flow Graph), состоящего из базовых блоков инструкций. Затем граф переводится в SSA (Static Single Assignment), где каждая переменная получает значение ровно один раз.
Благодаря SSA и анализу времени жизни компилятор четко разграничивает:
На основе этих границ формируются Reactive Scopes. В сгенерированном JavaScript-коде каждый скоуп защищен обращением к ультрабыстрому плоскому массиву кэша — рантайм-слотам useMemoCache ($ / c).
React Compiler мемоизирует не только вычисления, но и сами виртуальные объекты React Element ($[i] = jsx(...)). Если входные пропсы не изменились, компонент возвращает ровно ту же самую ссылку на JSX-элемент. Движок Fiber в фазе beginWork видит равенство ссылок oldProps === newProps и за O(1) полностью пропускает рендеринг всего вложенного поддерева!
Исходный TSX преобразуется в синтаксическое дерево. Линтер проверяет соблюдение правил чистоты функций и неизменяемости пропсов.
Код разбивается на базовые блоки ветвления (if/else, switch, циклы) с единым входом и выходом, формируя Control Flow Graph.
Переменные изолируются в уникальные регистры (v0, v1, v2). В точках слияния потоков вычисляются Phi-функции для точного отслеживания значений.
Выполняется Escape Analysis и Liveness Analysis. Смежные инструкции группируются в реактивные скоупы с фиксацией зависимостей.
Исходное тело функции заменяется на прямое обращение к плоскому массиву слотов памяти $[i], исключая накладные расходы хуков.
Почему приведение синтаксического дерева к SSA-форме (Static Single Assignment) является критически важным шагом для компилятора React?
Анализ исходного TypeScript/JSX. Проверка инвариантов чистоты: запрет мутации пропсов, запрет вызова хуков в циклах, чистота рендера.
Преобразование вложенного синтаксического дерева в плоский список базовых блоков. Присваивание уникальных идентификаторов регистров (v0, v1, v2).
Определение интервалов мутабельности каждого объекта (InferMutableRanges). Проверка, мутирует ли функция свой аргумент или создает изолированную копию.
Группировка взаимосвязанных инструкций в скоупы с вычислением слотов памяти. Замена исходного тела функции на конструкцию проверки кэша $ = c(N).
Рассмотрим компонент аналитической карточки до и после обработки компилятором.
Исходный код разработчика (без единого хука мемоизации):
interface MetricProps {
label: string;
value: number;
threshold: number;
onAlert: (msg: string) => void;
}
export function MetricCard({ label, value, threshold, onAlert }: MetricProps) {
const isCritical = value > threshold;
const statusMessage = `Показатель ${label}: ${isCritical ? 'Критический' : 'В норме'}`;
const handleNotify = () => {
if (isCritical) {
onAlert(`Внимание: ${statusMessage}`);
}
};
return (
<div className={`metric-card ${isCritical ? 'critical' : 'normal'}`}>
<h3>{label}</h3>
<p>{value}</p>
<button onClick={handleNotify}>Проверить статус</button>
</div>
);
}А вот во что превращает этот компонент React Compiler на выходе сборщика (декомпилированное представление HIR-кодогенератора):
import { c as _c } from "react/compiler-runtime";
export function MetricCard(props: MetricProps) {
// Компилятор аллоцирует фиксированный блок памяти на 11 слотов для инстанса Fiber
const $ = _c(11);
const { label, value, threshold, onAlert } = props;
const isCritical = value > threshold;
// Реактивный скоуп 1: Вычисление statusMessage
let statusMessage: string;
if ($[0] !== isCritical || $[1] !== label) {
statusMessage = `Показатель ${label}: ${isCritical ? 'Критический' : 'В норме'}`;
$[0] = isCritical;
$[1] = label;
$[2] = statusMessage;
} else {
statusMessage = $[2];
}
// Реактивный скоуп 2: Замыкание обработчика handleNotify
let handleNotify: () => void;
if ($[3] !== isCritical || $[4] !== onAlert || $[5] !== statusMessage) {
handleNotify = () => {
if (isCritical) {
onAlert(`Внимание: ${statusMessage}`);
}
};
$[3] = isCritical;
$[4] = onAlert;
$[5] = statusMessage;
$[6] = handleNotify;
} else {
handleNotify = $[6];
}
// Реактивный скоуп 3: Мемоизация самого дерева JSX!
let jsxContent: JSX.Element;
if (
$[7] !== handleNotify ||
$[8] !== isCritical ||
$[9] !== label ||
$[10] !== value
) {
jsxContent = (
<div className={`metric-card ${isCritical ? 'critical' : 'normal'}`}>
<h3>{label}</h3>
<p>{value}</p>
<button onClick={handleNotify}>Проверить статус</button>
</div>
);
$[7] = handleNotify;
$[8] = isCritical;
$[9] = label;
$[10] = value;
} else {
jsxContent = $[7];
}
return jsxContent;
}Обратите внимание на сохранение ссылки на дерево JSX в ячейке $[7]. Если входящие пропсы label, value, threshold и колбэк onAlert не изменились, компонент возвращает ту же самую ссылку на React Element. В результате движок Fiber при сравнении oldProps === newProps за O(1) пропускает обход всех дочерних элементов (div, h3, p, button).
"use memo" и "use no memo"#В масштабных кодовых базах компилятор может применяться поэтапно или исключать модули с императивными интеграциями:
// Полное отключение компилятора для конкретного компонента с императивными хаками DOM
export function HeavyWebGLCanvas({ contextId }: { contextId: string }) {
"use no memo";
// Этот компонент не будет трансформирован в HIR-кэш слотов,
// что полезно при отладке сторонних библиотек прямой мутации буфера
return <canvas id={contextId} />;
}
// Принудительное включение в режиме opt-in
export function OptimizedWidget({ data }: { data: string[] }) {
"use memo";
return <ul>{data.map(item => <li key={item}>{item}</li>)}</ul>;
}Что произойдет, если в теле компонента разработчик попытается отсортировать переданный через пропсы массив на месте через props.items.sort()?
Для активации компилятора в современном стеке (Vite + React 19 + TypeScript) требуется связка плагина трансформации и линтера соответствия правилам React:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const ReactCompilerConfig = {
target: '19', // Ориентация на рантайм React 19
sources: (filename: string): boolean => {
// Компилируем только файлы исходников компонентов, исключая тесты и node_modules
return filename.includes('src/') && !filename.includes('.test.') && !filename.includes('.spec.');
},
panicThreshold: 'all_errors', // В CI выбрасывает ошибку при невозможности оптимизации
};
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
['babel-plugin-react-compiler', ReactCompilerConfig],
],
},
}),
],
resolve: {
alias: {
'@': '/src',
},
},
});React Compiler опирается на контракт чистых функций (Purity of Render). Попытка отсортировать переданный массив пропсов на месте (props.items.sort()) или модифицировать поля переданного объекта ломает SSA-анализ диапазонов мутации (InferMutableRanges). Компилятор либо аварийно отключит оптимизацию данного компонента (de-opt), либо сгенерирует неконсистентный кэш.
// ❌ ПЛОХО: мутация массива пропсов 'items' на месте ломает SSA форму
export function ProductList({ items }: { items: string[] }) {
const sorted = items.sort(); // Инструмент in-place мутации
return <div>{sorted.join(', ')}</div>;
}
// ✅ ПРАВИЛЬНО: использование иммутабельного метода без мутации источника
export function ProductList({ items }: { items: string[] }) {
const sorted = items.toSorted(); // Нативный метод ES2023 без мутации
return <div>{sorted.join(', ')}</div>;
}Рефы (useRef) предназначены исключительно для хранения императивных значений между рендерами или ссылок на DOM. Чтение ref.current непосредственно в фазе вычисления JSX превращает рендер в недетерминированный процесс: компилятор не может отследить изменение значения ref.current, так как мутация рефа не инициирует ре-рендер и не входит в цепочку реактивности.
// ❌ ПЛОХО: чтение мутабельного рефа в логике рендера ломает кэш
export function Timer({ initialSeconds }: { initialSeconds: number }) {
const countRef = useRef(initialSeconds);
if (countRef.current > 0) {
countRef.current -= 1; // Сайд-эффект в рендере
}
return <span>{countRef.current}</span>;
}
// ✅ ПРАВИЛЬНО: использование явного стейта или эффекта
export function Timer({ initialSeconds }: { initialSeconds: number }) {
const [count, setCount] = useState(initialSeconds);
useEffect(() => {
const timer = setInterval(() => setCount(c => Math.max(0, c - 1)), 1000);
return () => clearInterval(timer);
}, []);
return <span>{count}</span>;
}Многие разработчики привыкли отключать правило react-hooks/exhaustive-deps комментариями // eslint-disable-next-line. При включении React Compiler такие отключения гарантированно приводят к непредсказуемому поведению: компилятор доверяет объявленным границам видимости и построит некорректный HIR-граф, что приведет к демонстрации устаревших данных пользователю.
// ❌ ПЛОХО: подавление линтера маскирует нарушение инварианта
useEffect(() => {
trackUserVisit(userId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ✅ ПРАВИЛЬНО: реорганизация кода через событийно-ориентированные функции
const handleTrack = useEffectEvent((id: string) => {
trackUserVisit(id);
});
useEffect(() => {
handleTrack(userId);
}, [userId]);Попытка оставить старые useCallback и useMemo с искусственно урезанными массивами зависимостей в надежде "перехитрить" компилятор приводит к де-оптимизации. Компилятор видит вызов useMemo как консервативный барьер и не может произвести глубокую сквозную оптимизацию внутренних выражений.