React Compiler 装上之后我把项目里的 useMemo 全删了,跑了三个后台才发现真实收益在哪

🔑 关键词:React Compiler, useMemo, React 性能优化, React 19, useCallback

📖 摘要:React Compiler 1.0 已经能用了,但它到底值不值得迁?我把手上三个 React 项目(Vite 后台、Next 15 App Router、MobX 老项目)都跑了一遍,记录了编译通过率、bundle 体积、Profiler 帧时间的真实变化,顺便整理了几个网上教程没说的坑。

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 本身有价值多了,它相当于把你项目里那些脏写法全给你列出来了。

常见的几种静默跳过原因:

  1. 在 render 期间改了 props 或 state,比如 items.push(x) 这种
  2. 在 render 里读写 ref.current
  3. 有条件地调用 Hook
  4. 闭包外面被改了的变量
  5. 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 传函数的库,情况有点复杂,改天单独写一篇。)

🏷️ 标签: