React Compiler 上手实践:我把它接进老项目后,哪些 useMemo 真的可以删了

我们这个后台项目跑了五年多,React 从 16 一路升到 19。三年里为了压渲染,团队往代码里塞了不知道多少 useMemouseCallbackReact.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 依赖),这种要单独评估,不能无脑删。