React Compiler 深度实战:当编译器替你写完 useMemo——React 19 自动记忆化如何重写前端性能心智模型
关键词:React 19、React Compiler、自动记忆化、细粒度更新、性能优化、前端工程化
这是一篇写给"还在手动写 useMemo/useCallback"的工程师的实战长文。我们不从官网念概念,而是从编译器管线的角度,把 React Compiler 到底做了什么、为什么它比人写记忆化更可靠、以及它和 React 19 的 Actions / Activity 等新特性如何协同,一次性讲透。
一、背景:手动记忆化的"原罪"
如果你写过三年以上的 React,下面这段代码你一定写过,也一定被它坑过:
function ProductList({ products }) {
const inStock = products.filter(p => p.inStock);
return (
<ul>
{inStock.map(p => (
<ProductItem
key={p.id}
product={p}
onClick={() => console.log(p.id)} // ⚠️ 每次渲染都新建函数
/>
))}
</ul>
);
}
ProductItem 即使包了 React.memo,onClick={() => ...} 这种内联箭头函数也会让它在每次父组件渲染时拿到一个全新的函数引用,于是 memo 彻底失效,子组件跟着重渲染。
于是我们"正确地"改成了这样:
function ProductList({ products }) {
const inStock = useMemo(
() => products.filter(p => p.inStock),
[products]
);
const handleClick = useCallback(
(id) => console.log(id),
[]
);
return (
<ul>
{inStock.map(p => (
<ProductItem key={p.id} product={p} onClick={handleClick} />
))}
</ul>
);
}
看似解决了,但问题才刚刚开始:
- 依赖数组是隐式契约,编译器不帮你校验。 你少写
query或写错items,React 不会报错,只会在某些交互下悄悄返回过期数据——这是 React 社区最高频的 bug 来源之一。 - 记忆化是"传染性"的。 父组件 memo 了,子组件的 prop 也必须稳定,否则白费。于是
useCallback像病毒一样往上层层传播,最终一个中型组件树里塞满了useMemo/useCallback,可读性雪崩。 - 过度记忆化和记忆化不足一样有害。
useMemo本身有内存和比较开销。你给一个本就很廉价的表达式套上useMemo,反而拖慢了首次渲染。
React 团队内部在 Meta 的巨型代码库里早就受够了这种"人肉性能调优"。结论很直白:记忆化本质上是机械的、可推导的、应当由编译器完成的工作;让人类手写,既不经济也不可靠。 这就是 React Compiler 诞生的根本动机。
二、核心概念:React Compiler 到底是什么
React Compiler 是一个仅构建时(build-time only)的编译器。它基于 SWC/Babel 生态,读取你的原始源代码,静态分析每个组件和 Hook 的"反应性(reactivity)",然后自动插入等价的 useMemo/useCallback/React.memo,而不需要你改一行业务代码。
要点先说清楚:
- 它是纯编译期工具,运行时零额外依赖(React 19 用内置的
react/compiler-runtime,React 17/18 需额外装react-compiler-runtime垫片)。 - 它理解"React 的规则(Rules of React)"——比如"渲染必须是纯函数、不能在 render 中修改外部变量、不能在条件分支里调用 Hook"。
- 它不会强制你重构代码。如果某段代码违反了规则导致无法安全优化,编译器会保守地跳过那个组件或 Hook,继续安全地编译其余部分。它不会把你的代码编译坏。
- 配套的
eslint-plugin-react-compiler能在编辑器里直接标出"哪些代码编译器会跳过",哪怕你根本没启用编译器也能用。
2.1 "React 的规则"——编译器与你的契约
编译器之所以能自动记忆化,是因为它假定你的代码遵循几条铁律:
| 规则 | 含义 | 违反后编译器怎么做 |
|---|---|---|
| 渲染是纯的 | 组件函数调用两次应得到相同结果(在输入不变时) | 检测到副作用/可变读取 → 跳过该组件记忆化 |
| 不在 render 中改变外部状态 | 不修改 ref.current 之外的外部变量 | 跳过 |
| 不读非反应式全局可变值作为渲染依据 | 不依赖会变的模块级变量决定 UI | 跳过或保守处理 |
| Hook 不在条件/循环中调用 | 调用顺序稳定 | 报错(这是语法错误,编译器无法绕过) |
所谓"保守跳过",是 React Compiler 设计上最优雅的地方:它永远不会为了优化而破坏正确性。 一个组件被跳过,最多是"没有获得自动记忆化收益",绝不会产出错误 UI。
三、架构分析:编译器管线如何工作
理解一个编译器,最好的办法是看它在_pipeline_里的位置,以及它到底往 AST 上动了什么手脚。
3.1 必须第一个跑的 Babel 插件
React Compiler 以 Babel 插件(babel-plugin-react-compiler)形式存在,它必须在你的 Babel 插件链里第一个运行。原因很简单:它需要原始、未经其他转换污染的源码信息(尤其是 JSX 还没被展开成 React.createElement 之前的形态)才能正确做反应性分析。
// babel.config.js —— 注意插件数组顺序
module.exports = {
plugins: [
['babel-plugin-react-compiler', { target: '19' }], // ✅ 必须最先
// 其他插件(i18n、styled 等)往后放
],
};
在 Vite 里通过 @vitejs/plugin-react 的 babel 选项注入:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', { target: '19' }]],
},
}),
],
});
Next.js 则在 experimental.reactCompiler 中一键开启(App Router 与 Pages Router 均支持):
// next.config.js
module.exports = {
experimental: { reactCompiler: true },
};
3.2 反应性分析(Reactivity Analysis):store / load 模型
这是编译器的灵魂。它把组件里的每个值都建模成"数据如何流动":
- 当你读取一个会变化的值(props、state、
use()的结果、context 值等),编译器记录一次 load(读取)。 - 当你基于这些读取计算出一个新值,编译器把这个结果放进一个 memo(存储),并把这个 memo 的 key 绑定到它 load 过的那些反应式值上。
举个例子,编译前的代码:
function ExpensiveList({ items, query }) {
const filtered = items.filter(i => i.name.includes(query));
return <List items={filtered} />;
}
编译器看到:filtered 这个值的计算中 load 了 items 和 query。于是它生成:
function ExpensiveList($props) {
const items = $props.items;
const query = $props.query;
const filtered = React.useMemo(
() => items.filter(i => i.name.includes(query)),
[items, query]
);
return <List items={filtered} />;
}
注意它自动推导出了依赖数组 [items, query]——这正是人类最容易写错的地方,编译器永远写对。下次 query 不变时,filtered 直接复用上一次的计算结果,子组件 <List> 拿到的 items 引用稳定,自然跳过重渲染。
3.3 比 React.memo 更细:元素级记忆化
传统 React.memo 是在组件边界做浅比较。React Compiler 更进一步,对 JSX 元素本身做记忆化:
function Todo({ todo, onToggle }) {
return (
<li className={todo.done ? 'done' : ''} onClick={onToggle}>
{todo.text}
</li>
);
}
编译器会把这段 JSX 的创建也包进 useMemo,依赖是 todo 和 onToggle。这意味着:即使 Todo 没有被 React.memo 包裹,只要 todo 和 onToggle 引用没变,这个 <li> 元素实例就会原样复用,而不需要重新创建虚拟 DOM 节点、也不需要父组件去做整层 diff 前的属性浅比较。这是一种元素级(element-level)的细粒度记忆化,颗粒度比组件级 memo 小一个层级,重渲染成本进一步摊薄。
3.4 逃逸值(Escaped Values)与保守跳过
编译器分析时遇到它无法证明安全的模式,会把这些值标记为 escaped(逃逸)。典型场景:
function Bad({ onClick }) {
const ref = useRef(0);
ref.current += 1; // ❌ render 中修改 ref —— 违反纯函数规则
return <button onClick={onClick}>{ref.current}</button>;
}
这里 ref.current 在渲染期间被改写,编译器无法保证记忆化后行为一致,于是整段跳过自动记忆化,但代码照常编译运行(只是没有优化)。它不会报错、不会崩,只是"放弃优化这个组件"。这正是 eslint-plugin-react-compiler 存在的意义——它能在你写代码时就把这种"会被跳过"的地方标红。
3.5 看一眼真实编译产物
最好的学习方式是用官方 Playground(playground.react.dev)直接贴代码看输出。拿一个计数组件举例:
function Counter({ initial }: { initial: number }) {
const [count, setCount] = useState(initial);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
编译后大致是:
function Counter($props) {
const initial = $props.initial;
const $count = useState(initial);
const count = $count[0];
const setCount = $count[1];
const onClick = React.useCallback(
() => setCount(c => c + 1),
[setCount]
);
const JSX = React.useMemo(
() => <button onClick={onClick}>{count}</button>,
[onClick, count]
);
return JSX;
}
你看,onClick 被 useCallback 自动包好(且依赖数组只有稳定的 setCount),<button> 元素被 useMemo 自动包好。你一个 useMemo 都没写,但记忆化已经严丝合缝地到位了。
四、代码实战:从零接入到与 React 19 新特性协同
4.1 安装与最小可跑配置
# 要求 React 19(17/18 需额外装 react-compiler-runtime 垫片)
npm i react@19 react-dom@19
npm i -D babel-plugin-react-compiler
Vite 用户最快路径:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
['babel-plugin-react-compiler', { target: '19' }],
],
},
}),
],
});
4.2 对照实验:同一段业务逻辑,开/关编译器的差异
我们写一个"搜索 + 列表"的真实场景,关闭编译器时(人类手写记忆化):
// 关闭编译器:手写版
function SearchPage({ items }) {
const [query, setQuery] = useState('');
const results = useMemo(
() => items.filter(i => i.title.toLowerCase().includes(query.toLowerCase())),
[items, query]
);
const highlight = useCallback(
(text) => text.replace(query, `**${query}**`),
[query]
);
const onClear = useCallback(() => setQuery(''), []);
return (
<div>
<SearchInput value={query} onChange={setQuery} onClear={onClear} />
<ResultList results={results} highlight={highlight} />
</div>
);
}
开启编译器后,你只需写:
// 开启编译器:回归业务本质
function SearchPage({ items }) {
const [query, setQuery] = useState('');
const results = items.filter(i =>
i.title.toLowerCase().includes(query.toLowerCase())
);
const highlight = (text) => text.replace(query, `**${query}**`);
const onClear = () => setQuery('');
return (
<div>
<SearchInput value={query} onChange={setQuery} onClear={onClear} />
<ResultList results={results} highlight={highlight} />
</div>
);
}
useMemo、useCallback 全部消失,性能却不低于手写版——甚至因为编译器用了元素级记忆化,反而更精细。这就是"心智负担清零,性能不降反升"。
4.3 React 19 的 Actions:把"异步 + 状态"从样板里解放
React 19 的 Actions 与 Compiler 是天生一对。过去一个表单提交要维护 isPending、error、optimistic 三个状态;现在:
import { useActionState, useOptimistic } from 'react';
async function updateNameAction(prevState, formData) {
const name = formData.get('name');
const error = await api.updateName(name);
if (error) return { error };
return { ok: true };
}
function NameForm() {
const [state, formAction, isPending] = useActionState(updateNameAction, null);
return (
<form action={formAction}>
<input name="name" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '保存中…' : '保存'}
</button>
{state?.error && <p className="error">{state.error}</p>}
</form>
);
}
useActionState 自动管理 isPending、错误和提交结果;<form action={formAction}> 让 React 自动接管提交、重置表单。配合 Compiler,这些 Hook 内部的记忆化与依赖推导同样被自动处理,你完全不必关心 isPending 变化会不会引发多余重渲染。
4.4 React 19.2 的 Activity:用"可见性"调度渲染
2026 年 5 月发布的 React 19.2 带来了 Activity 组件,用于把 UI 切成可隐藏、可保持状态、可被抢占的活动单元——而 Compiler 的细粒度记忆化让"隐藏页保持状态但不渲染"的成本极低:
import { Activity } from 'react';
function App({ route, isActive }) {
return (
<Activity mode={isActive ? 'visible' : 'hidden'}>
<RoutePage route={route} />
</Activity>
);
}
mode="hidden" 时,子组件的 effects 被卸载、更新被延迟,但 React 会保留组件状态(比如表单输入);切回 visible 时瞬间恢复,无需重新加载。这正是 Compiler 让"大组件树 + 多页面状态保持"在性能上变得可负担的底气。
4.5 use():在组件里读 Promise 与 Context
React 19 的 use() Hook 让你可以直接在组件体内 await 式地读取资源,而 Compiler 同样能正确推导其反应性:
import { use } from 'react';
import { ThemeContext } from './theme';
function Toolbar() {
const theme = use(ThemeContext); // 读取 context,编译器自动建立依赖
return <div className={theme.dark ? 'dark' : 'light'}>…</div>;
}
五、性能优化与渐进落地策略
5.1 不要全量开启:按目录渐进式 rollout
对一个百万行级的遗留项目,最稳妥的做法是先在一个目录验证收益,再逐步铺开:
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
target: '19',
sources: (filename) =>
filename.indexOf('src/features/cart') !== -1, // 只编译 cart 模块
}],
],
};
这是"不会翻车"的迁移路径:先让编译器只覆盖一小块,观察产物与运行时表现,确认无误后再扩大 sources 范围。
5.2 编译模式:infer vs annotation
默认 compilationMode: 'infer',即编译器自动推断哪些需要记忆化。另一种 annotation 模式则只处理你显式标注的文件/组件:
const config = {
target: '19',
compilationMode: 'annotation',
};
/* @react-compiler */
function CriticalHotPath({ data }) {
// 只有带标注的组件才会被编译优化
}
annotation 模式适合"我只想给性能热点动手术、其余保持原样"的精细控制场景。
5.3 eslint-plugin-react-compiler:把"会被跳过"提前到编码阶段
// eslint.config.js(flat config)
import reactCompiler from 'eslint-plugin-react-compiler';
export default [
{
plugins: { 'react-compiler': reactCompiler },
rules: { 'react-compiler/react-compiler': 'error' },
},
];
插件会在你违反"React 规则"的代码上直接报错,告诉你"这段代码编译器会跳过优化"。强烈建议先跑一遍这个 lint,把违规点修掉,再全量开启编译器——否则那些被跳过的组件会"看起来没生效",让人误以为编译器无用。
5.4 收益到底有多大?
React 团队在 Meta 内部把编译器铺到了 Instagram、Facebook 等超大应用,公开数据是:多数页面重渲染时间显著下降,部分交互路径减少 30%~50% 的渲染开销,而工程师删掉了数以万计的 useMemo/useCallback。原理上收益来自两点:
- 依赖数组永不出错——人类漏写依赖导致的"该更新没更新 / 该复用没复用"同时被消灭。
- 元素级记忆化——比组件级 memo 颗粒度更细,重渲染的虚拟 DOM 复用率更高。
但也要诚实地说:编译器不是银弹。对于本身就廉价、每帧都要重算的渲染(比如随滚动实时变化的 canvas 坐标),记忆化反而增加内存与比对成本;编译器对这类场景会"识别为不值得记忆化"而跳过,这正是它的智慧所在——它比你更懂哪里该 memo、哪里不该。
六、陷阱与边界:哪些情况编译器会"装死"
写了这么多好处,必须泼点冷水。以下场景编译器会保守跳过或行为需要你理解:
- 渲染期修改 ref / 外部可变对象:违反纯函数假设,整个组件跳过优化。
- 自定义 Hook 内部的状态跨越:如果自定义 Hook 返回的值依赖外部可变状态,编译器可能无法推导其反应性,相关组件不享受记忆化。
- 依赖 babel 转换后的产物做分析:所以编译器必须第一个跑,后面任何改写 AST 的插件都会破坏它的分析。
- 第三方组件库未适配:被 memo 的 prop 如果来自一个内部每次都返回新对象的库,记忆化照样失效——这是数据问题,不是编译器问题。
应对之道只有一个:先跑 eslint-plugin-react-compiler,把规则违规清零,让编译器能够"放心地"优化尽可能多的代码。
七、总结与展望:手动记忆化的时代正在落幕
回头看,React 的性能调优史,是一部"把人该做的事交给机器"的历史:
- 早期:手动
shouldComponentUpdate。 - 中期:
React.memo+ 手写useMemo/useCallback,把负担转嫁给每个开发者。 - 现在:React Compiler 在编译期自动完成这一切,且做得比人更对、更细。
React 19 与 Compiler 的组合,标志着 React 正式进入"编译时(compile-time)"时代:Actions 帮你省掉异步状态样板,Activity 把页面调度交给运行时,use() 把数据读取统一化,Compiler 把所有记忆化自动化。开发者终于可以把注意力从"我这段代码会不会重渲染"转移到"我的产品逻辑是否正确"——这恰恰才是 React 该有的样子。
你今天就可以做三件事:
- 把项目升到 React 19;
- 装上
eslint-plugin-react-compiler,跑一遍把违规清零; - 用
sources配置在一个目录里先开 Compiler,验证收益后逐步铺开。
当编译器替你写完 useMemo 的那天,你会发现:原来我们花在记忆化上的那些年,本可以不那么累。
参考与延伸阅读
- React 官方 React Compiler 文档(react.dev/reference/react-compiler)
- React Compiler Playground(playground.react.dev)
eslint-plugin-react-compiler仓库- React 19.2 Release Notes(Activity / useEffectEvent / 部分预渲染)
- "React Labs" 系列博客(编译时方向的路线图)