React Compiler 上手实践:我把它接进老项目后,哪些 useMemo 真的可以删了
我们这个后台项目跑了五年多,React 从 16 一路升到 19。三年里为了压渲染,团队往代码里塞了不知道多少 useMemo、useCallback 和 React.memo。有的确实有用,更多的是当年某个同事看 profiler 飘红就顺手包了一层,谁也不敢删——删了万一掉帧呢?于是这些 memo 化的代码越堆越多,组件里一半篇幅都在处理依赖数组,可读性差到新人接手第一句话就是"这个 useCallback 是干嘛的"。
升到 React 19 之后,我决定把 React Compiler 接进来试试,顺便看看能不能把这堆历史包袱清掉一部分。哪些手写 memo 真的可以删、哪些写法会让编译器直接撂挑子不干、上线前又是怎么确认它没把行为改坏的——都是接下来实际踩过才知道的。
React Compiler 到底替我做什么
先说清楚它是什么。React Compiler 就是之前喊了好几年的 React Forget。有个容易混淆的地方要分清:React 19 在 2024 年底发布,React Compiler 1.0 稳定版是 2025 年 4 月单独发布的,它不是 React 19 发布时自动附带的开关。它是一个构建期的编译器,在你写的组件被打包之前介入,自动分析每个组件和 Hook 的渲染逻辑,把那些"输入没变就不该重算"的值和函数自动缓存起来。
换句话说,过去我手动做的事情——用 useMemo 缓存一个昂贵计算、用 useCallback 稳住一个传给子组件的回调、用 memo 包住一个不想跟着父组件重渲染的子组件——这些它现在能在编译期自动帮我做掉,而且做得比人更细。人只会在"看起来贵"的地方包 memo,编译器是把组件里每一个可缓存的中间值都缓存了。
我最关心的一个问题是:那我之前手写的那些还要不要留着?答案是大部分可以删。编译器生成的缓存比手写的更全面,手写的 useMemo/useCallback 在它眼里只是普通代码,它会照样分析、照样优化。留着它们不会出错,但属于冗余——既然编译器已经把整个组件缓存好了,外层那层手动 memo 就是噪音。
不过有个前提:能不能删,取决于编译器是否真的接管了这个组件。如果某个组件因为写法问题被编译器跳过了(后面会讲这个 bailout),那原来的手写 memo 就还在发挥作用,删了就真的会掉性能。所以删之前必须先确认编译器生效了,这是我后面整套验证流程的核心。
怎么把它接进来
接入方式取决于你的构建工具。底层还是 Babel 插件 babel-plugin-react-compiler,但各框架都包了一层更方便的入口。
我们项目是 Vite,直接用官方的 Vite 插件:
1npm install -D babel-plugin-react-compiler @vitejs/plugin-react
vite.config.ts 里把编译器塞进 React 插件的 babel 配置:
1import { defineConfig } from 'vite' 2import react from '@vitejs/plugin-react' 3 4export default defineConfig({ 5 plugins: [ 6 react({ 7 babel: { 8 plugins: [ 9 ['babel-plugin-react-compiler', { 10 // target 指定你的 React 版本,19 是默认 11 target: '19', 12 }], 13 ], 14 }, 15 }), 16 ], 17})
如果是 Next.js(13.5 以上,App Router 项目尤其推荐),就不用碰 Babel,直接在 next.config.js 里开:
1/** @type {import('next').NextConfig} */ 2const nextConfig = { 3 experimental: { 4 reactCompiler: true, 5 }, 6} 7 8module.exports = nextConfig
裸 Babel 项目(比如自己搭的工具链)就是普通插件写法,注意它要放在插件列表的最前面,因为它需要在其它转换之前拿到接近原始的代码:
1// babel.config.js 2module.exports = { 3 plugins: [ 4 ['babel-plugin-react-compiler', {}], 5 // 其它插件放后面 6 ], 7}
配完之后开发服务器一启,编译器就开始默默工作了,肉眼看代码没任何变化——它改的是产物,不是源码。
ESLint 插件先行,别急着编译
接入编译器之前,我做的第一件事不是改构建配置,而是先装 ESLint 插件扫一遍。
1npm install -D eslint-plugin-react-compiler
1// eslint.config.js (flat config) 2import reactCompiler from 'eslint-plugin-react-compiler' 3 4export default [ 5 { 6 plugins: { 7 'react-compiler': reactCompiler, 8 }, 9 rules: { 10 'react-compiler/react-compiler': 'error', 11 }, 12 }, 13]
为什么先跑 lint?因为编译器能不能优化你的组件,前提是你的代码遵守"Rules of React"——也就是 React 的那套规矩:组件和 Hook 必须是纯函数(同样的输入给同样的输出)、不能在渲染期产生副作用、不能在渲染中 mutate props 或 state、Hook 必须无条件地在顶层调用,等等。
这套规矩以前是"建议遵守",违反了大多数时候也能跑。但编译器是认真按这套规矩来推理的:它假设你的代码是纯的,基于这个假设去缓存。如果你的代码偷偷违反了,缓存就可能给你缓存出一个过期的值,行为就错了。
编译器的保护机制是——它检测到一个组件违反规则,就直接跳过这个组件,不优化它,这叫 bailout。它不会硬上去优化一个不安全的组件,宁可放弃也不改坏你的程序。这个设计很稳,但坏处是你以为开了编译器全员加速,实际上一堆组件被静默跳过了,你还不知道。
所以 ESLint 插件的价值就是:它用的是和编译器同一套静态分析逻辑,能在编译之前就告诉你"这个组件会被 bailout,因为你这里违反了规则"。我们项目第一次跑出来一百多个 warning,挨个看下来基本就这么几类。
哪些写法会让编译器直接放弃
把实际踩到的 bailout 归一下类,这几种最常见。
渲染期间修改 ref 或外部变量。 这是我们项目最多的一种。有人喜欢在组件体里直接写 someRef.current = xxx:
1function Chart({ data }) { 2 const prevData = useRef(null) 3 // 渲染期间直接改 ref —— 编译器不喜欢 4 prevData.current = data 5 // ... 6}
ref 的 mutation 应该发生在事件处理或 effect 里,渲染期间写 ref 让编译器没法确定渲染是纯的,于是放弃。改法是把这种逻辑挪进 useEffect,或者改用 useState 配合派生逻辑。
更隐蔽的是改外部模块级变量:
1let renderCount = 0 2function Foo() { 3 renderCount++ // 渲染有副作用,bailout 4 return <div>{renderCount}</div> 5}
条件调用 Hook。 这个本来 eslint-plugin-react-hooks 就会拦,但老代码里总有漏网的:
1function Panel({ enabled }) { 2 if (enabled) { 3 const [open, setOpen] = useState(false) // 条件里调 Hook 4 } 5}
Hook 必须无条件在顶层调,这是铁律,编译器遇到这种直接跳过整个组件。
渲染中 mutate props 或 state。 比如直接往 props 传进来的数组 push:
1function List({ items }) { 2 items.push({ id: 'extra' }) // 直接改了 props,bailout 3 return items.map(/* ... */) 4}
正确做法是 const next = [...items, extra],永远产出新对象而不是改原对象。这条修完之后,顺带还修掉了好几个其它地方似有似无的诡异 bug,因为这种 mutation 本来就是隐患。
修这些 warning 的过程其实挺有价值的。它逼着我们把那些"能跑但不规范"的代码补正了,相当于一次免费的代码体检。我没有追求一次清零,而是按模块分批修,修一批确认一批组件被编译器接管。
用 DevTools 确认它真的生效了
代码改干净、构建配好之后,怎么知道某个组件到底被编译器优化了没有?光看产物太累,我用 React DevTools。
新版 DevTools 的 Components 面板里,被 React Compiler 成功优化过的组件,名字旁边会有一个带闪光小图标的 "Memo" 徽章。看到这个标记就说明编译器接管了这个组件,缓存生效。看不到,要么是没被优化,要么就是被 bailout 了。
我的做法是:删手写 memo 之前,先在 DevTools 里确认目标组件挂了 "Memo" 徽章。只有挂了标记的,我才放心把它里面的 useMemo/useCallback/memo 删掉,因为这时候编译器已经把活全接了。没挂标记的组件,手写 memo 我一律先留着,等把对应的 bailout 原因修掉、标记出现了再删。
配合 Profiler 录一段交互,对比删 memo 前后的渲染次数和耗时,确认没有变多,这一步就算闭环了。
编译产物长什么样
虽然不需要看产物,但理解一下原理有助于建立信任。编译器会给优化过的组件引入一个缓存数组,每个需要缓存的值占一个"槽位",用一个内部函数(一般编出来叫 _c)来拿这块缓存。简化后大概是这个意思:
1// 源码 2function Greeting({ name }) { 3 const greeting = expensiveCompute(name) 4 return <h1>{greeting}</h1> 5} 6 7// 编译后(示意,实际更复杂) 8import { c as _c } from 'react/compiler-runtime' 9 10function Greeting({ name }) { 11 const $ = _c(2) // 申请 2 个缓存槽 12 let greeting 13 if ($[0] !== name) { 14 greeting = expensiveCompute(name) // name 没变就不重算 15 $[0] = name 16 $[1] = greeting 17 } else { 18 greeting = $[1] 19 } 20 return <h1>{greeting}</h1> 21}
_c(n) 在组件实例上分配一块长度为 n 的缓存,编译器为每一个中间值生成"输入变了才重算、否则取缓存"的判断。可以看出它做的就是我手写 useMemo 那套事,只是粒度细到每个表达式,而且不用我维护依赖数组——依赖关系是它静态分析出来的,不会漏也不会写错。
理解了这个就明白:为什么手写 memo 可以删。因为 expensiveCompute(name) 这种调用,编译器已经自动包进缓存判断里了,我再手动 useMemo 一层纯属重复。
增量接入:先用 annotation 模式
我没敢一上来就对全项目开编译器。老项目最怕的就是大面积静默行为变化,万一某个被 bailout 漏判的组件出了问题,全量上线根本定位不到。
所以第一阶段我用了 annotation 模式(也叫 opt-in 模式)。配置里把编译器设成只编译显式标注了 "use memo" 指令的组件:
1['babel-plugin-react-compiler', { 2 compilationMode: 'annotation', 3}]
然后在确认安全的组件函数体顶部加一行指令:
1function SafeComponent({ data }) { 2 'use memo' 3 // 只有标了这行的组件才会被编译器处理 4 // ... 5}
这样我可以一个模块一个模块地放开,每放开一批就跑回归、看 DevTools 标记、删对应的手写 memo。反过来也有 'use no memo',可以临时把某个有问题的组件排除在编译之外,等修好再放回去——排查问题时很好用。
跑了大概两周,把核心模块都标完、确认稳定之后,我才把 compilationMode 去掉(默认是 'infer',全量推断),让编译器接管所有遵守规则的组件,剩下没标的也一并优化。
上线前的回归验证
这是我最当回事的一步。编译器改的是行为层面的东西,单测过了不代表交互对,所以我做了几层验证。
一是把现有的单测和组件测试全跑一遍,重点看那些涉及"重渲染才更新"逻辑的测试有没有挂。编译器缓存激进,如果有组件本来靠"每次都重新创建对象/函数"来触发某些 effect,缓存之后这些 effect 可能不再触发——这正是不纯代码会暴露的地方。挂了的测试基本都指向真实的规则违反。
二是关键路径的端到端测试跑一遍,表单提交、列表筛选、弹窗交互这些有状态流转的场景重点看。
三是灰度。我们先在内部环境全量开编译器跑了几天,让 QA 和我们自己日常用,观察有没有"点了没反应""数据不刷新"这类典型的过度缓存症状。没有,才推到生产。
四是性能对比,用 Profiler 在几个重页面上对比接入前后的渲染次数和提交耗时。我们最重的那个数据看板页,交互时的重渲染组件数量明显下降,体感卡顿也好了——这部分收益是编译器全粒度缓存带来的,手写 memo 时代根本做不到这么细。
它和现有库会不会打架
接入前我担心过和状态管理库、第三方组件的兼容性。实际跑下来,遵守 React 规则的库基本都没问题。
我们用的 Zustand、TanStack Query 这类,本身就是规规矩矩的 Hook 写法,编译器照常优化使用它们的组件,没冲突。需要留意的是那些在渲染期做了"骚操作"的库——比如某些老的样式库会在渲染期往全局对象上写东西,或者某些库依赖"每次渲染都创建新引用"来触发内部逻辑。这类如果出问题,表现就是用了它的组件被 bailout,或者行为变化。处理方式是给那个组件加 'use no memo' 单独排除,等库更新或者换掉。
还有个细节:编译器只处理你的源码,不会去编译 node_modules 里已经打包好的第三方代码。所以第三方组件该怎样还怎样,它的性能特性不受你开不开编译器影响——它管得到的只有你自己写的那部分代码。想清楚这一点,排查"为什么这个第三方组件还在频繁重渲染"时就不会找错方向。
团队怎么落地
最后说下推广。我没有一上来就要求全员改写法,而是分了三步走。
先把 eslint-plugin-react-compiler 设成 warning 级别接进 CI,让所有人写新代码时就能看到规则违反提示,但不阻断提交。跑了两周,让大家先熟悉这套规则、积累修改经验。
然后把核心模块的 bailout 集中修掉,开 annotation 模式标注上线,验证收益。这一步出成果后,团队对这事的信心就建立起来了。
最后才把 ESLint 规则提到 error 级别、编译器切到全量推断模式,并在 code review 里加一条:删手写 memo 必须附带 DevTools 截图证明组件已被编译器接管。这条规矩防的就是有人图省事乱删 memo 又没确认编译生效,把性能悄悄搞坏。
那些"不能删"的 memo 要怎么甄别
删 memo 这件事我后来形成了一条原则:先分清这个 memo 是为了"性能"还是为了"语义"。
纯性能型的最好判断。它的存在理由就是"这个计算贵,别每次重算"或者"这个回调别每次新建免得子组件白渲染"。这类编译器全包了,删就完事。
语义型的危险,它表面是 memo,实际被别人当成"引用稳定性的保证"在依赖。最典型的例子:
1function Search({ query }) { 2 // 这个 fetchData 被下面的 effect 当依赖 3 const fetchData = useCallback(() => { 4 return api.search(query) 5 }, [query]) 6 7 useEffect(() => { 8 fetchData() 9 }, [fetchData]) // 依赖了 fetchData 的引用稳定性 10}
这里 useCallback 不只是性能优化,它保证了 fetchData 只在 query 变时才换引用,从而控制 effect 的触发时机。如果我无脑删掉这个 useCallback,effect 的依赖语义就变了。
编译器接管之后,它其实也会给 fetchData 做缓存、保持引用稳定,所以多数情况删了行为不变。但我没敢全删,凡是 callback/memo 的结果被塞进了某个 useEffect/useMemo 的依赖数组的,我都单独看一遍,确认编译器的缓存语义和原来一致再删。这种地方占了"删不掉"里的一大半。
另一类是传出了编译器管辖范围的。比如一个 memo 化的值被传给了一个没有被编译器优化的第三方组件(那个组件不在我们代码里,编译器管不到),这时候手写 memo 提供的引用稳定性对那个第三方组件仍然有意义,删了可能让它白渲染。这种也得留。
关于 useEffect 不要被编译器误导
接入过程中有个认知要纠正一下:React Compiler 优化的是渲染期的缓存,它不会帮你减少 effect 的执行,也不会改 effect 的语义。这一点很容易搞混——开了编译器就以为 effect 也会跟着变少,其实不是这么回事。
实际上编译器对 effect 的依赖反而更敏感。因为它让传给 effect 的值/函数引用更稳定了,那些原本因为"每次新建对象导致 effect 反复触发"的 bug,开编译器后行为会变——有的是变好了(effect 不再乱触发),有的是暴露出你的 effect 本来就写错了(依赖列表不诚实)。
我们就遇到一个:一个组件每次渲染都新建一个 config 对象传给子组件,子组件 effect 依赖这个 config,于是 effect 每次渲染都重跑,正好"掩盖"了一个数据同步 bug——因为它一直在跑所以数据看起来总是对的。编译器把 config 缓存稳定之后,effect 不再每次跑,那个 bug 就浮出来了。这其实是好事,编译器帮我们找到了一个一直靠"频繁重跑"掩盖的真实缺陷。修法是把数据同步逻辑写对,而不是退回去让它乱跑。
这件事让我更确信一点:编译器是放大镜,它会把代码里不诚实的地方放大出来。代码越规范,接入越顺;代码越脏,接入过程中冒出来的"诡异问题"越多,但每一个深究下去都是真问题。
最后删了多少 memo
收尾时统计了一下,项目里手写的 useMemo/useCallback 删掉了大约六成,React.memo 删掉一半左右。删不掉的那部分主要是两类:一类是组件还有未修完的规则违反、暂时被 bailout,手写 memo 还得留着;另一类是个别地方的缓存语义不只是性能优化,还被当成了"引用稳定性契约"被别处依赖(比如某个 key、某个 effect 依赖),这种要单独评估,不能无脑删。