一、从"React Forget"说起
React Compiler 最早由 Meta 工程师 黄玄(Xuan Huang) 和 Joe Savona 主导,2021 年立项时的代号叫 "React Forget"。
这个名字取得很妙------"让开发者忘掉手动 memo 这件事"。
时间线:
- 2021:立项,Meta 内部实验
- 2023 React Conf:首次公开演示
- 2024 May:正式改名 React Compiler,开源 Beta
- 2024 Oct:进入 Release Candidate
- 2025:Instagram、Quest Store 等 Meta 全线产品上生产
- 2026 现在:React 19 生态推荐搭配使用
要理解它为什么值得单独发一个编译器,得先看它解决的痛。
二、痛点:手写 memo 三宗罪
React 的默认渲染模型是"父 render → 子全部 render"。为了避免不必要的重渲染,开发者被迫手写:
jsx
const value = useMemo(() => computeExpensive(a, b), [a, b]);
const handler = useCallback(() => doSomething(x), [x]);
const MemoChild = memo(Child);
这套 API 有三大罪状:
1. 心智负担重
每写一个组件都要想:这里要不要 memo?依赖数组对不对?props 稳定吗?很累。
2. 容易写错
依赖数组漏一个变量 → stale closure(读到过期的 state)。这种 bug 隐蔽、难查、生产环境才复现。
3. 过度使用反而更慢
useMemo 本身有开销:
- 每次 render 都要读缓存对象(内存)
- 比较依赖数组(CPU)
- 更新缓存槽(内存写)
当计算本身比"比较 + 缓存"还便宜时,memo 就是纯亏。 比如:
jsx
const sum = useMemo(() => a + b, [a, b]); // 负优化
a + b 一个 CPU 周期就算完,比较 [a, b] 还得读两次数组、比较两次值------给一个 O(1) 的加法套了一个更贵的判断,纯赔本。
三、React Compiler 的核心思路
一句话:开发者写"看起来每次都重算"的代码,编译器在构建时自动帮你插入等价的 memoization。
markdown
┌─────────────────────┐
你写的 JSX ────► │ React Compiler │ ────► 优化后的 JS
(无 memo) │ (Babel plugin) │ (自动缓存)
└─────────────────────┘
它本质是一个 Babel 插件 ,在构建时对函数组件和自定义 Hook 做静态分析 和代码转换。
编译示例
源码:
jsx
function Profile({ user }) {
const fullName = user.firstName + ' ' + user.lastName;
const handleClick = () => alert(fullName);
return (
<div>
<Avatar url={user.avatar} />
<button onClick={handleClick}>{fullName}</button>
</div>
);
}
编译后(伪代码):
jsx
function Profile({ user }) {
const $ = useMemoCache(6); // 分配一个缓存数组
let fullName;
if ($[0] !== user.firstName || $[1] !== user.lastName) {
fullName = user.firstName + ' ' + user.lastName;
$[0] = user.firstName;
$[1] = user.lastName;
$[2] = fullName;
} else {
fullName = $[2];
}
let handleClick;
if ($[3] !== fullName) {
handleClick = () => alert(fullName);
$[3] = fullName;
$[4] = handleClick;
} else {
handleClick = $[4];
}
// 连 JSX 本身也被缓存!
let jsx;
if ($[5] !== user.avatar /* 其他依赖 */) {
jsx = (
<div>
<Avatar url={user.avatar} />
<button onClick={handleClick}>{fullName}</button>
</div>
);
$[5] = /* ... */;
} else {
jsx = /* cached */;
}
return jsx;
}
关键点:JSX 表达式本身会被缓存。 这是手写 memo 几乎不会做的事------你不会给整个 return 语句包一层 useMemo,但编译器会。
四、内部原理:HIR 与数据流分析
React Compiler 内部是一个完整的编译器管线:
css
JS/JSX 源码
▼
AST(Babel 语法树)
▼
HIR(High-level IR,React 团队自研的中间表示)
▼
控制流分析(Control Flow Graph)
▼
可变性分析(Mutability Inference)------ 哪些值会被修改
▼
依赖分析(Dependency Inference)------ 每个值依赖哪些输入
▼
Reactive Scope 划分 ------ 哪些代码共享一个缓存槽
▼
代码生成(插入 useMemoCache)
其中最难的是可变性分析。举个例子:
jsx
function Foo({ items }) {
const result = [];
items.forEach(x => result.push(x * 2)); // result 是可变的
return <List data={result} />;
}
编译器要判断出 result 虽然是 const,但内容会变化,需要把整个 push 循环视为一个"反应域(reactive scope)",只有 items 变了才重新执行。
五、避免负优化的核心规则
这是最精妙的部分------编译器怎么知道该 memo 什么、不该 memo 什么?
内部有一套决策链:
markdown
对每个表达式/值:
1. 这个值是不是"逃逸"了本次 render? ── 没逃逸 → 不 memo
2. 这个值是不是原始类型(primitive)? ── 是 → 大概率不 memo
3. 计算本身贵不贵? ── 不贵 → 通常不单独 memo
4. 能不能和其他值合并进同一个作用域? ── 能 → 合并共享缓存槽
5. 依赖里有没有恒定不变的东西? ── 有 → 从依赖里剔除
规则 1:Escape Analysis(逃逸分析)
只有离开当前 render 作用域的值才值得缓存。
什么叫"逃逸"?
- 作为 JSX 属性传给子组件
- 作为 JSX children
- 从组件/hook 返回
- 传给
useEffect依赖 - 存进 ref、订阅回调等
jsx
function Foo({ items }) {
const count = items.length; // 本地用,不 memo
const doubled = count * 2; // 本地用,不 memo
const list = items.map(x => x * 2); // 传给子组件,会 memo
console.log(count, doubled);
return <List data={list} />;
}
原因 :count 和 doubled 就算变了也不影响子组件(它们没被传出去),缓存它们毫无意义。
规则 2:Primitive 基本不 memo
原始类型(number/string/boolean)几乎不会被单独缓存。
原因:
- 原始类型比较是 O(1),本来就不慢
- 不会导致子组件 memo 失效(
"张三" === "张三"恒为 true) - 缓存 primitive = 花一个内存槽存一个值,收益基本为零
jsx
const greeting = `Hello, ${user.name}`; // string,不单独 memo
const isAdult = user.age >= 18; // boolean,不单独 memo
例外 :如果 primitive 的计算贵(比如 expensiveFn().toString()),编译器会因为"计算成本高"而 memo。
规则 3:Reactive Scope 合并(最精妙)
多个值如果依赖相同、总是一起变,就共享一个缓存槽。
jsx
function Chart({ data, theme }) {
const xAxis = computeXAxis(data);
const yAxis = computeYAxis(data);
const colors = pickColors(theme);
const legend = buildLegend(data, theme);
return <Canvas x={xAxis} y={yAxis} colors={colors} legend={legend} />;
}
手写会写 4 个独立的 useMemo,4 组依赖数组,4 次比较。
编译器分析出:
xAxis和yAxis依赖完全一样(都是data)→ 合并成一个 Scopecolors单独 Scope(只依赖theme)legend单独 Scope(依赖data + theme)
编译后只做 3 次比较(不是 4 次),逻辑相关的值聚在一起,缓存局部性更好。
规则 4:依赖裁剪(Dependency Pruning)
编译器能识别哪些"依赖"其实是不变的常量,从依赖里剔除。
jsx
const MULTIPLIER = 2; // 模块级常量
function Foo({ x }) {
const result = x * MULTIPLIER; // 依赖里不会包含 MULTIPLIER
return <Bar value={result} />;
}
同理,import 进来的函数、useState 的 setter、useRef 的 ref 对象,都会被识别为"恒稳定",不计入依赖。
规则 5:本地临时值不 memo
只在当前作用域用一次的中间值直接算,不进缓存。
jsx
const filtered = items.filter(x => x.active); // 中间产物
const result = filtered.map(x => x.value); // 用完 filtered 就没了
return <List data={result} />;
filtered 只在下一行用了一次,直接内联进 result 的计算,或跟 result 合并进同一个 Scope。
六、成本模型(Cost Model)
内部有一个粗略的成本估算:
| 操作 | 相对成本 |
|---|---|
| Primitive 计算(+、-、比较) | 极低 |
| 字符串拼接 | 低 |
| 对象/数组字面量 | 低(但创建了新引用) |
| 箭头函数 | 低(但创建了新引用) |
| 数组遍历(map/filter/reduce) | 中~高 |
| 调用未知函数 | 高(保守估计) |
判断逻辑:"计算成本 > memo 开销" 且 "值会逃逸" 才 memo。对不确定的函数调用,保守假设它可能贵,倾向于 memo。
七、手写 vs Compiler:一个对照实验
jsx
function Search({ query, users }) {
const trimmed = query.trim();
const upperQuery = trimmed.toUpperCase();
const filtered = users.filter(u => u.name.includes(trimmed));
const handleClick = () => console.log(trimmed);
return (
<div>
<Header title={upperQuery} />
<List data={filtered} onItemClick={handleClick} />
</div>
);
}
新手手写版(存在负优化):
jsx
const trimmed = useMemo(() => query.trim(), [query]); // ❌ 字符串操作太便宜
const upperQuery = useMemo(() => trimmed.toUpperCase(), [trimmed]); // ❌ 同上
const filtered = useMemo(() => users.filter(...), [users, trimmed]); // ✅ 数组遍历,值得
const handleClick = useCallback(() => ..., [trimmed]); // ✅ 函数引用,值得
Compiler 优化版:
jsx
// 便宜的 primitive → 不 memo
const trimmed = query.trim();
const upperQuery = trimmed.toUpperCase();
// 数组、传给子组件 → memo
if ($[0] !== users || $[1] !== trimmed) {
filtered = users.filter(...);
}
// 函数、传给子组件 → memo
if ($[3] !== trimmed) {
handleClick = () => ...;
}
// JSX 也 memo
if (/* deps changed */) {
jsx = <div>...</div>;
}
差异:编译器跳过了对便宜 primitive 的 memo(避免负优化),又比手写多做了 JSX 级别的 memo(增加收益)。
八、Rules of React:编译器安全的前提
编译器要能安全地插入 memoization,前提是你的代码遵守 Rules of React------因为如果代码有副作用、可变引用、突变 props,缓存出来的东西可能是错的。
编译器会在构建时检查:
| 规则 | 违反例子 |
|---|---|
| 组件/Hook 是纯函数 | 在 render 里改全局变量 |
| 不修改 props/state | props.list.push(x) |
| Hook 遵循 Rules of Hooks | if (x) useState(0) |
| Ref 只在 effect/handler 里读写 | render 里 ref.current++ |
如果检测到违规,编译器会跳过这个组件不做优化 ,同时给出 lint 警告。这是它的防御机制------宁可不优化,也不生成错误代码。
配套的 eslint-plugin-react-compiler 会把这些规则前置到编辑器里报错。
九、实际应用
9.1 安装(React 19+)
bash
npm install --save-dev babel-plugin-react-compiler
npm install --save-dev eslint-plugin-react-compiler
Vite 配置:
js
import react from '@vitejs/plugin-react';
export default {
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', {}]],
},
}),
],
};
Next.js(14.3+):
js
module.exports = {
experimental: {
reactCompiler: true,
},
};
9.2 渐进式采纳
编译器提供三种模式:
- 全量模式(默认):所有组件都编译
- Opt-in 模式 :只编译带
'use memo'指令的组件 - Opt-out 模式 :默认编译,用
'use no memo'跳过
jsx
function Heavy() {
'use memo'; // 主动开启
// ...
}
function Legacy() {
'use no memo'; // 兜底关闭
// ...
}
老项目推荐 :先全量跑一遍编译器,看 lint 报错多不多,逐步修复;不能立刻修的用 'use no memo' 兜底。
9.3 实际收益(Meta 官方数据)
- Instagram Web:初始加载提升 12%,交互响应提升 2.5×
- Quest Store:几乎所有页面 render 次数减少 20~50%
- 代码库改造 :删掉了数千行手写的
useMemo/useCallback/memo
9.4 最受益的场景
| 场景 | 收益 |
|---|---|
| 大列表(Table/List) | 极大 |
| 多输入框表单 | 极大 |
| 图表 / 可视化 | 极大 |
| 深嵌套组件树 | 大 |
| 静态展示页 | 一般 |
十、和其他框架的对比
| 方案 | 优化时机 | 更新粒度 | 心智模型 |
|---|---|---|---|
| Vue 3 | 编译期 + 运行时 | 组件 + 静态节点 | 响应式(getter 追踪) |
| Solid.js | 编译期 | signal 级(极细) | 组件只跑一次 |
| Svelte | 编译期 | DOM 节点级 | 编译到 imperative |
| React + Compiler | 编译期 | 表达式级 | 每次重新执行 + 自动缓存 |
React Compiler 的路线独特之处:保留了"函数每次重新执行"的心智模型(React 的宗教),但通过编译器让它性能接近细粒度响应式方案。
是一个非常"React 式"的解法------不改哲学,改工具链。
十一、局限与注意事项
- 不能解决网络请求 / IO 慢 ------ 编译器只优化 render
- 不能解决业务逻辑写得差 ------ O(n²) 算法还是慢
- 首次渲染无变化 ------ 主要优化后续更新
- class 组件不支持 ------ 只处理函数组件和 Hook
- 缓存空间开销 ------ 每个组件多分配一个数组,虚拟长列表要评估
- 依赖代码规范 ------ 违反 Rules of React 的老代码会被跳过
十二、给你的建议
- 新项目直接开 ------ React 19 + Compiler + eslint 插件三件套
- 老项目先加 lint ------ 把 Rules of React 违规修完再打开编译器
- 写代码时忘掉 memo ------ 专注业务,让编译器操心性能
- 性能瓶颈还是要 profile ------ 编译器不是银弹
十三、总结
React Compiler = 编译期自动 memoization。
它不改变 React 的编程模型,而是把开发者过去手写的 memo / useMemo / useCallback 通过静态分析自动生成,粒度更细、覆盖更全、出错更少。
它的核心武器是四板斧:
- 逃逸分析:只 memo 会影响下游的值
- Reactive Scope 合并:智能分组,避免重复比较
- 依赖裁剪:剔除恒稳定的依赖
- 成本模型:跳过便宜操作,避免负优化
结果是:手写 memo 的正收益全部拿到,负收益全部避免。
这也是 Meta 敢在 Instagram 生产环境全线铺开的原因------它比人写得更精准、更均衡。本质上,它把"memoization 策略"这件事从"依赖开发者的经验"变成"编译器的静态分析问题"。
React Fiber 解决了时间维度 的性能问题(长任务不阻塞主线程),React Compiler 解决了空间维度的性能问题(不必要的 render 被自动跳过)。至此,React 才算真正补齐了它十年来的两个大坑。
忘掉 memo 吧,让编译器操心。