React Compiler 装上之后我把项目里的 useMemo 全删了,跑了三个后台才发现真实收益在哪
先说结论,省得你翻到底:React Compiler 值不值得开?值得。但它省下的是你写 memo 的脑力,不是你页面的帧率。我一开始也以为这是个性能大招,实际跑完之后发现完全不是那么回事。
从时间线说,React Compiler 是在 2024 年 5 月 React Conf 上开源的那会儿我第一次听说的,当时还叫 React Forget。2025 年 4 月出的 RC,1.0 正式版是 2025 年 10 月 7 号。我记得挺清楚,因为那天我正好在改一个 Vite 项目的构建配置,中午刷到 release notes,下午就上手了。
我手上三个 React 项目可以拿来试:
- 项目 A:4 年前的后台管理系统,Vite + React 18.2,90 多个页面,最重的一屏是订单表格
- 项目 B:Next.js 15 的 App Router,React 19,RSC 为主,只有少量交互组件
- 项目 C:MobX 6 + antd 4 的老古董,状态管理是历史遗留
下面挨个说,先讲怎么装,因为现在网上很多教程还是 beta 时期的写法,早就不一样了。
装之前先确认插件真的生效了
Vite 项目是这么配的:
npm i -D babel-plugin-react-compiler@latest
然后 vite.config.ts 里:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', {}]],
},
}),
],
})
这里有个坑我得说一下。@vitejs/plugin-react 走的是 Babel,所以上面这么写没问题;但如果你的项目用的是 @vitejs/plugin-react-swc,那 babel 插件是不生效的,得换 @swc/plugin-react-compiler。我第一次就搞混了,装完运行一切正常,没有任何报错,但编译率是 0。SWC 版本压根不会去加载 babel plugin,它只是静静地把你的配置忽略了。
确认有没有生效,最直接的办法是跑:
npx react-compiler-healthcheck
它会扫描你的 src 目录,输出类似这样:
Successfully compiled 1214 out of 1290 components.
Found 4 incompatible libraries.
我第一次看到 76 个组件没编译过的时候是真有点意外,因为我一直觉得自己代码写得挺规范的。
那个 lint 规则才是真正值钱的东西
安装完之后一定要打开这个:
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks'
export default [
{
plugins: { 'react-hooks': reactHooks },
rules: {
...reactHooks.configs.recommended.rules,
'react-hooks/react-compiler': 'error',
},
},
]
设成 error 之后你的编辑器会到处飘红,每一条标的都是「Compilation Skipped」的具体原因。我个人觉得这个比自动 memo 本身有价值多了,它相当于把你项目里那些脏写法全给你列出来了。
常见的几种静默跳过原因:
- 在 render 期间改了 props 或 state,比如
items.push(x)这种 - 在 render 里读写
ref.current - 有条件地调用 Hook
- 闭包外面被改了的变量
- class 组件(整个跳过,不管里面写得漂不漂亮)
重点是「静默」两个字。编译器跳过某个组件的时候不会给你任何运行时提示,不开上面那条 lint 规则的话,你根本不知道哪些组件吃上了、哪些没吃上。你以为全站都优化了,其实可能一半还是原来的样子。
项目 A:删掉 200 多行 memo 之后的真实数字
这个后台是最有代表性的一屏是订单表,默认 50 行,可以拉到 500 行。之前为了不卡,我做了这些事:columns 用 useMemo、row 组件用 React.memo 包、每个单元格的格式化函数用 useCallback。零零散散加起来删掉了大概 230 行相关代码。
跑之前我是用 DevTools Profiler 和 CDP 录的,具体数值:
- 在搜索框里连续输入 20 个字符(用 CDP 的 Input.dispatchKeyEvent 打的,不是手打,避免误差),平均每次 commit 的 render 时间从 8.4ms 变成 8.1ms,说实话这个差距在测量误差范围内
- bundle 体积(gzip 后)从 412KB 变成 409KB,基本可以忽略
- 但是 500 行表格做一次筛选,从平均 340ms 降到了 210ms
第三个数看着挺唬人,但原因很尴尬:我原来那个 useMemo 的依赖数组写错了,漏了 filter 条件。所以这不是 Compiler 变快了,是我原来那个 memo 根本就在缓存错误的结果然后被迫重新算。删掉它的那一刻它反而对了。
这件事让我对「手写 memo」这件事的信任度下降了不少。我们平时写 useMemo 的时候,很大一部分其实是心理安慰,写对了没多少收益,写错了还查不出来。
项目 B:Next 15 + RSC,收益比想象中小
Next.js 这边配置是这样的:
// next.config.js
const nextConfig = {
experimental: {
reactCompiler: true,
},
}
我记得 Next 15.x 后面某个版本开始这个配置项从 experimental 里移出来了,但具体是哪个小版本我记不清了,你按自己项目版本查一下 release notes 更靠谱。
配置本身没什么好说的,但 RSC 和 Compiler 的关系得讲清楚:Server Component 本来就没有 re-render 这回事,每次请求重新跑一遍,Compiler 对它意义不大。它主要服务的是 'use client' 那些组件。所以如果你项目是 RSC 为主的,实际能受益的面其实挺窄的,除非你的交互组件本身很重。我这个项目 B 跑完 healthcheck,编译通过率 94%,但客户端组件只占全部组件的 31%,算下来真正被优化的代码量没多少。肉眼体验上,我没感觉到任何区别。
项目 C:MobX 老项目,我劝你别急
这个项目是重灾区,MobX 6 + antd 4。MobX 的 observable 在 React Compiler 眼里就是外部可变对象,它没法安全地给你做 memo。healthcheck 通过率只有 68%,大量组件因为读了 observable 被直接跳过。
我的建议是这种项目先别上,把状态管理统一了再说。强行上也可以,你会得到一堆 lint 飘红和基本为零的收益,纯属自己给自己找事。
那什么时候还应该手写 useMemo
删了一圈下来,我自己总结的是这三种情况还得留着:
第一,要传给非 React 系统用的稳定引用。比如 IntersectionObserver、ResizeObserver 的回调、echarts 的 setOption、monaco 的实例。这些东西不被 React 管,Compiler 保证的那些 identity 语义对它们不成立,你该 useRef 就 useRef,该 useMemo 就 useMemo。
第二,真的贵的同步计算。比如解析 3 万行 CSV、对大段文本跑正则替换。Compiler 帮你 memo 了也只能省掉第二次以后的开销,第一次该跑还得跑,而且这种活本来就更应该丢进 Web Worker。
第三,需要显式表达「这块我不想让它跟着父组件变」的时候。这已经不是性能问题了,是意图表达。
至于 Context 的 value、JSX 属性里的对象字面量这些,Compiler 是能处理的,我删掉之后没出问题。前提是你那个 Provider 组件自己的 props 得稳定。
最后
跑完三个项目,我真正因为 Compiler 变快的地方只有一处,原因还是旧代码里 useMemo 依赖写错,以及一个 React.memo 包了但父组件每次传新函数导致完全失效的位置。剩下 90% 的场景,跑起来肉眼没区别。
所以如果你的页面确实卡,别急着装 Compiler。先开 Profiler,看火焰图里最长的那次 commit,看它到底在算什么。大概率你会发现瓶颈根本不在 re-render,而是某个 useEffect 里一次性 map 了 5000 条数据,或者某个 3000 行的表格没有做虚拟滚动。这种问题,装什么插件都救不了。
(写到这想起来还有个坑,Compiler 和 React.memo 一起用不冲突,React.memo 还是会在的,只是你没必要再手写了。另外如果你用了 react-virtualized 那种 children 传函数的库,情况有点复杂,改天单独写一篇。)