陌上人如玉
公子世无双

5.10 React Compiler

5.10 React Compiler

一句话:React Compiler 是「编译时自动记忆化」的工具——它在构建阶段分析组件并自动插入缓存(相当于替你写 useMemo/useCallback/memo),让日常代码可以写得更「朴素」,但它依赖「组件是纯函数」的假设,且不能替代对渲染机制的理解。

一、它是什么

React Compiler(原名 React Forget)是一个【构建时】工具:
  · 作为 Babel / SWC 插件运行(或集成在框架里)
  · 分析组件与 Hook 的代码
  · 自动判断「哪些计算/值/函数是稳定的」,插入记忆化

它【不是】运行时库,也不改变 React 的语义——
只是把「你本来要手写的 memo」交给编译器生成。
// 你写的(朴素代码)
function ExpensiveList({ items, onSelect }: Props) {
  const sorted = [...items].sort((a, b) => a.score - b.score);
  const handleSelect = (id: string) => onSelect(id);
  const options = { sortable: true };

  return <List items={sorted} onSelect={handleSelect} options={options} />;
}
// 编译器「等价地」生成的东西(概念示意)
function ExpensiveList({ items, onSelect }: Props) {
  const $ = useMemoCache(5);                       // 内部缓存槽

  let sorted;
  if ($[0] !== items) {
    sorted = [...items].sort((a, b) => a.score - b.score);
    $[0] = items;
    $[1] = sorted;
  } else {
    sorted = $[1];
  }

  let handleSelect;
  if ($[2] !== onSelect) {
    handleSelect = (id: string) => onSelect(id);
    $[2] = onSelect;
    $[3] = handleSelect;
  } else {
    handleSelect = $[3];
  }

  let options;
  if ($[4] === undefined) {                       // 常量 → 只创建一次
    options = { sortable: true };
    $[4] = options;
  } else {
    options = $[4];
  }

  return <List items={sorted} onSelect={handleSelect} options={options} />;
}
// → 效果等价于你手写 useMemo / useCallback,但由编译器判断「哪里需要」

二、它解决什么问题

手写记忆化的三个负担:

① 心智负担
   每写一个对象/数组/函数,都要想「这个会不会让子组件重渲染?」
   → 代码里到处是 useMemo / useCallback

② 依赖数组容易写错
   漏了 → 结果过期;多了 → 完全失效;含字面量 → 失效
   → 一个隐蔽的错误就让「优化」白做

③ 难以维护
   层层 useMemo 的派生链、为稳定 props 而写的 useCallback
   → 可读性下降,重构时容易破坏

React Compiler 的目标:
   让「默认性能」变好——写朴素代码也能有记忆化,
   从而把开发者从「手写 memo」中解放出来。

三、工作原理(大致流程)

① 解析组件:把组件函数转成「控制流图 + SSA」形式
② 分析纯度:判断组件是否「符合 React 规则」(无副作用、不在渲染中改外部)
   · 如果发现「不纯」→ 【跳过该组件】(不插入记忆化)+ 输出诊断信息
③ 识别依赖:分析「每个值依赖哪些输入」(props / state / 其它缓存值)
④ 插入缓存:为「可缓存的值」生成 useMemoCache 的读写逻辑
⑤ 保留语义:不改写你的业务逻辑,只加缓存

⚠️ 关键:它【不猜测】运行时的东西——
   它只基于「静态分析」+「React 规则」来决定能缓存什么。
它能缓存的典型对象:
· 对象/数组字面量(依赖 props/state 的部分)
· 内联箭头函数
· useMemo/useCallback 的「计算结果」
· JSX 元素(部分场景)
· 组件的「派生计算」

它不能缓存的:
· 依赖「外部可变状态」的计算(无法追踪)
· 有副作用的组件(会被跳过)

四、与手动优化的关系

维度 手动 useMemo/useCallback React Compiler
时机 运行时(每次渲染比较依赖) 编译时(生成缓存读写)
精确度 由你决定(可能漏/多) 由编译器分析(通常更精确)
代码可读性 需要包裹、依赖数组 代码保持朴素
依赖 React 规则 部分(lint 检查) 强依赖(不纯就跳过)
覆盖范围 你写的每一处 编译器能分析到的部分
现状 仍然有效、仍需要理解 可选特性,逐步落地
⭐ 它不是「取代理解」,而是「减少机械劳动」:

仍然需要理解的:
① 渲染机制(为什么父组件渲染会带上子组件)
② 引用稳定(跨组件的 props 传递仍可能失效)
③ Context 的重渲染(编译器不改变 Context 的订阅粒度)
④ key / 列表 / diff
⑤ 结构性优化(状态下移、虚拟滚动、代码分割、children 透传)

编译器主要覆盖的是「局部记忆化」这一类工作。

五、启用与前置条件

# 安装编译器与 ESLint 插件
npm i -D babel-plugin-react-compiler
npm i -D eslint-plugin-react-compiler
// babel.config.js(或通过框架的配置)
module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', {
      // 可选配置(如 target、编译模式等)
    }],
  ],
};
// eslint.config.js —— 用 lint 提前发现「不纯的组件」
{
  plugins: ['react-compiler'],
  rules: {
    'react-compiler/react-compiler': 'error',
  },
}
前置条件的四条「React 规则」(编译器依赖它们):

① 组件/Hook 必须是【纯函数】
   · 相同输入 → 相同输出
   · 不在渲染中修改外部变量 / DOM / 存储
② 不在渲染中「修改 props / state 的对象」
③ 遵循 Hook 规则(顶层调用、顺序稳定)
④ 不在渲染中读取/写入「可变的外部状态」(如全局变量、随机数、时间)

若违反 → 编译器【跳过该组件】(不做记忆化)并给出诊断提示。
// ❌ 违反纯度 → 编译器会跳过这个组件
function Impure({ items }: { items: Item[] }) {
  items.push(createItem());            // ❌ 修改了 props
  return <div>{items.length}</div>;
}

// ✅ 符合规则 → 可以被优化
function Pure({ items }: { items: Item[] }) {
  const next = [...items, createItem()];   // 不改原数组
  return <div>{next.length}</div>;
}

六、它跳过什么(诊断信息)

常见「跳过原因」(ESLint 插件会提示):

① 在渲染中修改了「可变的值」
   · 修改 props / state 对象
   · 修改 ref.current(读是允许的,写在渲染中不允许)
② 调用了「未知的副作用函数」
   · 编译器无法判断某个函数是否是纯的(如第三方库的调用)
   · 但「以 use 开头的 Hook」与「命名为 setXxx 的状态更新」是已知安全的
③ 读取了「会变化的外部值」
   · 全局变量、模块级可变变量
   · `Math.random()` / `Date.now()` / `performance.now()`
④ 违反了 Hook 规则(条件调用等)

→ 被跳过的组件仍然【正常工作】,只是【不会获得自动记忆化】。
   也就是说:启用编译器不会让代码出错,只是「没优化到」。
// ② 的一个实际例子:编译器无法判断第三方函数是否纯
function Comp({ data }: Props) {
  const formatted = someThirdPartyLib.format(data);   // ⚠️ 无法判断是否纯 → 不缓存

  // ✅ 如果它确实是纯函数,可以手动 useMemo
  const formatted2 = useMemo(() => someThirdPartyLib.format(data), [data]);
}

七、限制与风险

① 强依赖「组件是纯函数」的假设
   · 现实中很多遗留代码并不「纯粹」(在渲染中改外部变量)
   · 这些组件会被跳过 → 优化效果不如预期

② 不能解决「结构性问题」
   · Context 的全量重渲染(需要拆分/换库)
   · 大列表的 DOM 数量(需要虚拟滚动)
   · 首屏包体积(需要代码分割)
   · 状态放太高(需要状态下移)

③ 构建产物变化
   · 生成的代码依赖 useMemoCache(React 内部的 Hook)
   · 需要一个兼容的 React 版本(React 19 原生支持;React 17/18 需要 patch 包)
   · 调试时看到的代码与源码不完全一致(需要 source map)

④ 工具链集成
   · 需要 Babel/SWC 的接入(Next.js、Vite 有插件支持)
   · 与其它 Babel 插件的顺序可能需要注意

⑤ 「默认开启」的变化
   · 某些原本依赖「每次渲染都重新创建」的代码可能行为不同
     (如「每次渲染都 new 一个实例」的第三方库用法)
   → 需要测试覆盖

八、迁移建议

① 先在「一部分组件」上试用
   · 用配置的 include/exclude 或目录范围限定
   · 观察 Profiler 与业务指标变化

② 先修「不纯的代码」
   · 打开 react-compiler ESLint 规则 → 修复提示的问题
   · 这本身就有价值(符合 React 规则 = 更可预测)

③ 保留「必要的」手写优化
   · Context 的 useMemo(value)
   · 结构性优化(children 透传、状态下移)
   · 这些编译器不会帮你做

④ 不要为「配合编译器」而写奇怪的代码
   · 目标是「写符合 React 规则的朴素代码」

⑤ 注意兼容性
   · React 版本(19 原生支持;旧版本需要 runtime 包)
   · 构建工具插件版本
   · 测试覆盖(尤其是「依赖每次新建实例」的第三方集成)

⑥ 度量收益
   · 用 Profiler 对比「开启前后的渲染次数/耗时」
   · 如果收益不明显,可能是「不纯组件太多」或「瓶颈根本不在渲染」
// ③ 示例:这些优化即使有编译器也仍然需要
function Provider({ children }: { children: ReactNode }) {
  const [state, setState] = useState(initial);

  // ⭐ Context value 仍然要主动 useMemo(编译器不改变 Context 的订阅粒度)
  const value = useMemo(() => ({ state, setState }), [state]);

  return (
    <Ctx.Provider value={value}>
      {children}          {/* ⭐ children 透传仍然是结构层面的优化 */}
    </Ctx.Provider>
  );
}

九、常见坑

// ① 以为「有编译器就不需要关心性能了」
// → 它只做「局部记忆化」,结构性问题(Context/虚拟滚动/分包)仍需自己做

// ② 在渲染中修改外部变量(导致组件被跳过,且本身就是 bug)
let cache = new Map();
function Comp({ key }: { key: string }) {
  cache.set(key, Date.now());        // ❌ 渲染中写外部状态
}

// ③ 依赖「每次渲染都重新创建实例」的第三方用法
const editor = new Editor();          // ⚠️ 编译器可能把它缓存为一个实例
// → 若你确实需要「每次渲染都新建」,要显式处理(且这通常是坏味道)

// ④ 忘记 React 版本兼容(旧版本需要 runtime 包)
// ⑤ 不用 ESLint 插件 → 不知道哪些组件被跳过
// ⑥ 期望编译器能优化「Context 引起的大范围重渲染」
// ⑦ 把「不要写 useMemo」理解成「useMemo 已废弃」
//    → 它仍然有效、仍然需要(尤其在编译器没覆盖到的地方)
// ⑧ 在「渲染中读 Date.now()」做时间显示(不纯 → 跳过)
//    ✅ 用 useEffect + state 更新,或用外部数据源
// ⑨ 编译器开启后「组件行为变化」却没有测试覆盖
// ⑩ 认为「编译器能解决 memo 失效问题」
//    → 它能减少这类问题,但跨组件的引用传递仍需理解
// ⑪ 在编译目标里排除了大量文件却没意识到
// ⑫ 把「编译器」与「React 19」混为一谈(Compiler 是独立工具,React 19 提供原生支持)

十、面试延伸

  1. 「React Compiler 是什么?」
    它是 React 团队提供的「编译时自动记忆化」工具(原名 React Forget)。它作为 Babel/SWC 插件在构建时分析组件代码,自动判断「哪些值/对象/函数是稳定的」并插入缓存逻辑——效果等价于你手写 useMemo/useCallback/memo,但由编译器决定「在哪里需要」。它的目标是「让默认性能变好」,从而减少手写记忆化的机械劳动与出错概率。

  2. 「它和 useMemo/useCallback 是什么关系?」
    互补而非替代。编译器覆盖的是「局部记忆化」——对象字面量、内联函数、派生计算等;而下列场景仍然需要手动处理:① Context 的 value(编译器不改变 Context 的订阅粒度);② 结构性优化(children 透传、状态下移、虚拟滚动、代码分割);③ 编译器覆盖不到的地方(如它无法判断「第三方库函数是否纯」而跳过)。所以「useMemo 没有被废弃」,只是使用频率会下降。

  3. 「Compiler 依赖什么前提?」
    它强依赖「组件/Hook 是纯函数」这一 React 规则,具体包括:① 不在渲染中修改外部状态(外部变量、DOM、存储、ref.current);② 不修改 props/state 的对象;③ 遵循 Hook 规则(顶层调用、顺序稳定);④ 不在渲染中读取会变化的外部值(全局变量、Math.random()、Date.now())。违反规则的组件会被「跳过」(仍能正常工作,只是不获得记忆化),ESLint 插件(eslint-plugin-react-compiler)会提示这些位置。

  4. 「被跳过的组件会出错吗?」
    不会。编译器遇到「无法证明是纯的」代码时会保守地跳过该组件——它只是不做记忆化,代码行为与之前一致。所以启用编译器是「安全的」:最坏情况是「没有优化到」。不过要注意一种情况:如果代码依赖「每次渲染都重新创建某个实例」(比如某些第三方库的用法),而编译器恰好把它缓存了,行为可能变化——这类场景需要有测试覆盖。

  5. 「Compiler 能解决哪些性能问题?不能解决哪些?」
    能解决:局部记忆化——避免「每次渲染都重新创建对象/函数/数组」导致的 memo 失效与 useEffect 反复执行(即 5.8 里的大部分问题)。不能解决:① Context 的全量重渲染(订阅粒度不变);② 大列表的 DOM 数量(需要虚拟滚动);③ 首屏包体积(需要代码分割);④ 状态放太高/渲染范围过大(需要状态下移、组件拆分、children 透传);⑤ 非 React 的瓶颈(布局抖动、长任务)。一句话:它优化「局部」,不解决「结构」。

  6. 「怎么判断要不要启用它?」
    三个考虑:① 代码库是否「符合 React 规则」——先用 ESLint 插件扫描,如果不纯的组件很多,收益会打折(且修复本身有价值);② 瓶颈是否在「渲染」——如果用 Profiler 发现瓶颈是「Context 重渲染」「DOM 数量」「包体积」,编译器帮不上忙;③ 工具链与版本——React 19 原生支持,旧版本需要额外的 runtime 包,且需要验证构建与测试。建议先在小范围试用并度量,再决定全量启用。

  7. 「有了 Compiler,还需要理解渲染机制吗?」
    需要,而且更重要。因为:① 编译器只覆盖「局部记忆化」,结构性问题(Context、虚拟滚动、代码分割、状态下移)仍要自己判断;② 「为什么组件会重渲染」「引用稳定为什么重要」这些理解是「用 Profiler 找瓶颈」的基础;③ 编译器会跳过不纯的组件,你需要能识别「哪些代码不纯」;④ 跨组件的引用传递、key、列表优化等它管不了。编译器减少的是「机械劳动」,不是「理解成本」。

  8. 「React Compiler 与 Vue 的编译时优化有什么区别?」
    有相似之处也有本质差异:① Vue 的编译时优化(PatchFlags、Block Tree、静态提升)主要针对「模板结构」——它在编译时就知道「哪个节点是动态的」,从而在运行时跳过大量 diff;② React Compiler 主要针对「组件内部的记忆化」——它不改变「React 运行时 diff」的机制,而是减少「每次渲染都重新创建值」带来的无效更新。换句话说:Vue 优化的是「更新时怎么比」,React Compiler 优化的是「渲染时创建了什么」。

一句话速记

React Compiler 是「编译时自动记忆化」的工具(Babel/SWC 插件),效果等价于自动写 useMemo/useCallback/memo,让代码保持朴素;它强依赖「组件是纯函数」(在渲染中改外部变量、改 props、读 Date.now() 等会让组件被跳过——跳过不等于报错,只是没优化);它只覆盖「局部记忆化」,不解决结构性问题(Context 订阅粒度、DOM 数量、包体积、状态放太高);所以 useMemo 未废弃,理解渲染机制仍必要;建议先用 eslint-plugin-react-compiler 修不纯代码,再小范围试用并度量;React 19 提供原生支持,旧版本需要 runtime 包。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.10 React Compiler

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫