封面

React Compiler 1.0 实战:哪些 useMemo 可以删掉

原文:https://dev.to/parsajiravand/react-compiler-10-what-usememo-you-can-delete-hgm(作者 @parsajiravand)

打开任何 2025 年下半年之前构建的 React 代码库,你几乎会在每个组件里看到同样的防御性脚手架:这里包一个 memo(),那里加一个 useCallback,再给某个排序操作套上 useMemo——而你其实并不确定这个排序是否昂贵到值得这么做。这些代码大多不是对已测量问题的回应,而是对一种你猜测可能发生的重渲染所买的保险。

截至 React Compiler 1.0(2025 年 10 月起稳定),这场猜测游戏基本结束了。编译器会做你之前手动做的分析,比单独使用 hooks 应用得更精确,而且在某些情况下能做到手动记忆化在结构上根本做不到的事情。这是 React Deep Dive 系列的第二期,主题是"自动记忆化"到底意味着什么、它从你的代码中移除了什么,以及它刻意留给你继续决定的东西。

本文基于 React 19.2(已验证 19.2.8,撰写时 npm 上的当前发布版本)和 React Compiler 1.0(稳定的 babel-plugin-react-compiler@1.0.0 版本,于 2025 年 10 月 7 日在 React Conf 上发布——已对照 React 官方发布说明验证)。如果你想了解本文所依赖的重渲染术语体系,第一期讲了 re-render 与 remount 的区别——有用的背景知识,但不是必读前置。

你将学到什么

读完本文后,你将能够:

  • 解释 为什么 React 默认会重渲染整个子树,以及添加记忆化实际上能带来什么
  • 阅读包含 React.memouseMemouseCallback 的代码,并准确知道各自解决的是什么问题
  • 描述 React Compiler 自动化了什么,包括手动 hooks 无法解决的两个场景
  • 知道即使安装了编译器,什么时候仍然需要 useMemo/useCallback
  • 将编译器添加到项目中,并阅读它在无法安全优化时输出的 lint 诊断信息

适合谁阅读

你写过函数组件,用过 useStateuseEffect,以及 useMemo/useCallback/React.memo 中的至少一个——即使你无法完全解释为什么要用。不需要编译器内部原理或构建工具方面的经验。

目录

问题:手动记忆化无法扩展

来看一个仪表盘,上面有搜索框和下方的团队成员列表。没什么特别的:

function Dashboard({ members }) {
  const [query, setQuery] = useState("");

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <TeamRoster members={members} onInvite={(id) => sendInvite(id)} />
    </div>
  );
}

function TeamRoster({ members, onInvite }) {
  const sorted = sortByActivity(members); // 每次调用都重新计算
  return (
    <ul>
      {sorted.map((m) => (
        <MemberCard key={m.id} member={m} onInvite={() => onInvite(m.id)} />
      ))}
    </ul>
  );
}


members 在你打字时根本不会变。但每次按键都会更新 query,从而重渲染 Dashboard,进而重渲染 TeamRoster——默认情况下,在没有任何记忆化的地方,只要组件自身的状态或父组件的状态发生变化,React 就会重渲染该组件及其下方的所有内容。TeamRoster 从头重新执行 sortByActivity,构建一个全新的数组,并给每个 MemberCard 传一个全新的 onInvite 闭包。每张卡片在每次按键时都重渲染,而值其实并没有变化。

教科书式的修复方法是在边界处做记忆化:

const TeamRoster = memo(function TeamRoster({ members, onInvite }) {
  const sorted = useMemo(() => sortByActivity(members), [members]);
  const handleInvite = useCallback((id) => onInvite(id), [onInvite]);

  return (
    <ul>
      {sorted.map((m) => (
        <MemberCard key={m.id} member={m} onInvite={() => handleInvite(m.id)} />
      ))}
    </ul>
  );
});


这确实有帮助——sortByActivity 只在 members 真正变化时才重新执行。但仔细看渲染每张卡片的那行:onInvite={() => handleInvite(m.id)}。这个内联箭头函数在每次渲染时都会重新创建,不管有没有 useCallback 包裹,所以 MemberCard 每次仍然拿到一个新的 onInvite prop,仍然会重渲染。useCallback 在不重构代码的情况下无法修复这个问题——hooks 只能稳定,而这里的值是包裹 handleInvite 的箭头函数,不是 handleInvite 本身。

这就是问题的真实面貌:正确的手动记忆化要求你在每次编辑时,追踪流入每个子组件的每个值,永远如此。漏掉一处——上面那处很容易漏——你添加的记忆化就悄悄地什么也没做。

心智模型:记忆化到底带来了什么

心智模型: React 在组件自身状态变化时会重新渲染该组件,在其父组件重新渲染时也会重新渲染——无论它接收的 props 是否真的变了。记忆化并不能阻止组件因自身状态而重新渲染;它给 React 提供了一种判断子组件输入是否变化的机制,让 React 的协调器可以完全跳过该子树,而不必重新执行并比对结果。

具体来说:如果一个组件在连续两次渲染中返回的是完全相同的元素引用(不只是看起来一样的 JSX,而是内存中同一个对象),React 就会跳过该子树,不再触碰它。React.memo 通过在重新渲染子组件前比较 props 来实现这一点;useMemo/useCallback 则通过保持传入 JSX 的值稳定来实现,这样由它们构建的 JSX 也保持稳定。

React Compiler 的全部工作就是在所有可以证明安全的地方,自动产生同样的稳定性——它读取你的组件代码,逐个值判断自上次渲染以来是否可能发生变化。它不会改变组件自身状态导致重新渲染的时机,它改变的是这次重新渲染是否会级联传播到与变化无关的组件。

阶段一:无记忆化的重新渲染

运行上面的 Dashboard/TeamRoster 代码,每次按键时每个 MemberCard 都会打印一条渲染日志——你可以在下面的 playground 中亲眼看到这一过程,它运行的是真正的 React 19.2.8 运行时,而非模拟。这正是 React 按文档所描述的行为:没有记忆化就意味着没有跳过,整棵子树都会重新执行。

关键概念: 未记忆化的重新渲染不是 bug。它是 React 的默认行为,通常完全没问题——React 足够快,大多数重新渲染的开销用户根本感知不到。问题只在子树足够昂贵或足够庞大时才会出现,此时每次按键都重新执行就会变得肉眼可见。

<!-- playground:start -->

亲自试一试

[▶️ 打开交互式 playground →](https://bestpractic.org/blog/react-weekly-compiler-memoization/playground)

_直接在浏览器中运行——动手试试,实时观察概念的实际反应。_

<!-- playground:end -->

阶段二:手动修复及其隐藏的裂缝

上面的 memo/useMemo/useCallback 版本修复了昂贵的排序,但没有修复内联箭头函数,原因值得细细品味:hooks 只能在组件顶层无条件调用,所以你不能在 .map() 回调中为每个条目用 useCallback 包裹一个函数——那等于在循环中调用 hook,会违反 Rules of Hooks。仅靠 hooks 能做到的唯一修复是重构代码,通常是将点击处理函数下推到 MemberCard 中,只传递 id。

关键概念: 手动记忆化不仅繁琐,它还有真正的结构性缺口——有些情况是 useMemo/useCallback/React.memo 的任何组合都无法在不改变代码写法的前提下弥补的。这正是 React Compiler 要填补的缺口。

阶段三:同一段代码,编译后

启用 React Compiler 后,你写的是 TeamRoster 的第一个版本——没有 memo,没有 useMemo,没有 useCallback,内联箭头函数就留在读起来最自然的位置:

function TeamRoster({ members, onInvite }) {
  const sorted = sortByActivity(members);
  return (
    <ul>
      {sorted.map((m) => (
        <MemberCard key={m.id} member={m} onInvite={() => onInvite(m.id)} />
      ))}
    </ul>
  );
}


编译器在构建时分析函数体并重写它——大致来说,为 sorted 和每个卡片元素生成记忆化槽位,将它们与上次渲染的值进行比较,当没有相关变化时复用旧结果。根据 React 自己的编译器文档,无论有没有箭头函数,这种方式都能正确处理内联箭头函数的情况,因为编译器推理的是整个函数的数据流,而非对单个命名值应用 hook。

这一部分是一个模型,而非实时演示:React Compiler 是构建时的 Babel 转换,没有打包器就无法在静态 HTML 页面中运行。上面的 playground 展示的是真实的、可测量的效果——更少的重新渲染——这是在实际 React 运行时中切换"应用手动记忆化"所产生的效果;编译后的输出产生的是同样的效果,只是由这段普通代码自动为你生成。

编译器能做两件手动 hooks 在结构上做不到的事,这两点都记录在它自己的发布说明中:

  • 在提前返回之后进行记忆化。 useMemouseCallback 不能出现在条件 return 之后——这是 Rules of Hooks 的规定。编译器不是 hook,所以它可以记忆化在提前返回之后计算的值。
  • 处理上面的内联箭头函数情况,无需你做任何重构。

阶段 4:开启编译器

安装方式是添加一个开发依赖并集成到构建工具中:

npm install --save-dev --save-exact babel-plugin-react-compiler@latest
npm install --save-dev eslint-plugin-react-hooks@latest


eslint-plugin-react-hooksrecommendedrecommended-latest 预设现在直接内置了编译器的 lint 规则——在编译器进入稳定版后,这取代了原先独立的 eslint-plugin-react-compiler 包。而且这些 lint 规则即便在尚未接入编译器的项目中也能生效,因为它们本质上是在标记违反 React 规则的代码。

使用较新版本的 Vite、Next.js(15.3.1+)或 Expo(SDK 54+)脚手架创建的新项目,可以一开始就内置编译器。现有代码库则可以渐进式接入:先将编译器指向某个目录或路由,观察 lint 输出,再逐步扩大范围。

边界情况与注意事项

  • 编译器只编译组件和 Hook——不处理任意函数。 在组件内部调用的普通辅助函数会被当作一次"调用"进行记忆化,但如果同一个高开销辅助函数被三个不同组件调用,每个组件仍然各自承担一次开销;编译器的记忆化不会跨组件共享。
  • 编译器要求遵守 React 规则。 编译器假设你的组件和 Hook 是纯函数——在相同的 props/state 下幂等、渲染期间不修改 props 或 state、渲染期间不产生副作用。以编译器能静态检测到的方式违反这些规则的代码会被标记出来(通过 eslint-plugin-react-hooks 呈现,规则如 set-state-in-renderset-state-in-effect);而以 JavaScript 无法静态捕获的方式违反规则的代码,可能在编译时没有任何警告,但行为会与之前产生微妙差异。
  • 移除现有的手动记忆化并非自动安全。 React 官方的建议是保留现有的 useMemo/useCallback 调用,或者在删除前仔细测试——编译器的记忆化边界不一定与你手写的完全一致,如果其他地方的某个 effect 依赖于你的某个值在特定渲染中保持引用稳定,改变这个边界可能会影响该 effect 的触发频率。
  • 不仅支持 React 19,也支持 React 17 和 18——需要配置 target 选项并添加 react-compiler-runtime 包作为额外依赖。在 React 19 上两者都不需要。
  • 这是构建时转换,仅此而已。 没有运行时开关或 DevTools 切换选项;如果没有接入打包器配置,以上所有内容都不会对你的应用生效。

最佳实践

  • 新代码:默认不再手动记忆化。 直接写最朴素的版本。只有当你需要显式控制某个值的引用身份时才使用 useMemo/useCallback——最常见的情况是,该值是某个 effect 的依赖项,你需要确保它不会导致 effect 过度触发。
  • 在安装编译器之前就先开启编译器驱动的 lint 规则。 它们是 React 规则检查,能捕获真实的 bug(渲染期间更新状态、不安全的 ref 读取),与你是否已接入编译器无关。
  • 在现有代码库中渐进式接入。 先编译一个路由或目录,观察 lint 诊断和行为回退,再扩大范围。如果你的测试覆盖率较低,请用 --save-exact 将编译器锁定到精确版本而非 semver 范围,因为未来版本可能改变记忆化边界。
  • 不要指望编译器来修复慢函数。 如果前面提到的 sortByActivity 确实开销很大,从多个组件调用它仍然会在每个组件中重新执行——先做性能分析,如果同一个高开销调用在组件树中被重复执行,考虑自行实现缓存。
  • 把 `"use no memo"` 当作手术刀,而不是习惯。 它的作用是让某个函数退出编译,在调试编译器诊断或隔离编译器尚无法处理的代码时很有用——而不是你到处撒"以防万一"的默认做法。

FAQ

React Compiler 会取代 useEffect 吗?

不会。编译器完全只负责 memoize 渲染时的值和 JSX;effect 是你与 React 外部事物同步的方式,编译器对什么应该放进 effect 以及何时运行没有意见。

安装编译器后还需要 React.memo、useMemo 或 useCallback 吗?

默认不需要——编译器在大多数情况下自动应用等效的 memoization。它们仍然作为显式逃生舱保留,最典型的情况是:一个值被用作 effect 的依赖项,且你需要刻意保证其引用身份稳定。

现在直接删除所有已有的 useMemo/useCallback 调用安全吗?

不能自动删除。React 官方发布指南建议保留已有的 memoization,或仅在仔细测试后才移除,因为编译器生成的 memoization 边界可能与你手写的略有不同。

React Compiler 支持 React 18 或更早版本吗?

支持——它兼容 React 17 及以上版本。在 React 19 以下,你需要在编译器配置中添加 target,并依赖 react-compiler-runtime;在 React 19 上则不需要这个额外依赖。

如果我的组件违反了 React 规则会怎样?

编译器的验证流程将 React 规则编码在内,并通过 eslint-plugin-react-hooks 将违规项作为诊断信息呈现。静态可检测的违规会被标记出来,而非被静默错误编译;JavaScript 在编译时无法检测到的违规,正是 React 建议在生产环境中依赖编译器之前确保良好测试覆盖率的原因。

如何阻止编译器处理某个特定组件?

在该函数体的第一行添加 "use no memo" 指令。

速查表

跳过无关状态变化时子组件的重渲染

  • 之前(手动):React.memo(Child)
  • 使用 React Compiler 后:自动处理
  • 说明:编译器保持子组件的 JSX 引用稳定,使 React 的协调器可以跳过重渲染

稳定传递给 memoized 子组件的回调

  • 之前(手动):useCallback(fn, [deps])
  • 使用 React Compiler 后:自动处理
  • 说明:能处理直接写在 JSX 中的内联箭头函数,这是手动 hooks 做不到的

避免重复计算昂贵的渲染时值

  • 之前(手动):useMemo(() => calc(x), [x])
  • 使用 React Compiler 后:自动处理
  • 说明:仅适用于在组件或 hook 内部计算的值

Memoize 在提前返回之后定义的值

  • 之前(手动):不可能——会违反 Hooks 规则
  • 使用 React Compiler 后:自动处理
  • 说明:这是编译器相对于 hooks 的文档化优势之一

为 effect 依赖保证值的引用身份

  • 之前(手动):useMemo/useCallback
  • 使用 React Compiler 后:仍需 useMemo/useCallback
  • 说明:文档化的逃生舱——在此处继续使用

将某个函数排除在编译之外

  • 之前(手动):—
  • 使用 React Compiler 后:"use no memo" 指令
  • 说明:用于调试或与编译器不兼容的代码

启用 Rules-of-React lint 检查

  • 之前(手动):eslint-plugin-react-compiler(已废弃)
  • 使用 React Compiler 后:eslint-plugin-react-hooks@latestrecommended 预设
  • 说明:自 1.0 起直接内置编译器的 lint 规则

在 React 17/18 上运行

  • 之前(手动):—
  • 使用 React Compiler 后:添加 react-compiler-runtime + target 配置
  • 说明:React 19 两者都不需要
# Minimal install for a React 19 project
npm install --save-dev --save-exact babel-plugin-react-compiler@latest
npm install --save-dev eslint-plugin-react-hooks@latest


核心要点

  • React 默认在状态变化时重渲染组件的整个子树;memoization 的真正作用是给子组件一个稳定的引用,让 React 的协调器可以跳过对它们的重渲染。
  • React Compiler 1.0 自 2025 年 10 月起稳定,在构建时自动完成 memoization——包括 hooks 在结构上无法解决的情况,比如 JSX 中的内联箭头函数或提前返回之后定义的值。
  • 它只编译遵守 React 规则的组件和 hooks,且只 memoize 其内部的工作——不是跨组件共享的任意函数。
  • useMemo/useCallback 并未过时。在值的引用身份是正确性要求时(如 effect 依赖),继续使用它们;不要在未经测试的情况下从旧代码中移除已有的 memoization。

开头那段堆积如山的 useMemo/useCallback/memo() 包装代码之所以消失,不是因为你终于抽出手动删除了——而是因为从 React 19.2 启用编译器起,你一开始就不需要再写大部分这些东西了。那份你从来不确定会不会触发的重渲染保险单,现在由构建步骤替你承担。

你已经在真实代码库中启用编译器了吗?lint 规则有没有捕获到你意料之外的问题?

原文:https://dev.to/parsajiravand/react-compiler-10-what-usememo-you-can-delete-hgm(作者 @parsajiravand)

发布评论
全部评论(0)