React Compiler 详解
原文链接:neciudan.dev/react-compi...
原文作者:Neciu Dan

React Compiler 在构建期为你的组件做记忆化(memoize),而且比你手写做得更好。它是什么、编译产物如何工作、如何开启,以及代码库里已有的 useMemo 该怎么办。

Neciu Dan
2026 年 7 月 11 日
此时此刻,在你的代码库某处,一个包在 React.memo 里的组件,仍会在每次按键时重新渲染。
这个包装看起来管用。组件被记忆化了,回调也包在了 useCallback 里;作者确实花了功夫。
但在树的上层某处,父组件把内联箭头函数当 prop 传下来,或在渲染时新建一个 options 对象,或展开自己从更上层父组件拿到的 props。
memo 内部的检查看到有 prop 变了,于是每次都重新渲染该组件,就好像包装根本不存在一样。
React 从不在这种事发生时警告你。应用能跑、测试能过,于是这条坏掉的优化就一直躺在那里,成本悄悄累积。
手工记忆化无法规模化,这是它真正的问题所在。
Meta 也遇到过这个问题,于是开发了一个编译器来自动做记忆化,让开发者不必再手工修组件。
该编译器已在 Instagram 与 Meta Quest Store 的生产环境中运行,它会自动检测组件内的依赖,并把最优的记忆化插入编译后的代码。
这能节省开发时间、减少 bug,并确保最终交付的代码始终是最高效的版本,源码里完全不需要手工记忆化。
来认识一下 React Compiler(React 编译器)。
它是什么
React Compiler 是一个构建期工具。它在构建时运行,并重写你的组件,加入记忆化。
你的构建本来就会替你改写代码------例如把 JSX 转成 JavaScript。React Compiler 以 Babel 插件的形式工作,无缝嵌入这一过程。
又因为优化发生在构建产物里,你可以在源码里写出完全没有记忆化的版本:
tsx
function List({ items, onSelect }) {
const handleClick = (item) => {
onSelect(item.id);
};
return (
<ul>
{items.map(item => (
<Item key={item.id} label={item.label} onClick={() => handleClick(item)} />
))}
</ul>
);
}
任何地方都没有手工记忆化。也就是说,你不必在组件里到处加 useMemo、useCallback 或 React.memo。
编译器会分析哪些值依赖哪些 props 与 state,然后生成代码:把每一块缓存起来,只在其真实输入变化时才重新计算。
这个项目在 Meta 起步时的代号是 React Forget,因为目标就是让你忘掉记忆化这件事。
首次公开演示出现在 2021 年,实验性版本在 React Conf 2024 发布,1.0 则在 2025 年 10 月 7 日落地。
尽管时间线与 React 19 重叠,编译器并不属于 react 包。
安装 React 19 会给你编译产物所依赖的运行时支持,但编译本身是你要单独加进构建流水线的工具。
编译产物实际长什么样
既然我们已经知道 Compiler 是什么、做什么,一旦你把它加进代码库并跑起来,你会以为编译器会在最终结果里替你插入 useMemo 与 useCallback 调用。
它不会这么做,因为 useMemo 本身很复杂。
每次调用都会在每次渲染时分配一个闭包和一份新的依赖数组,只为了判断缓存值能否复用。
当你手工记忆化五个值时,这点开销可以忽略;当编译器几乎把每个组件里的一切都记忆化时,开销就会累积起来。
因此编译器改为生成自己的缓存机制。例如:
tsx
function TodoList({ todos, tab }) {
const visibleTodos = filterTodos(todos, tab);
return <ul>{visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>;
}
接下来会编译成下面这样(为了可读性做了简化):
js
function TodoList(t0) {
const $ = _c(5); // 为本组件申请 5 个缓存槽
const { todos, tab } = t0;
let visibleTodos;
if ($[0] !== todos || $[1] !== tab) {
// 某个输入变了,于是重新计算并保存结果
visibleTodos = filterTodos(todos, tab);
$[0] = todos;
$[1] = tab;
$[2] = visibleTodos;
} else {
// 输入完全相同,复用已缓存的值
visibleTodos = $[2];
}
// ...同一模式会对 JSX 本身再重复一遍
}
下面用几句话解释一下这里实际发生了什么。
首先,t0 参数就是你的 props 对象,只是被重命名并解构了。
_c(5) 调用会创建一个带五个槽位的数组,并挂在组件实例上。
每次渲染时,代码会检查当前输入与已存输入。若匹配,就复用缓存输出;否则重新计算并更新值。
比较使用的是引用相等,与 memo 和依赖数组一样。
如果父组件每次渲染都新建一个 todos 数组,即使内容相同,下游每一次比较都会失败,每个缓存值都会被重新计算。编译器遵循的仍是 React 一贯的引用相等规则。
生成的检查是一个对少量数组读取的 if 语句,这几乎是 JavaScript 里最便宜的操作,因此编译器可以远比你手工写 useMemo 时更密集地应用它。
在依赖追踪这一侧,它给出的表现也更好。
当你写 useMemo 时,由你决定依赖数组里放什么,而且可能写错;编译器从代码的实际数据流推导依赖,因此它比较的集合,恰好就是计算所读取的集合。
而且编译器能在 useMemo 不合法的地方做记忆化。
Hooks 不能有条件地调用,也不能在提前返回之后调用,因此像下面这样的组件根本无法手工记忆化:
tsx
function FriendList({ friends }) {
if (friends.length === 0) {
return <NoFriends />; // 提前返回,此后不能再调用 hooks
}
const summary = buildSummary(friends); // useMemo 在这里帮不上忙
return <Summary data={summary} />;
}
编译器不在乎,因为它的缓存槽并不是你代码里的 hooks。它会独立记忆化每个分支,包括提前返回里的 <NoFriends /> JSX。
若想在自己的组件上看看效果,把它们粘贴进 React Compiler Playground。
它如何在没有 React.memo 的情况下跳过重新渲染
上面的演练展示了值如何被缓存,但缓存一个过滤后的数组并不能阻止子组件重新渲染。
编译器通过另一种完全不同的机制跳过重新渲染。
先从 JSX 实际是什么说起。<Item label={label} onClick={handleClick} /> 是一个产生元素对象的函数调用,也就是一份应被渲染内容的纯描述。
父组件的每次渲染,通常都会为每个子组件产生一个全新的元素对象。
React 对这些对象有一条由来已久的退出(bailout)规则:若组件收到的元素对象与上次完全相同,React 就跳过对它的重新渲染,因为相同的描述不可能产生不同的输出。
这也是把组件以 children 传入能避免重新渲染的同一原因。
而编译器正是在系统地利用这条长期存在的规则。
JSX 本身也会进入缓存槽,因此当某个元素所依赖的东西都没变时,父组件的编译产物会把上一轮渲染里同一个元素对象交给 React,React 就会对该整棵子树退出。
任何地方都没有 memo 包装;父组件只是不再产出新的描述。
于是跳过发生在父组件的编译产物里,而不是子组件里。如果你编译了叶子组件,但其父组件仍未编译,父组件仍会在每次渲染时创建全新的元素对象,你的已编译叶子组件就会像以前一样继续重新渲染。
如何开启它
编译器是一个 Babel 插件,因此设置取决于你的构建工具,并且它必须在 Babel 插件流水线中最先运行,因为它需要分析你原始的、未经转换的源码。
Vite
对 Vite,安装该插件以及一个小型 Babel 桥接:
bash
npm install --save-dev --save-exact babel-plugin-react-compiler@latest
npm install --save-dev @rolldown/plugin-babel
js
// 文件:vite.config.js
import { defineConfig } from 'vite';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';
export default defineConfig({
plugins: [
react(),
babel({
presets: [reactCompilerPreset()],
}),
],
});
如果你用的 @vitejs/plugin-react 版本低于 6.0,有一种更短的写法:该插件以前接受内联 Babel 插件,形如 react({ babel: { plugins: ['babel-plugin-react-compiler'] } })。
6.0 移除了该内联选项,因此当前设置要走单独的 Babel 插件。
Next.js
对 Next.js,编译器支持在第 16 版变为稳定,开启它只需一个配置开关:
ts
// 文件:next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactCompiler: true,
};
export default nextConfig;
但要知道,Next.js 通常根本不用 Babel。
它用 SWC 编译------这是 Next.js 团队采纳的基于 Rust 的工具,正是因为它比 Babel 快得多------而 React Compiler 今天仍只通过 Babel 运行(可自行核实)。
因此,启用编译器等于把 Babel 重新接到一条本为避开它而建的流水线上,你的开发与生产构建都会变慢。
Next.js 用一次预检来缓和这一点:检测哪些文件实际包含 JSX 或 hooks,并只对这些文件跑编译器。
面向编译器的官方 SWC 插件目前仍在推进之中。
Expo
React 团队已明确表示,他们期望编译器成为构建 React 代码的默认方式,生态也正分阶段朝这个方向滚动。
Expo 走在前面。用 SDK 54 或更新版本创建的新应用,默认模板里就启用了编译器,因此每个新 Expo 应用从第一次 npx expo start 起就是经过编译的。你可以在 Expo CLI 里按 J 打开组件面板,查看哪些组件被记忆化了。
它适用于哪些 React 版本
编译产物在运行时需要那个 _c 缓存 hook,而该 hook 内置于 React 19。
因此默认情况下,编译器以 React 19 为目标。
不过你也不会被锁死在旧版本之外。编译器通过一个可作为常规依赖安装的小型运行时包,正式支持 React 17 与 18:
bash
npm install react-compiler-runtime@latest
然后告诉编译器要为哪个 React 版本生成代码:
js
// 文件:babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', { target: '18' }], // 可选:'17' | '18' | '19'
],
};
target 选项背后的机制很简单。当 target: '19' 时,生成的代码从 react/compiler-runtime 导入缓存 hook,它内置于 React 本身。
当 target: '17' 或 '18' 时,它改为从独立的 react-compiler-runtime 包导入,该包基于你所用 React 版本已有的 API,提供同一 hook 的实现。
因此,一个因为某个依赖无法升级而卡在 React 18 的团队,今天仍能获得自动记忆化。
等他们最终迁到 19,只需改 target、丢掉运行时包,产物就会切到内置实现。
不过独立运行时是一个 polyfill,行为与 React 19 内置实现略有不同。它未必在所有相同情形下都能保留缓存值,因此请把它当作通往 React 19 的桥梁。
不过类组件不行。编译器只优化函数组件与 hooks,因此类组件会原样穿过构建;它们仍能工作,只是什么也得不到。
已有的 useMemo 与 useCallback 会怎样
关于这一点,人人都会问到这个问题。
对新代码,首先:别再写它们了,也别再把组件包进 React.memo。
编译器的记忆化覆盖了这三种 API 所做的事。
对已有代码:先别动它们,至少一开始别动。
你也许会想开一个巨型 PR,删掉代码库里每一个 useMemo,虽然这可能很解气,但 React 文档明确建议不要这么做。
如果某个记忆化值出现在 useEffect 的依赖数组里,它的引用稳定性决定了该 effect 何时触发。
去掉 useMemo 会改变编译产物;若编译器在该处划出的记忆化边界与你原来略有不同,effect 就可能比以前更频繁或更少地触发。
反方向也有同样风险:在你第一次启用编译器的那天。若某个 effect 的依赖以前每次渲染都是新对象,因而每次渲染都在重新触发,而编译器稳定了该引用,effect 就会突然停止重新触发。
若有些行为悄悄依赖了这种持续重新触发,它也会一并停下。
这也是我们前面使用 --save-exact 标志的真正理由。记忆化边界可能在编译器版本之间漂移,因此你希望用端到端测试挡在编译器升级与生产环境之间。
你现有的 hooks 也不会被静默丢弃。只有当编译器自身的推断匹配或超过你手工写的记忆化时,它才会编译该组件;若无法证明这一点,它会整组件跳过,而不是交付比你要求更弱的记忆化(preserve-manual-memoization lint 规则正是标记这些情况)。
那些确实被编译进去的冗余 hooks 在运行时几乎没有成本,因此并不紧急;你可以在编译器跑过一段时间之后,带着测试逐步清理。
有些手工记忆化你会永远保留,当作逃生舱。
看下面的代码,并想象没有 useMemo 会怎样。options 对象每次渲染都会新建,于是 effect 的依赖每次渲染都变,于是 effect 每次渲染都拆掉再跑一遍;若背后是真实订阅,就意味着用户每敲一个字符都要断开并重连:
tsx
// effect 必须只在 query 有意义地变化时才重跑,
// 因此我们手工钉住引用,而不是依赖可能在版本间漂移的
// 编译器边界。
const options = useMemo(() => ({ query, page }), [query, page]);
useEffect(function syncSearchSubscription() {
const sub = subscribeToSearch(options);
return () => sub.unsubscribe();
}, [options]);
另一种情况是第三方代码持有对你函数的引用。在初始化时一次性注册 onClick 处理函数的图表库、某个 DOM API、非 React 的 SDK;这些系统活在编译器视野之外,因此当引用身份是与外部世界约定的一部分时,请保留 useCallback。
React 19.2 用 useEffectEvent 进一步缩小了这类场景:这是一个 hook,用于 effect 需要读取某个值、但不应在该值变化时重跑的情况。
编译器何时会跳过你的组件
编译器只能记忆化它能证明可以安全记忆化的代码,而证明取决于你的代码是否遵循 React 规则(Rules of React):
- 组件在渲染期间保持纯净
- props 与 state 不被突变
- hooks 被无条件地调用
当组件以编译器能检测到的方式打破这些规则时,它不会尝试修复你的代码,也不会把它弄坏。
它会整组件跳过,并原样留下你当初写下的代码。
你可以在 React DevTools 的 Components 标签里看到哪些组件通过了编译,已编译组件会有一个小小的「Memo ✨」徽章。
缺少徽章的组件,就是编译器最终放弃处理的组件。
那通常意味着规则违规,不过也可能是手工记忆化;编译器无法证明它与手工写法匹配。
eslint-plugin-react-hooks 的 lint 规则会告诉你你看到的是哪一种。
徽章在开发环境也能用,因为编译器跑在 Babel 插件所在的任何地方,包括你的开发服务器。因此你本地已经在剖析的,就是编译后的行为,而不是只在生产构建里才出现的东西。
在启用任何东西之前,你也可以对项目运行 npx react-compiler-healthcheck。
它会扫描代码库,并报告有多少组件可以成功编译。
若你全局启用了编译器,而某个组件行为异常,你可以在排查时把它退出:
tsx
function LegacyDataGrid() {
'use no memo';
// 被编译器整段跳过
}
若你更想按组件迁移,可在编译器配置里设 compilationMode: 'annotation',改为按组件选择加入:
tsx
function ProductList() {
'use memo';
// 只有带注解的函数才会被编译
}
对有真实流量的应用做生产放量,还有第三种选项。
编译器的 gating 配置会让它为每个已编译函数发出两个版本------优化版与原版------并在运行时根据你提供的特性开关函数在两者之间切换。
这把编译器变成了你可以做金丝雀发布(canary)的东西:同时交付两个版本,为 5% 用户打开开关,观察错误率与指标,再随着信心增加逐步放量。
它不会做什么
有几条需要事先记住、千万不要搞混的边界:
缓存是按组件的。 若五个组件各自用同一份数据调用 processHugeDataset(data),该函数会跑五次,每个组件一次,结果各自单独缓存。
编译器不会把共享工作提升到共享缓存里,因此跨组件缓存仍是你的事,无论是把计算上提、放进 context,还是让 TanStack Query 持有它。
它只编译你的代码。 node_modules 里的一切原样穿过,因为编译器需要原始源码,而你的依赖交付的是已转换产物。
若某个库想交付已编译组件,库作者必须在发布前自己跑编译器。
它无法稳定它看不见的东西。 若某个第三方 hook 每次调用都返回新对象,编译器收到的就是不稳定引用,而你从缓存机制已经知道接下来会发生什么:每次渲染引用检查都会失败,依赖它的每个缓存值都会重新计算。
你的组件被优化了,但从外部流入的不稳定性不会消失。
服务端组件(Server Components)几乎得不到什么。 服务端组件在服务器上渲染,从不在客户端重新渲染,而记忆化是用来跳过重新渲染的工具,因此编译器在这里几乎没什么可加速的。
它对大多数慢应用里占主导的问题毫无帮助。 网络瀑布、过大的包、慢服务器、在 effects 里拉取数据:编译器让重新渲染更便宜;若你的应用因任何其他原因而慢,开了编译器也会一样慢。
它不适用于类组件。 它会跳过它们,就像我前面说的。
数字
Meta 在 Meta Quest Store 上线了编译器,测得首次加载与跨页导航最高可改善约 12%,某些交互快了超过 2.5 倍,内存占用保持中性。
Sanity 预编译了他们的 Studio 包,报告整体渲染时间与延迟降低 20--30%,在首次通过时 1,411 个组件中有 1,231 个成功编译。
Wakelet 把编译器放量到 100% 用户,并测量了两项 Web Vitals:
- LCP,即主内容在屏幕上可见所需时间,改善约 10%(从 2.6s 降到 2.4s)。
- INP,即用户交互与屏幕响应之间的间隔,改善约 15%(从 275ms 降到 240ms);在下拉菜单这类纯 React 表面上,交互加速更接近 30%。
你的数字取决于应用里原先有多少不必要的重新渲染。
这把你带到哪里
对新项目,第一天就启用编译器,写组件时不必再想记忆化。
Expo 已经替你做了这个决定;在 Vite 或 Next.js 里,也只是一块配置。
对已有项目,先跑 healthcheck,把 eslint-plugin-react-hooks 升到 v6 或更新,并修掉 linter 标出的问题。
然后按整棵子树而不是单个组件启用编译器,在启用前后剖析一个交互页面,并让现有的 useMemo 先留着,直到你可以带着测试慢慢移除它们。
在 React 19.2 里,剖析也变得更容易:React 现在直接在 Chrome DevTools 性能分析器中提供 Performance Tracks,带有 Scheduler 轨道与 Components 轨道,精确显示哪些组件在何时渲染。
打开它吧------现在就启用 React Compiler。
参考资料
- React Compiler v1.0 - React 博客,稳定版发布公告
- React Compiler: Introduction - React 文档
- Introducing React Compiler - React Working Group,关于编译产物如何工作
- Using the compiler on < React 19 - React Working Group,关于
_c缓存 hook 与运行时包 - React Compiler Playground - 粘贴你的组件,查看输出
- Next.js: reactCompiler - Next.js 文档
- Next.js 16 - 稳定编译器支持公告
- Expo: React Compiler - Expo 文档,SDK 54+ 默认启用
- React Compiler Beta Release - React 博客,React 17/18 支持落地处
- React Compiler: Incremental Adoption - React 文档,指令、注解模式与运行时 gating
- preserve-manual-memoization - React 文档,编译器如何把已有 useMemo/useCallback 当作下限
- Meta's React Compiler 1.0 Brings Automatic Memoization to Production - InfoQ,含 Sanity 与 Wakelet 案例数字
- I tried React Compiler today, and guess what... - Nadia Makarevich,关于编译器对不稳定第三方引用的局限