
一句话:React Compiler 是「编译时自动记忆化」的工具——它在构建阶段分析组件并自动插入缓存(相当于替你写
useMemo/useCallback/memo),让日常代码可以写得更「朴素」,但它依赖「组件是纯函数」的假设,且不能替代对渲染机制的理解。
一、它是什么
React Compiler(原名 React Forget)是一个【构建时】工具:
· 作为 Babel / SWC 插件运行(或集成在框架里)
· 分析组件与 Hook 的代码
· 自动判断「哪些计算/值/函数是稳定的」,插入记忆化
它【不是】运行时库,也不改变 React 的语义——
只是把「你本来要手写的 memo」交给编译器生成。
// 你写的(朴素代码)
function ExpensiveList({ items, onSelect }: Props) {
const sorted = [...items].sort((a, b) => a.score - b.score);
const handleSelect = (id: string) => onSelect(id);
const options = { sortable: true };
return <List items={sorted} onSelect={handleSelect} options={options} />;
}
// 编译器「等价地」生成的东西(概念示意)
function ExpensiveList({ items, onSelect }: Props) {
const $ = useMemoCache(5); // 内部缓存槽
let sorted;
if ($[0] !== items) {
sorted = [...items].sort((a, b) => a.score - b.score);
$[0] = items;
$[1] = sorted;
} else {
sorted = $[1];
}
let handleSelect;
if ($[2] !== onSelect) {
handleSelect = (id: string) => onSelect(id);
$[2] = onSelect;
$[3] = handleSelect;
} else {
handleSelect = $[3];
}
let options;
if ($[4] === undefined) { // 常量 → 只创建一次
options = { sortable: true };
$[4] = options;
} else {
options = $[4];
}
return <List items={sorted} onSelect={handleSelect} options={options} />;
}
// → 效果等价于你手写 useMemo / useCallback,但由编译器判断「哪里需要」
二、它解决什么问题
手写记忆化的三个负担:
① 心智负担
每写一个对象/数组/函数,都要想「这个会不会让子组件重渲染?」
→ 代码里到处是 useMemo / useCallback
② 依赖数组容易写错
漏了 → 结果过期;多了 → 完全失效;含字面量 → 失效
→ 一个隐蔽的错误就让「优化」白做
③ 难以维护
层层 useMemo 的派生链、为稳定 props 而写的 useCallback
→ 可读性下降,重构时容易破坏
React Compiler 的目标:
让「默认性能」变好——写朴素代码也能有记忆化,
从而把开发者从「手写 memo」中解放出来。
三、工作原理(大致流程)
① 解析组件:把组件函数转成「控制流图 + SSA」形式
② 分析纯度:判断组件是否「符合 React 规则」(无副作用、不在渲染中改外部)
· 如果发现「不纯」→ 【跳过该组件】(不插入记忆化)+ 输出诊断信息
③ 识别依赖:分析「每个值依赖哪些输入」(props / state / 其它缓存值)
④ 插入缓存:为「可缓存的值」生成 useMemoCache 的读写逻辑
⑤ 保留语义:不改写你的业务逻辑,只加缓存
⚠️ 关键:它【不猜测】运行时的东西——
它只基于「静态分析」+「React 规则」来决定能缓存什么。
它能缓存的典型对象:
· 对象/数组字面量(依赖 props/state 的部分)
· 内联箭头函数
· useMemo/useCallback 的「计算结果」
· JSX 元素(部分场景)
· 组件的「派生计算」
它不能缓存的:
· 依赖「外部可变状态」的计算(无法追踪)
· 有副作用的组件(会被跳过)
四、与手动优化的关系
| 维度 | 手动 useMemo/useCallback |
React Compiler |
|---|---|---|
| 时机 | 运行时(每次渲染比较依赖) | 编译时(生成缓存读写) |
| 精确度 | 由你决定(可能漏/多) | 由编译器分析(通常更精确) |
| 代码可读性 | 需要包裹、依赖数组 | 代码保持朴素 |
| 依赖 React 规则 | 部分(lint 检查) | 强依赖(不纯就跳过) |
| 覆盖范围 | 你写的每一处 | 编译器能分析到的部分 |
| 现状 | 仍然有效、仍需要理解 | 可选特性,逐步落地 |
⭐ 它不是「取代理解」,而是「减少机械劳动」:
仍然需要理解的:
① 渲染机制(为什么父组件渲染会带上子组件)
② 引用稳定(跨组件的 props 传递仍可能失效)
③ Context 的重渲染(编译器不改变 Context 的订阅粒度)
④ key / 列表 / diff
⑤ 结构性优化(状态下移、虚拟滚动、代码分割、children 透传)
编译器主要覆盖的是「局部记忆化」这一类工作。
五、启用与前置条件
# 安装编译器与 ESLint 插件
npm i -D babel-plugin-react-compiler
npm i -D eslint-plugin-react-compiler
// babel.config.js(或通过框架的配置)
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
// 可选配置(如 target、编译模式等)
}],
],
};
// eslint.config.js —— 用 lint 提前发现「不纯的组件」
{
plugins: ['react-compiler'],
rules: {
'react-compiler/react-compiler': 'error',
},
}
前置条件的四条「React 规则」(编译器依赖它们):
① 组件/Hook 必须是【纯函数】
· 相同输入 → 相同输出
· 不在渲染中修改外部变量 / DOM / 存储
② 不在渲染中「修改 props / state 的对象」
③ 遵循 Hook 规则(顶层调用、顺序稳定)
④ 不在渲染中读取/写入「可变的外部状态」(如全局变量、随机数、时间)
若违反 → 编译器【跳过该组件】(不做记忆化)并给出诊断提示。
// ❌ 违反纯度 → 编译器会跳过这个组件
function Impure({ items }: { items: Item[] }) {
items.push(createItem()); // ❌ 修改了 props
return <div>{items.length}</div>;
}
// ✅ 符合规则 → 可以被优化
function Pure({ items }: { items: Item[] }) {
const next = [...items, createItem()]; // 不改原数组
return <div>{next.length}</div>;
}
六、它跳过什么(诊断信息)
常见「跳过原因」(ESLint 插件会提示):
① 在渲染中修改了「可变的值」
· 修改 props / state 对象
· 修改 ref.current(读是允许的,写在渲染中不允许)
② 调用了「未知的副作用函数」
· 编译器无法判断某个函数是否是纯的(如第三方库的调用)
· 但「以 use 开头的 Hook」与「命名为 setXxx 的状态更新」是已知安全的
③ 读取了「会变化的外部值」
· 全局变量、模块级可变变量
· `Math.random()` / `Date.now()` / `performance.now()`
④ 违反了 Hook 规则(条件调用等)
→ 被跳过的组件仍然【正常工作】,只是【不会获得自动记忆化】。
也就是说:启用编译器不会让代码出错,只是「没优化到」。
// ② 的一个实际例子:编译器无法判断第三方函数是否纯
function Comp({ data }: Props) {
const formatted = someThirdPartyLib.format(data); // ⚠️ 无法判断是否纯 → 不缓存
// ✅ 如果它确实是纯函数,可以手动 useMemo
const formatted2 = useMemo(() => someThirdPartyLib.format(data), [data]);
}
七、限制与风险
① 强依赖「组件是纯函数」的假设
· 现实中很多遗留代码并不「纯粹」(在渲染中改外部变量)
· 这些组件会被跳过 → 优化效果不如预期
② 不能解决「结构性问题」
· Context 的全量重渲染(需要拆分/换库)
· 大列表的 DOM 数量(需要虚拟滚动)
· 首屏包体积(需要代码分割)
· 状态放太高(需要状态下移)
③ 构建产物变化
· 生成的代码依赖 useMemoCache(React 内部的 Hook)
· 需要一个兼容的 React 版本(React 19 原生支持;React 17/18 需要 patch 包)
· 调试时看到的代码与源码不完全一致(需要 source map)
④ 工具链集成
· 需要 Babel/SWC 的接入(Next.js、Vite 有插件支持)
· 与其它 Babel 插件的顺序可能需要注意
⑤ 「默认开启」的变化
· 某些原本依赖「每次渲染都重新创建」的代码可能行为不同
(如「每次渲染都 new 一个实例」的第三方库用法)
→ 需要测试覆盖
八、迁移建议
① 先在「一部分组件」上试用
· 用配置的 include/exclude 或目录范围限定
· 观察 Profiler 与业务指标变化
② 先修「不纯的代码」
· 打开 react-compiler ESLint 规则 → 修复提示的问题
· 这本身就有价值(符合 React 规则 = 更可预测)
③ 保留「必要的」手写优化
· Context 的 useMemo(value)
· 结构性优化(children 透传、状态下移)
· 这些编译器不会帮你做
④ 不要为「配合编译器」而写奇怪的代码
· 目标是「写符合 React 规则的朴素代码」
⑤ 注意兼容性
· React 版本(19 原生支持;旧版本需要 runtime 包)
· 构建工具插件版本
· 测试覆盖(尤其是「依赖每次新建实例」的第三方集成)
⑥ 度量收益
· 用 Profiler 对比「开启前后的渲染次数/耗时」
· 如果收益不明显,可能是「不纯组件太多」或「瓶颈根本不在渲染」
// ③ 示例:这些优化即使有编译器也仍然需要
function Provider({ children }: { children: ReactNode }) {
const [state, setState] = useState(initial);
// ⭐ Context value 仍然要主动 useMemo(编译器不改变 Context 的订阅粒度)
const value = useMemo(() => ({ state, setState }), [state]);
return (
<Ctx.Provider value={value}>
{children} {/* ⭐ children 透传仍然是结构层面的优化 */}
</Ctx.Provider>
);
}
九、常见坑
// ① 以为「有编译器就不需要关心性能了」
// → 它只做「局部记忆化」,结构性问题(Context/虚拟滚动/分包)仍需自己做
// ② 在渲染中修改外部变量(导致组件被跳过,且本身就是 bug)
let cache = new Map();
function Comp({ key }: { key: string }) {
cache.set(key, Date.now()); // ❌ 渲染中写外部状态
}
// ③ 依赖「每次渲染都重新创建实例」的第三方用法
const editor = new Editor(); // ⚠️ 编译器可能把它缓存为一个实例
// → 若你确实需要「每次渲染都新建」,要显式处理(且这通常是坏味道)
// ④ 忘记 React 版本兼容(旧版本需要 runtime 包)
// ⑤ 不用 ESLint 插件 → 不知道哪些组件被跳过
// ⑥ 期望编译器能优化「Context 引起的大范围重渲染」
// ⑦ 把「不要写 useMemo」理解成「useMemo 已废弃」
// → 它仍然有效、仍然需要(尤其在编译器没覆盖到的地方)
// ⑧ 在「渲染中读 Date.now()」做时间显示(不纯 → 跳过)
// ✅ 用 useEffect + state 更新,或用外部数据源
// ⑨ 编译器开启后「组件行为变化」却没有测试覆盖
// ⑩ 认为「编译器能解决 memo 失效问题」
// → 它能减少这类问题,但跨组件的引用传递仍需理解
// ⑪ 在编译目标里排除了大量文件却没意识到
// ⑫ 把「编译器」与「React 19」混为一谈(Compiler 是独立工具,React 19 提供原生支持)
十、面试延伸
- 「React Compiler 是什么?」
它是 React 团队提供的「编译时自动记忆化」工具(原名 React Forget)。它作为 Babel/SWC 插件在构建时分析组件代码,自动判断「哪些值/对象/函数是稳定的」并插入缓存逻辑——效果等价于你手写useMemo/useCallback/memo,但由编译器决定「在哪里需要」。它的目标是「让默认性能变好」,从而减少手写记忆化的机械劳动与出错概率。 -
「它和
useMemo/useCallback是什么关系?」
互补而非替代。编译器覆盖的是「局部记忆化」——对象字面量、内联函数、派生计算等;而下列场景仍然需要手动处理:① Context 的 value(编译器不改变 Context 的订阅粒度);② 结构性优化(children透传、状态下移、虚拟滚动、代码分割);③ 编译器覆盖不到的地方(如它无法判断「第三方库函数是否纯」而跳过)。所以「useMemo没有被废弃」,只是使用频率会下降。 -
「Compiler 依赖什么前提?」
它强依赖「组件/Hook 是纯函数」这一 React 规则,具体包括:① 不在渲染中修改外部状态(外部变量、DOM、存储、ref.current);② 不修改 props/state 的对象;③ 遵循 Hook 规则(顶层调用、顺序稳定);④ 不在渲染中读取会变化的外部值(全局变量、Math.random()、Date.now())。违反规则的组件会被「跳过」(仍能正常工作,只是不获得记忆化),ESLint 插件(eslint-plugin-react-compiler)会提示这些位置。 -
「被跳过的组件会出错吗?」
不会。编译器遇到「无法证明是纯的」代码时会保守地跳过该组件——它只是不做记忆化,代码行为与之前一致。所以启用编译器是「安全的」:最坏情况是「没有优化到」。不过要注意一种情况:如果代码依赖「每次渲染都重新创建某个实例」(比如某些第三方库的用法),而编译器恰好把它缓存了,行为可能变化——这类场景需要有测试覆盖。 -
「Compiler 能解决哪些性能问题?不能解决哪些?」
能解决:局部记忆化——避免「每次渲染都重新创建对象/函数/数组」导致的memo失效与useEffect反复执行(即 5.8 里的大部分问题)。不能解决:① Context 的全量重渲染(订阅粒度不变);② 大列表的 DOM 数量(需要虚拟滚动);③ 首屏包体积(需要代码分割);④ 状态放太高/渲染范围过大(需要状态下移、组件拆分、children透传);⑤ 非 React 的瓶颈(布局抖动、长任务)。一句话:它优化「局部」,不解决「结构」。 -
「怎么判断要不要启用它?」
三个考虑:① 代码库是否「符合 React 规则」——先用 ESLint 插件扫描,如果不纯的组件很多,收益会打折(且修复本身有价值);② 瓶颈是否在「渲染」——如果用 Profiler 发现瓶颈是「Context 重渲染」「DOM 数量」「包体积」,编译器帮不上忙;③ 工具链与版本——React 19 原生支持,旧版本需要额外的 runtime 包,且需要验证构建与测试。建议先在小范围试用并度量,再决定全量启用。 -
「有了 Compiler,还需要理解渲染机制吗?」
需要,而且更重要。因为:① 编译器只覆盖「局部记忆化」,结构性问题(Context、虚拟滚动、代码分割、状态下移)仍要自己判断;② 「为什么组件会重渲染」「引用稳定为什么重要」这些理解是「用 Profiler 找瓶颈」的基础;③ 编译器会跳过不纯的组件,你需要能识别「哪些代码不纯」;④ 跨组件的引用传递、key、列表优化等它管不了。编译器减少的是「机械劳动」,不是「理解成本」。 -
「React Compiler 与 Vue 的编译时优化有什么区别?」
有相似之处也有本质差异:① Vue 的编译时优化(PatchFlags、Block Tree、静态提升)主要针对「模板结构」——它在编译时就知道「哪个节点是动态的」,从而在运行时跳过大量 diff;② React Compiler 主要针对「组件内部的记忆化」——它不改变「React 运行时 diff」的机制,而是减少「每次渲染都重新创建值」带来的无效更新。换句话说:Vue 优化的是「更新时怎么比」,React Compiler 优化的是「渲染时创建了什么」。
一句话速记
React Compiler 是「编译时自动记忆化」的工具(Babel/SWC 插件),效果等价于自动写
useMemo/useCallback/memo,让代码保持朴素;它强依赖「组件是纯函数」(在渲染中改外部变量、改 props、读Date.now()等会让组件被跳过——跳过不等于报错,只是没优化);它只覆盖「局部记忆化」,不解决结构性问题(Context 订阅粒度、DOM 数量、包体积、状态放太高);所以useMemo未废弃,理解渲染机制仍必要;建议先用eslint-plugin-react-compiler修不纯代码,再小范围试用并度量;React 19 提供原生支持,旧版本需要 runtime 包。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!