React Compiler 原理与实践
React Compiler 把「手动 memo」变成编译期自动优化。它替代不了架构决策 —— 渲染一万行依然慢。
一、编译期自动加 memo
编译器做的是自动记忆化:它分析组件里哪些值依赖 props 或 state、哪些是稳定不变的,然后在需要的地方插入等价的缓存。父组件重渲染时,引用没变的子表达式与子组件就不会被重算。
它优化的是「本可以跳过却被跳过了的重渲染」,不改变渲染本身的成本。这句话是理解它的关键:一个本身就慢的组件(一次渲染要算几百毫秒),编译器救不了;但一个被无谓重渲染拖累的组件树,它能大幅改善。
// 编译器处理前(我们习惯的写法)
function List({ items, onSelect }) {
const active = useMemo(() => items.filter((i) => i.active), [items])
const handle = useCallback((id) => onSelect(id), [onSelect])
return active.map((i) => <Row key={i.id} item={i} onClick={handle} />)
}
// 开启编译器之后:直接写,缓存由编译期插入
function List({ items, onSelect }) {
const active = items.filter((i) => i.active)
return active.map((i) => <Row key={i.id} item={i} onClick={() => onSelect(i.id)} />)
}
第二段代码更干净,而且更安全——手写 useMemo 的依赖数组是会写错的,写错了要么失效要么拿到过期值,而编译器是按数据流推导的,不存在「忘写依赖」这种错误。
截至 2026 年 10 月,React Compiler 已随 1.0 进入稳定状态,并且向后兼容到 React 17——也就是说,不必先升级 React 大版本就能先用上它。
二、接进构建链的几步
顺序比速度重要,建议五步:
- 跑兼容性检查。官方提供了检查工具,会扫描代码里有多少组件能被安全优化、多少会被跳过。这一步的输出决定了要不要继续。
- 小范围开启。先在某个目录或某几个页面开启,而不是全项目一把梭。
- 用 Profiler 对比。开启前后各录一次,看目标交互的重渲染次数与耗时。没有测量的开启等于没开。
- 逐步扩大范围。确认无误后按模块推进。
- 清理冗余
useMemo/useCallback。这一步可以慢慢做,编译器与手写 memo 可以共存。
配置层面很简单,以 Next.js 为例是在配置里打开对应开关;其它构建管线则是加一个编译插件。
// next.config.js
module.exports = { reactCompiler: true }
需要提醒的是:开启编译器会带来额外的构建耗时(多一遍静态分析),产物体积也会有少量增加(插入的缓存代码)。这不是免费午餐,只是把成本从「开发者心智」挪到了「构建时间」。
三、它替代不了的架构问题
第一,组件必须是纯的。 编译器依赖 React 的规则做推导——渲染必须是纯函数:
- 不能在渲染期间修改外部变量或 props;
- 不能读取会被外部改动的可变对象(比如一个在渲染里被 push 的模块级数组);
- 副作用必须放在事件处理或 effect 里。
违反时它不会报错,而是安全地跳过这个组件:产物照常运行,只是这部分没有优化。所以「开了编译器好像没效果」的第一嫌疑,就是代码里有不纯的写法。
// 会被编译器跳过:渲染期间修改了外部变量
let renderCount = 0
function Bad() {
renderCount++ // 副作用 + 读取外部可变状态
return <span>{renderCount}</span>
}
第二,跨模块边界的引用稳定性它管不了。 如果你在一个被编译的组件里创建了新的对象,并把它传给一个没有被编译的第三方组件,那个组件仍会重渲染。编译器的优化作用在被它处理过的代码范围内。
第三,它不做架构决策。 渲染一万行依然慢、一次请求拿两兆数据依然慢、effect 里做了同步阻塞依然慢。编译器优化的是「重复计算」,不是「昂贵计算」。
四、两种编译期优化的差别
两者目标相似——都是把工作从运行时挪到编译时——但着力点不同:
- React Compiler 做的是自动记忆化:分析数据流,在需要处插入缓存,解决的是「不必要的重渲染」;
- Vue 的编译时优化做的是静态提升与补丁标记:把静态节点提出来不再比对、给动态节点打上标记让 diff 只遍历动态部分,解决的是「比对范围太大」。
一个优化的是「要不要重算」,一个优化的是「重算时比多少」。也正因为 Vue 已经有了这套编译时信息,它对运行时记忆化(手写 memo)的依赖本来就比 React 小。
编译器会跳过哪些组件,配套的 ESLint 插件会直接标出来,比事后猜快得多:
# 官方插件会指出哪些组件无法被优化,并给出原因
npx eslint --rule '{"react-compiler/react-compiler":"error"}' src/
# 常见原因:渲染期间修改了外部变量、在条件分支里调用 Hook、直接改 ref.current
五、哪些手写 memo 该留
编译器稳定之后,大部分手写 memo 可以删掉,但有三类要留:
- 作为 effect 依赖的对象。effect 的依赖数组要的是稳定引用,这个语义跟「渲染要不要跳过」不是一回事;
- 跨库边界的稳定引用。传给未编译的第三方组件或需要引用相等性的库时;
- 开销极大的计算且需要显式控制粒度。编译器按表达式粒度插入缓存,如果你需要的是「整块缓存」的语义,手写更直观。
另外,React.memo 不急着删——编译器与它可以共存,而且编译器是在表达式级插入缓存,粒度比组件级的 React.memo 更细。等确认编译器覆盖了某个文件之后再清理更稳妥。
被跳过优化的典型写法长这样——看着无害,但编译器不敢动它:
let rendered = 0
function Item({ name }) {
rendered++ // 渲染期间写外部变量 → 整段被跳过
return <li>{name}</li>
}
function Card({ user }) {
const theme = window.__theme // 读取渲染期间可变的值 → 同样跳过
return <div className={theme}>{user.name}</div>
}
六、编译器不介入的那部分
上了编译器也不该立刻删掉所有 useMemo:effect 依赖、跨库边界的引用、超大计算这三类仍然需要显式控制。
它也不会让应用快一倍。它减少的是无效重渲染,算法本身慢、渲染本身就慢的问题依然存在。
编译失败更不等于代码有 bug——违反规则时它会安全跳过该组件,产物照常运行,表现为「这部分没优化」。所以要靠 Profiler 量化前后差异,否则你不知道它到底生效了没有。
编译器替你做的是「本可以跳过却被跳过的重渲染」,它救不了本身就慢的组件——先测量,再开启,再测量。
编译器的启用方式与「什么情况下会跳过优化」,官方在 react.dev:React Compiler 里写得很明确。