React 19 内核探秘

第10章 React Compiler 深度剖析

作者 杨艺韬 · 12,520 字 · 发布于 · 更新于

第10章 React Compiler 深度剖析

本章要点

  • 手动优化的认知负担:useMemo/useCallback 泛滥背后的工程困境
  • React Compiler 的编译管线:从 Babel 插件到 HIR/ReactiveFunction 的多层中间表示
  • Rules of React:编译器正确性的核心假设与语义契约
  • 自动记忆化的实现原理:静态分析、依赖追踪与缓存槽位分配
  • 编译前后代码对比:编译器如何消除手写 useMemo/useCallback
  • 编译器的局限性与逃生舱:"use no memo" 指令与 opt-out 策略
  • 与 Vue Compiler、Svelte Compiler、Solid Compiler 的架构对比

在 React 的历史上,有一个问题困扰了社区近十年:性能优化到底应该是开发者的责任,还是框架的责任?

从 shouldComponentUpdate 到 React.memo,从 useMemo 到 useCallback,React 一直将"避免不必要的重渲染"这个任务交给开发者。这种设计哲学的好处是明确——开发者完全掌控优化的时机和粒度。但代价同样惊人:在一个中等规模的 React 项目中,你会发现 useMemo 和 useCallback 像野草一样蔓延到每一个组件,不是因为它们真正需要,而是因为开发者不确定"不加会不会出问题"。这种防御性编程不仅增加了代码量,更严重的是,它把开发者的注意力从业务逻辑拽向了框架的性能细节。

React Compiler 的诞生,标志着 React 团队对这个问题给出了一个彻底不同的答案:让编译器来做这件事。编译器在构建阶段静态分析你的组件代码,自动插入细粒度的记忆化逻辑,使得开发者可以按照最自然的方式编写 React 代码,而不必操心缓存和引用稳定性。这不是一个简单的 Babel 插件,而是一套完整的编译管线,包含自己的中间表示、类型推导、副作用分析和代码生成。本章将深入这套编译管线的每一个环节,揭示 React Compiler 在技术层面究竟做了什么,以及它为什么能做到。

10.1 为什么需要编译器:手动优化的认知负担

10.1.1 useMemo/useCallback 的泛滥

考虑一个典型的 React 组件:

function ProductList({ products, onAddToCart }: Props) {
  const sortedProducts = products
    .filter(p => p.inStock)
    .sort((a, b) => a.price - b.price);

  const handleClick = (id: string) => {
    analytics.track('product_click', { id });
    onAddToCart(id);
  };

  return (
    <div>
      {sortedProducts.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onClick={() => handleClick(product.id)}
        />
      ))}
    </div>
  );
}

这段代码逻辑清晰,可读性极佳。但一个有经验的 React 开发者会立即指出几个"性能问题":

  1. sortedProducts 每次渲染都会重新计算,即使 products 没有变化
  2. handleClick 每次渲染都是新的函数引用
  3. onClick={() => handleClick(product.id)} 每次渲染为每个 item 创建新的闭包
  4. 如果 ProductCard 被 React.memo 包裹,上述所有新引用都会导致它无法跳过重渲染

于是"优化"后的版本变成了这样:

function ProductList({ products, onAddToCart }: Props) {
  const sortedProducts = useMemo(
    () => products.filter(p => p.inStock).sort((a, b) => a.price - b.price),
    [products]
  );

  const handleClick = useCallback(
    (id: string) => {
      analytics.track('product_click', { id });
      onAddToCart(id);
    },
    [onAddToCart]
  );

  return (
    <div>
      {sortedProducts.map(product => (
        <MemoizedProductCard
          key={product.id}
          product={product}
          onClick={handleClick}
          productId={product.id}
        />
      ))}
    </div>
  );
}

const MemoizedProductCard = React.memo(ProductCard);

代码膨胀了近一倍,而且引入了新的复杂性:依赖数组是否正确?onAddToCart 的引用稳定吗?如果不稳定,是否需要在父组件也加 useCallback?这种"传染式优化"会一层一层向上蔓延,直到组件树的根部。

下图展示了手动优化的"传染式"扩散路径,一个组件的优化需求会逐层向上蔓延:

graph BT
    LEAF["ProductCard<br/>需要 React.memo"] -->|"要求 onClick 引用稳定"| MID["ProductList<br/>需要 useCallback"]
    MID -->|"要求 onAddToCart 引用稳定"| TOP["App<br/>需要 useCallback"]
    TOP -->|"要求 config 引用稳定"| ROOT["Root<br/>需要 useMemo"]
    LEAF -.->|"依赖数组遗漏<br/>= 缓存失效"| BUG1["Bug: 不必要重渲染"]
    MID -.->|"依赖数组多余<br/>= 过度缓存"| BUG2["Bug: 代码膨胀"]
    TOP -.->|"闭包引用过时<br/>= 状态不一致"| BUG3["Bug: 陈旧闭包"]
    style LEAF fill:#ffcdd2,stroke:#c62828
    style MID fill:#ffe0b2,stroke:#e65100
    style TOP fill:#fff9c4,stroke:#f9a825
    style ROOT fill:#ffecb3,stroke:#ff8f00

10.1.2 手动优化的三重困境

手动记忆化面临三个根本性的困境:

第一,正确性难以保证。 依赖数组遗漏是 React 应用中最常见的 bug 来源之一。ESLint 的 exhaustive-deps 规则能捕获一部分,但对于复杂的闭包引用和对象依赖,开发者常常不确定应该包含哪些依赖。

// 一个微妙的依赖遗漏
const processData = useCallback(() => {
  // config 是外部变量,但开发者忘了加到依赖数组
  return data.map(item => transform(item, config));
}, [data]); // ❌ 遗漏了 config

第二,粒度难以把控。 什么该 memo,什么不该 memo?这个决策需要对 React 的渲染机制有深入理解,而且往往取决于组件在树中的位置和使用频率——这些信息在编写组件时并不总是可知的。

第三,维护成本持续增长。 每次修改组件逻辑,都需要同步审视 useMemo/useCallback 的依赖数组。添加一个新的状态变量,可能需要更新三四个依赖数组。重构一个函数的参数,可能引发一连串的 useCallback 更新。

深度洞察:手动优化的本质问题不在于它"难",而在于它是一种与业务逻辑正交的关注点。当一个开发者在编写购物车的增删改查时,他不应该同时操心"这个函数引用是否稳定"。React Compiler 的核心价值,就是将这种正交关注点从开发者的认知负担中彻底移除。

10.1.3 从人工到自动:编译器的必然性

React 团队对这个问题的认知经历了几个阶段:

  1. 2018 年 10 月:React 16.6 推出 React.memo;2019 年 2 月 Hooks 随 React 16.8 落地,带来 useMemo/useCallback,将优化能力交给开发者

  2. 2021 年:React Conf 上首次提出 React Forget(React Compiler 的前身),明确承认手动优化是不可持续的

  3. 2023 年:React Labs 系列博客持续播报 Forget 的研发进展,架构从简单的 Babel 插件演进为独立编译管线

  4. 2024 年 5 月:React Compiler 在 React Conf 上开源,同年 10 月发布 Beta;此后按独立于 React 的节奏演进——2025 年 4 月 RC、2025 年 10 月发布 1.0 稳定版,并通过 react-compiler-runtime 向下兼容 React 17/18,标志着 React 进入编译时优化时代

    这条时间线以 npm 上 babel-plugin-react-compiler 的发布记录为准(https://registry.npmjs.org/babel-plugin-react-compiler 的 time 字段):首个实验版 0.0.0-experimental-4690415-20240515 发布于 2024-05-15(React Conf 当天),首个 beta 19.0.0-beta-9ee70a1-20241017 发布于 2024-10-18,首个 rc 19.1.0-rc.1 发布于 2025-04-21,1.0.0 发布于 2025-10-07;react-compiler-runtime 的 1.0.0 同日发布。注意本专栏锚定的源码树是 2024-12 的 v19.0.0 tag,覆盖不到 2025 年的这几个节点,所以此处不引源码而引发布记录。

这个演进路径揭示了一个深层道理:当一个优化模式可以被形式化描述时,它就应该被自动化。记忆化的逻辑——"如果输入没变,就返回上一次的输出"——是完全可以被机械化执行的。需要解决的核心问题只有一个:如何准确判断"输入是否变化"。

10.2 编译器架构:从 Babel 插件到独立编译管线

10.2.1 整体架构概览

React Compiler 不是一个简单的代码变换工具。它是一套完整的编译管线,包含以下阶段:

源代码 (JSX/TSX)
    │
    ▼
┌──────────────────────┐
│  1. Babel Parser      │  ── 解析为 Babel AST
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│  2. HIR 构建          │  ── 从 AST 构建高层中间表示
│     (High-level IR)   │     控制流图 + 指令序列
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│  3. 分析与验证 Pass   │  ── Rules of React 验证
│     - 类型推导         │     副作用分析
│     - 作用域分析       │     变量可变性分析
│     - 副作用推断       │
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│  4. 反应性分析        │  ── 识别响应式输入
│     (Reactivity)      │     构建依赖关系图
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│  5. 作用域构建        │  ── 划定记忆化边界
│     (Scope Building)  │     HIR → ReactiveFunction 树
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│  6. 代码生成          │  ── 输出带有缓存逻辑的代码
│     (Codegen)         │     分配 $[i] 槽位,调用 useMemoCache
└──────────────────────┘

下图以流程图的方式展示了编译管线中各阶段的输入输出关系:

flowchart TD
    SRC["JSX/TSX 源代码"] -->|"Babel Parser"| AST["Babel AST"]
    AST -->|"HIR Builder"| HIR["HIR<br/>控制流图 + 指令序列"]
    HIR -->|"分析 Pass"| AHIR["标注后的 HIR<br/>类型/副作用/作用域信息"]
    AHIR -->|"Reactivity Pass"| RHIR["响应性标注 HIR<br/>每个值标记 reactive/non-reactive"]
    RHIR -->|"Scope Builder"| SCOPE["作用域划分<br/>记忆化边界 + ReactiveFunction"]
    SCOPE -->|"Codegen"| OUT["输出代码<br/>分配缓存槽位 + useMemoCache"]
    AHIR -.->|"Rules of React 验证<br/>违规则跳过编译"| SKIP["跳过该函数<br/>保留原始代码"]
    style SRC fill:#fff3e0,stroke:#e65100
    style HIR fill:#e3f2fd,stroke:#1565c0
    style RHIR fill:#e8f5e9,stroke:#2e7d32
    style OUT fill:#f3e5f5,stroke:#6a1b9a

这套管线的设计哲学与传统编译器(如 LLVM)高度相似:通过多层中间表示逐步降低抽象层级,在每一层执行针对性的分析和变换。

10.2.2 HIR:高层中间表示

HIR(High-level Intermediate Representation)是 React Compiler 的核心数据结构。它将 JavaScript 代码转换为一种结构化的控制流图(CFG),其中每个基本块包含一系列指令:

// compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts:282(v19.0.0 树,节选)
export type HIRFunction = {
  id: string | null;
  fnType: ReactFunctionType;        // 'Component' | 'Hook' | 'Other'
  env: Environment;
  params: Array<Place | SpreadPattern>;   // 函数参数
  context: Array<Place>;            // 闭包捕获的外部变量
  returnType: Type;
  body: HIR;                        // 函数体:控制流图
  directives: Array<string>;        // 'use memo' / 'use no memo' 等
  // ...loc / effects / generator / async
};

export type HIR = {
  entry: BlockId;
  blocks: Map<BlockId, BasicBlock>; // 所有基本块
};

export type BlockKind = 'block' | 'value' | 'loop' | 'sequence' | 'catch';

export type BasicBlock = {
  kind: BlockKind;
  id: BlockId;
  instructions: Array<Instruction>;
  terminal: Terminal;               // 分支、跳转或返回
  preds: Set<BlockId>;              // 前驱块
  phis: Set<Phi>;                   // SSA 合并点(见下文)
};

export type Instruction = {
  id: InstructionId;
  lvalue: Place;                    // 结果存储位置
  value: InstructionValue;          // 指令的具体操作
  loc: SourceLocation;              // 源码位置(用于报错与 source map)
};

// Place 是 HIR 中的"变量"概念
export type Place = {
  kind: 'Identifier';
  identifier: Identifier;
  effect: Effect;                   // 该位置的副作用标注(read / capture / mutate / freeze ...)
  reactive: boolean;                // 是否是响应式值
  loc: SourceLocation;
};

为什么不直接在 Babel AST 上做分析?因为 JavaScript 的 AST 是面向语法结构的树状表示,而编译器需要的是面向数据流和控制流的图状表示。例如,一个 if-else 语句在 AST 中是嵌套的节点,但在 HIR 中被展开为多个基本块和条件跳转——后者更适合做数据流分析。

// 原始代码
function Example({ items, filter }) {
  const filtered = items.filter(i => i.active);
  const label = filter ? `Active (${filtered.length})` : 'All';
  return <Header label={label} count={filtered.length} />;
}

// 对应的 HIR 伪代码(简化表示;指令名取自 HIR.ts 的 InstructionValue.kind)
// Block 0:
//   $0 = LoadLocal items          (参数本身就是 Place)
//   $1 = FunctionExpression (i => PropertyLoad i.active)
//   $2 = MethodCall $0.filter($1)
//   $3 = LoadLocal filter
//   Branch $3 → Block1, Block2   (三元表达式:terminal 为 ternary,其 test 块以 branch 结束)
//
// Block 1 (truthy):
//   $4 = PropertyLoad $2.length
//   $5 = TemplateLiteral `Active (${$4})`
//   Goto → Block3
//
// Block 2 (falsy):
//   $6 = Primitive 'All'
//   Goto → Block3
//
// Block 3 (merge):
//   phis: $7 = Phi(Block1: $5, Block2: $6)   ← label 的值取决于执行路径(enterSSA 之后才出现)
//   $8 = PropertyLoad $2.length
//   $9 = JsxExpression Header { label: $7, count: $8 }
//   Return $9

注意 Phi 节点——这是 SSA(Static Single Assignment)形式中的经典概念。当一个变量在不同的控制流路径中被赋予不同的值时,合并点需要一个 Phi 节点来统一。这使得数据流分析可以精确地追踪每个值的来源。

10.2.3 指令类型与语义

HIR 中的指令类型涵盖了 JavaScript 和 React 特有的操作:

// HIR.ts 的 InstructionValue 联合类型(v19.0.0 树,节选并省略 loc)
type InstructionValue =
  // 基础操作
  | { kind: 'Primitive'; value: number | boolean | string | null | undefined }
  | { kind: 'LoadLocal'; place: Place }
  | { kind: 'StoreLocal'; lvalue: LValue; value: Place }
  | { kind: 'LoadGlobal'; binding: NonLocalBinding }

  // 属性操作
  | { kind: 'PropertyLoad'; object: Place; property: string }
  | { kind: 'PropertyStore'; object: Place; property: string; value: Place }
  | { kind: 'ComputedLoad'; object: Place; property: Place }

  // 函数调用
  | { kind: 'CallExpression'; callee: Place; args: Array<Place | SpreadPattern> }
  | { kind: 'MethodCall'; receiver: Place; property: Place; args: Array<Place | SpreadPattern> }
  | { kind: 'FunctionExpression'; loweredFunc: LoweredFunction; ... }

  // React 特有
  | { kind: 'JsxExpression'; tag: Place | BuiltinTag; props: Array<JsxAttribute>; children: Array<Place> | null }
  | { kind: 'JsxFragment'; children: Array<Place> }

  // 解构 / 数组 / 对象 / 模板字符串
  | { kind: 'Destructure'; lvalue: LValuePattern; value: Place }
  | { kind: 'ArrayExpression'; elements: Array<Place | SpreadPattern | Hole> }
  | { kind: 'ObjectExpression'; properties: Array<ObjectProperty | SpreadPattern> }
  | { kind: 'TemplateLiteral'; subexprs: Array<Place>; quasis: Array<{raw: string; cooked?: string}> }
  // ...另有 Await、GetIterator、PostfixUpdate、TypeCastExpression、UnsupportedNode 等

// 注意两点:
// 1. Phi 不是 InstructionValue,而是挂在 BasicBlock.phis 上:
//    type Phi = { kind: 'Phi'; place: Place; operands: Map<BlockId, Place> };
// 2. 没有专门的 'HookCall' 指令。Hook 调用只是普通 CallExpression,
//    编译器靠类型推导(inferTypes)识别 callee 类型,再用
//    getHookKind(env, identifier) 取出 'useState' | 'useEffect' | ... 等 HookKind

下图展示了编译器对不同 Hook 的语义识别策略:

graph LR
    subgraph 响应式源["响应式源(Reactive Source)"]
        US["useState 返回值"]
        UC["useContext 返回值"]
        UR["useReducer 返回值"]
        PP["props 参数"]
    end
    subgraph 副作用区域["副作用区域(Effect Zone)"]
        UE["useEffect 回调"]
        ULE["useLayoutEffect 回调"]
        REF["useRef.current 赋值"]
    end
    subgraph 记忆化目标["记忆化目标(Memo Target)"]
        JSX["JSX 表达式"]
        COMP["计算值"]
        CB["回调函数"]
    end
    响应式源 -->|"依赖追踪"| 记忆化目标
    记忆化目标 -->|"缓存槽位分配"| CACHE["useMemoCache(N)"]
    副作用区域 -->|"Hook 调用本身每次执行"| SKIP["回调与依赖数组仍被缓存<br/>(见 10.4.4)"]
    style 响应式源 fill:#e3f2fd,stroke:#1565c0
    style 副作用区域 fill:#ffcdd2,stroke:#c62828
    style 记忆化目标 fill:#c8e6c9,stroke:#2e7d32

编译器对 Hook 调用做了特殊处理。它并不靠专门的指令类型,而是在 inferTypes 阶段把 useState、useEffect 等全局绑定推导为内置的 Hook 类型(HIR/Globals.ts 里用 addHook(DEFAULT_SHAPES, {hookKind: 'useState', ...}) 逐个注册的 shape),随后各个 Pass 用 getHookKind 读出这一语义——例如 Hook 调用的返回值一律视为响应式(Hook 可能读到 state / context),只有 isStableType 认定的稳定值除外(useState 的 setter、useReducer 的 dispatch、useActionState 的 dispatch、useRef 的 ref 对象、useTransition 的 startTransition);useEffect 等的回调参数则以 Freeze 效应处理,成为不能再被修改的值。

10.2.4 从 HIR 到反应性标注

HIR 构建完成后,编译器执行一系列分析 Pass,其中最关键的是反应性分析(Reactivity Analysis)。这个 Pass 的目标是回答一个核心问题:函数中的哪些值依赖于可能变化的输入(即"响应式值")?

// 反应性传播的核心逻辑(对 Inference/InferReactivePlaces.ts 的概念性简化)
function inferReactivePlaces(fn: HIRFunction): void {
  // 第一步:标记初始响应式源
  // - 函数参数(props)是响应式的
  // - 任何 Hook 调用的返回值都是响应式的(Hook 可能读到 state / context)
  for (const param of fn.params) {
    param.reactive = true;
  }

  for (const [, block] of fn.body.blocks) {
    for (const instr of block.instructions) {
      // 真实判定:value.kind 为 CallExpression/MethodCall,且
      // getHookKind(fn.env, callee.identifier) != null 或 callee 是 use()
      if (isHookCall(fn.env, instr.value) && !isStableType(instr.lvalue.identifier)) {
        instr.lvalue.reactive = true;
      }
    }
  }

  // 第二步:前向传播,直到不动点
  // - 输入含响应式值 → 输出也是响应式的
  // - 在一条含响应式操作数的指令里被修改的值 → 也变为响应式(可能捕获了响应式引用)
  // - 基于响应式条件做的条件赋值(phi)→ 结果响应式
  let changed = true;
  while (changed) {
    changed = false;
    for (const [, block] of fn.body.blocks) {
      for (const instr of block.instructions) {
        if (!instr.lvalue.reactive && hasReactiveInput(instr)) {
          instr.lvalue.reactive = true;
          changed = true;
        }
      }
    }
  }
}

这个传播过程类似于类型系统中的类型推导——从已知的"响应式源"出发,沿着数据流边传播到所有依赖的值。最终,每个 Place 都会被标注为"响应式"或"非响应式"。

深度洞察:反应性分析的本质是一种抽象解释(Abstract Interpretation)。编译器不运行代码,而是在一个简化的抽象域上"模拟"程序的执行,追踪"值是否可能因为 props/state 变化而变化"这一属性。这与 Vue 3 的响应式系统在运行时追踪依赖的目标相似——只不过 React Compiler 在编译时完成,而 Vue 在运行时完成。

10.3 React 的规则(Rules of React):编译器的核心假设

10.3.1 为什么编译器需要规则

React Compiler 不是万能的。它的正确性建立在一组关于 React 代码行为的假设之上,这些假设被称为 Rules of React。如果你的代码违反了这些规则,编译器可能会产生错误的优化。

这类似于 C 语言中的"未定义行为"(Undefined Behavior)——编译器基于"程序员不会触发未定义行为"的假设来做优化。React Compiler 也基于"开发者遵守 React 规则"的假设来插入记忆化。

10.3.2 核心规则清单

规则一:组件和 Hook 必须是幂等的。

给定相同的输入(props、state、context),组件必须返回相同的输出。这意味着在渲染过程中不能有可观察的副作用。

// ✅ 幂等:相同输入产生相同输出
function Greeting({ name }: { name: string }) {
  return <h1>Hello, {name}</h1>;
}

// ❌ 非幂等:每次渲染产生不同结果
function Timestamp() {
  return <span>{Date.now()}</span>; // 每次调用 Date.now() 结果不同
}

// ❌ 非幂等:渲染时修改外部变量
let renderCount = 0;
function Counter() {
  renderCount++; // 副作用!
  return <span>Rendered {renderCount} times</span>;
}

编译器如何利用这条规则?如果组件是幂等的,那么当输入未变时,编译器可以安全地跳过重新执行,直接返回缓存的结果。

规则二:Props 和 State 是不可变的。

React 的心智模型要求 props 和 state 被视为不可变快照。修改它们必须通过创建新对象的方式。

// ✅ 正确:创建新数组
function handleAdd(item: Item) {
  setItems(prev => [...prev, item]);
}

// ❌ 错误:直接修改 props
function BadComponent({ items }: { items: Item[] }) {
  items.push({ id: 'new' }); // 直接修改了 props!
  return <List items={items} />;
}

// ❌ 错误:直接修改 state
function BadMutation() {
  const [user, setUser] = useState({ name: 'Alice' });
  const handleClick = () => {
    user.name = 'Bob'; // 直接修改了 state 对象
    setUser(user);     // 同一个引用,React 会认为没有变化
  };
  return <button onClick={handleClick}>{user.name}</button>;
}

这条规则对编译器至关重要。编译器生成的代码用浅比较(!== 严格不等)来判断值是否变化。如果代码直接修改对象而不创建新引用,编译器的缓存判断就会失效。

规则三:传递给 Hook 的值是不可变的。

一旦一个值被传递给 Hook(如 useMemo 的依赖数组),它就不应该被后续修改。

// ❌ 危险:修改了传递给 Hook 的值
function Problematic({ config }: { config: Config }) {
  // config 被传给 useEffect 的依赖
  useEffect(() => {
    initializeSDK(config);
  }, [config]);

  // 然后修改了 config(如果 config 来自可变源)
  config.debug = true; // 这会影响已经捕获了 config 的 Effect
}

规则四:JSX 的返回值是不可变的。

创建的 JSX 元素不应该被后续修改。React 假设虚拟 DOM 树一旦构建就是固定的。

// ❌ 不要修改已创建的 JSX 元素
function Bad() {
  const element = <div className="base" />;
  element.props.className = 'modified'; // 绝对不要这样做
  return element;
}

10.3.3 编译器如何验证规则

React Compiler 内置了一套静态分析 Pass 来检测违反规则的代码模式:

// 编译器的验证 Pass(概念性实现)
function validateRulesOfReact(hir: HIR): Diagnostic[] {
  const diagnostics: Diagnostic[] = [];

  for (const block of hir.body) {
    for (const instr of block.instructions) {
      // 检测 1:渲染阶段是否修改了外部变量
      if (isMutatingExternalVariable(instr)) {
        diagnostics.push({
          severity: 'error',
          message: 'Writing to a variable defined outside a component or hook is not allowed. Consider using an effect',
          loc: instr.loc,
        });
      }

      // 检测 2:是否修改了 props
      if (isMutatingProps(instr, hir.params)) {
        diagnostics.push({
          severity: 'error',
          message: 'Mutating component props or hook arguments is not allowed. Consider using a local variable instead',
          loc: instr.loc,
        });
      }

      // 注意:并不检测 Date.now()、Math.random() 这类非确定性调用(见 10.6.1)
    }
  }

  return diagnostics;
}

当编译器检测到无法安全优化的代码时,它有两种策略:

  1. 局部降级:对无法分析的部分跳过记忆化,其余部分继续优化
  2. 完全跳过:对整个函数放弃编译器优化,保持原始行为(默认 panicThreshold: 'none' 时只通过 logger 上报 CompileError,构建照常进行;设为 'critical_errors' 或 'all_errors' 则直接抛错)

这种"保守策略"确保了编译器不会因为它检测到的问题而引入行为变更——最坏的情况是某些优化没有被应用,而不是产生错误的代码。没被检测到的违规则不在此列(见 10.6.1)。

10.4 自动记忆化的实现原理:静态分析 + 依赖追踪

10.4.1 作用域划分:确定记忆化边界

编译器的核心任务是将代码划分为若干"记忆化作用域"(memoization scopes),每个作用域对应一段可以被缓存的计算。

function UserProfile({ user, theme }: Props) {
  // 作用域 A:依赖 user.name
  const displayName = user.name.toUpperCase();

  // 作用域 B:依赖 user.posts
  const recentPosts = user.posts.slice(0, 5);

  // 作用域 C:依赖 theme
  const style = { color: theme.primary, fontSize: theme.size };

  // 作用域 D:依赖 displayName, recentPosts, style(即依赖 A, B, C 的输出)
  return (
    <div style={style}>
      <h1>{displayName}</h1>
      <PostList posts={recentPosts} />
    </div>
  );
}

编译器如何确定作用域边界?依据不是"是否有响应式输入",而是值的分配与可变区间(mutable range):

// 作用域构建的核心思路(对 ReactiveScopes/InferReactiveScopeVariables.ts 的概念性简化)
function inferReactiveScopeVariables(fn: HIRFunction): void {
  // 第一步:找出"会分配新值"或"之后还会被修改"的值,
  // 把在同一条指令里一起被修改的值并进同一个不相交集合
  const sets = new DisjointSet<Identifier>();
  for (const [, block] of fn.body.blocks) {
    for (const instr of block.instructions) {
      const range = instr.lvalue.identifier.mutableRange;
      // mayAllocate 对 BinaryExpression、TemplateLiteral、PropertyLoad 等返回 false:
      // 只产出原始值、不分配新对象的指令不会自成作用域
      if (range.end > range.start + 1 || mayAllocate(fn.env, instr)) {
        sets.union([instr.lvalue.identifier, ...mutableOperands(instr)]);
      }
    }
  }

  // 第二步:每个集合成为一个 ReactiveScope,range 取成员可变区间的并集。
  // 依赖此时还是空的:后续的 PropagateScopeDependenciesHIR 才填入依赖;
  // 相邻/重叠作用域的合并、不逃逸作用域的裁剪(PruneNonEscapingScopes)也在后续 Pass 里完成
  sets.forEach((identifier, group) => assignScope(identifier, group));
}

// 真实类型见 HIR.ts 的 ReactiveScope(v19.0.0 树,节选):
type ReactiveScope = {
  id: ScopeId;
  range: MutableRange;                                   // 覆盖的指令区间 [start, end)
  dependencies: ReactiveScopeDependencies;               // 该作用域依赖的响应式值(可精确到属性路径)
  declarations: Map<IdentifierId, ReactiveScopeDeclaration>; // 产出的值(供后续作用域使用)
  reassignments: Set<Identifier>;
  earlyReturnValue: {value: Identifier; loc; label: BlockId} | null;
  merged: Set<ScopeId>;
  loc: SourceLocation;
};
// 缓存槽位不存在作用域对象上:codegen 阶段按依赖数 + 产出数(含 reassignments)为每个作用域顺序分配 $[i]

10.4.2 依赖追踪:精确到属性级别

React Compiler 的一个重要特性是属性级别的依赖追踪。它不仅追踪"是否依赖 user",还追踪"依赖 user 的哪个属性":

function UserCard({ user }: { user: User }) {
  // 模板字符串只产出原始值:编译器不缓存它,每次渲染照常拼接
  const greeting = `Hello, ${user.name}`;

  // 只依赖 user.avatar —— user.name 变了不需要重新计算
  const avatarUrl = getAvatarUrl(user.avatar);

  return (
    <div>
      <img src={avatarUrl} />
      <span>{greeting}</span>
    </div>
  );
}

在编译器的 HIR 中,这会被分析为:

getAvatarUrl(...) 调用的依赖:{ user.avatar }   ← 而不是整个 user
$greeting:不建作用域,每次渲染重新拼接
<img> 的依赖:{ $avatarUrl };<span> 的依赖:{ $greeting };外层 <div> 依赖这两个 JSX

这种精细度意味着,如果只有 user.email 变化,而 user.name 和 user.avatar 不变,getAvatarUrl 不会被重新调用;greeting 虽然重新拼接,但拼出的字符串与上次 === 相等,<span> 和整棵 JSX 依然命中缓存。

10.4.3 缓存槽位分配与 useMemoCache

编译器使用一个特殊的内部 Hook——useMemoCache——来管理所有的缓存。这个 Hook 接受一个数字参数,表示需要的缓存槽位数量,返回一个持久化的数组:

// packages/react-reconciler/src/ReactFiberHooks.js:1202(React 19.0.0,精简)
function useMemoCache(size: number): Array<any> {
  let memoCache = null;
  // 快路径:wip fiber 的 updateQueue 上已有 memoCache
  let updateQueue = currentlyRenderingFiber.updateQueue;
  if (updateQueue !== null) {
    memoCache = updateQueue.memoCache;
  }
  // 否则从 current fiber 克隆(copy-on-write,逐个槽位数组 slice)
  if (memoCache == null) {
    const current = currentlyRenderingFiber.alternate;
    const currentMemoCache = current?.updateQueue?.memoCache;
    if (currentMemoCache != null) {
      memoCache = {
        data: currentMemoCache.data.map(array => array.slice()),
        index: 0,
      };
    }
  }
  // 最后兜底:全新缓存
  if (memoCache == null) {
    memoCache = { data: [], index: 0 };
  }
  if (updateQueue === null) {
    updateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = updateQueue;
  }
  updateQueue.memoCache = memoCache;

  // 一个组件里可以有多次 useMemoCache 调用(如内层 hook),
  // memoCache.data 是"每次调用一个数组",用 index 顺序取用
  let data = memoCache.data[memoCache.index];
  if (data === undefined) {
    data = memoCache.data[memoCache.index] = new Array(size);
    for (let i = 0; i < size; i++) {
      data[i] = REACT_MEMO_CACHE_SENTINEL;
    }
  }
  memoCache.index++;
  return data;
}

// 哨兵值(packages/shared/ReactSymbols.js):标识"尚未缓存"的槽位
export const REACT_MEMO_CACHE_SENTINEL: symbol = Symbol.for('react.memo_cache_sentinel');

真实的编译产物并不直接写 useMemoCache:Babel 插件会插入 import { c as _c } from "react/compiler-runtime"(target 为 17/18 时改为独立包 react-compiler-runtime),react/compiler-runtime 模块只做一件事——export {useMemoCache as c}。因此下文的 const $ = useMemoCache(N) 在产物里长得像 const $ = _c(N),为便于阅读本章保留 useMemoCache 这一写法。

编译器生成的代码通过比较依赖值来决定是否使用缓存:

// 编译前
function Greeting({ name, age }: Props) {
  const message = `Hello, ${name}! You are ${age}.`;
  return <p className="greeting">{message}</p>;
}

// 编译后(与编译器实际产物同构,仅保留参数解构、把 _c 写作 useMemoCache)
function Greeting({ name, age }: Props) {
  const $ = useMemoCache(2);

  // message 是模板字符串,只产出原始值、不分配新对象,
  // 编译器不为它建作用域(mayAllocate 为 false),每次渲染照常拼接
  const message = `Hello, ${name}! You are ${age}.`;

  // 缓存槽位 0 存输入(message),槽位 1 存 JSX 结果
  let t0;
  if ($[0] !== message) {
    t0 = <p className="greeting">{message}</p>;
    $[0] = message;
    $[1] = t0;
    // 注意:"greeting" 是字面量,不需要追踪
  } else {
    t0 = $[1];
  }

  return t0;
}

message 不进缓存,并不是因为"拼字符串足够便宜",而是作用域只为会分配新值的指令建立(compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/InferReactiveScopeVariables.ts 的 mayAllocate:TemplateLiteral、BinaryExpression 都返回 false)。原始值每次算出来只要相等,!== 比较照样命中下游缓存。快照测试 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/simple.expect.md 里的 const t0 = y * 10; 同样留在缓存块之外。

10.4.4 副作用的特殊处理

编译器对副作用代码非常谨慎。渲染阶段的纯计算可以被自由缓存,但副作用(如 useEffect)需要保留执行语义:

// 编译前
function DataFetcher({ url }: { url: string }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch(url).then(res => res.json()).then(setData);
  }, [url]);

  if (!data) return <Loading />;
  return <DataView data={data} />;
}

// 编译后(与编译器实际产物同构,写法约定同 10.4.3)
function DataFetcher({ url }: Props) {
  const $ = useMemoCache(6);
  const [data, setData] = useState(null);

  // useEffect 的回调与依赖数组被缓存,但 useEffect 本身始终被调用
  // (React 内部通过比较依赖来决定是否执行 effect)
  // 注意:setData 是 isStableType 认定的稳定值,不进入依赖比较
  let t0;
  let t1;
  if ($[0] !== url) {
    t0 = () => {
      fetch(url).then(res => res.json()).then(setData);
    };
    t1 = [url];
    $[0] = url;
    $[1] = t0;
    $[2] = t1;
  } else {
    t0 = $[1];
    t1 = $[2];
  }

  useEffect(t0, t1);

  // 早返回分支保留原有控制流,各自缓存:
  // <Loading /> 没有任何依赖,只在槽位为哨兵(首次)时创建一次
  if (!data) {
    let t2;
    if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
      t2 = <Loading />;
      $[3] = t2;
    } else {
      t2 = $[3];
    }
    return t2;
  }

  // <DataView> 依赖 data,单独占用两个槽位
  let t2;
  if ($[4] !== data) {
    t2 = <DataView data={data} />;
    $[4] = data;
    $[5] = t2;
  } else {
    t2 = $[5];
  }

  return t2;
}

注意编译器做了什么:

  1. useEffect 的回调函数被缓存(等价于手写 useCallback)
  2. useEffect 的依赖数组被缓存(避免每次创建新数组引用)
  3. useEffect 调用本身不被跳过——它必须在每次渲染中执行,由 React 运行时决定是否触发 effect

深度洞察:React Compiler 的记忆化策略可以被概括为一条原则——缓存值,而非跳过执行。组件函数始终被完整调用,但通过缓存中间值和最终 JSX,避免不必要的对象创建和子组件的 props 变化。这与 React.memo 跳过整个组件渲染的策略形成了互补。

10.5 编译前后代码对比:useMemo/useCallback 如何被消除

10.5.1 基础场景:计算属性

// 编译前:开发者手写 useMemo
function PriceDisplay({ price, taxRate }: Props) {
  const total = useMemo(
    () => price * (1 + taxRate),
    [price, taxRate]
  );

  const formatted = useMemo(
    () => `$${total.toFixed(2)}`,
    [total]
  );

  return <span className="price">{formatted}</span>;
}

// 有了 React Compiler 后,开发者只需写:
function PriceDisplay({ price, taxRate }: Props) {
  const total = price * (1 + taxRate);
  const formatted = `$${total.toFixed(2)}`;
  return <span className="price">{formatted}</span>;
}

// 编译器生成的缓存代码(与实际产物同构,写法约定同 10.4.3):
function PriceDisplay({ price, taxRate }: Props) {
  const $ = useMemoCache(2);

  // 乘法和模板字符串都只产出原始值,不分配槽位,每次渲染照常计算
  const total = price * (1 + taxRate);
  const formatted = `$${total.toFixed(2)}`;

  let t0;
  if ($[0] !== formatted) {
    t0 = <span className="price">{formatted}</span>;
    $[0] = formatted;
    $[1] = t0;
  } else {
    t0 = $[1];
  }

  return t0;
}

注意编译器与手写 useMemo 的差别:total 和 formatted 都是原始值,编译器根本不为它们建缓存块,只缓存会产生新对象的 JSX,而 JSX 的依赖就是 formatted 这个字符串——价格和税率没变时,重新算出的字符串与上次 === 相等,JSX 照样命中缓存。手写时我们往往会逐个 memo 每个中间值,编译器则只在对象、数组、JSX、返回非原始值的调用这类会分配新值的地方建立记忆化作用域。

10.5.2 回调函数场景

// 编译前:手动 useCallback + React.memo
function TodoItem({ todo, onToggle, onDelete }: Props) {
  const handleToggle = useCallback(() => {
    onToggle(todo.id);
  }, [onToggle, todo.id]);

  const handleDelete = useCallback(() => {
    onDelete(todo.id);
  }, [onDelete, todo.id]);

  return (
    <div className="todo-item">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={handleToggle}
      />
      <span>{todo.text}</span>
      <button onClick={handleDelete}>Delete</button>
    </div>
  );
}

export default React.memo(TodoItem);

// 有了 React Compiler,开发者只需写:
function TodoItem({ todo, onToggle, onDelete }: Props) {
  return (
    <div className="todo-item">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </div>
  );
}

// 编译器生成的代码会自动缓存这些匿名函数和 JSX

关键区别:开发者不再需要把回调函数"拎"出来用 useCallback 包裹,不再需要手动维护依赖数组,不再需要用 React.memo 包裹子组件。编译器会自动确保当 todo.id、todo.completed、todo.text、onToggle、onDelete 都没有变化时,生成的 JSX 是同一个引用。

10.5.3 复杂计算场景

// 一个真实场景:数据仪表盘
function Dashboard({ metrics, timeRange, filters }: Props) {
  // 编译器会为以下每段计算识别精确的依赖并缓存

  const filteredMetrics = metrics.filter(m =>
    filters.categories.includes(m.category) &&
    m.timestamp >= timeRange.start &&
    m.timestamp <= timeRange.end
  );

  const aggregated = {
    total: filteredMetrics.reduce((sum, m) => sum + m.value, 0),
    average: filteredMetrics.length > 0
      ? filteredMetrics.reduce((sum, m) => sum + m.value, 0) / filteredMetrics.length
      : 0,
    max: Math.max(...filteredMetrics.map(m => m.value)),
    min: Math.min(...filteredMetrics.map(m => m.value)),
  };

  const chartData = filteredMetrics.map(m => ({
    x: m.timestamp,
    y: m.value,
    label: m.name,
  }));

  return (
    <div className="dashboard">
      <SummaryCards data={aggregated} />
      <Chart data={chartData} />
      <MetricsTable rows={filteredMetrics} />
    </div>
  );
}

在没有编译器的情况下,正确优化这个组件需要至少 4 个 useMemo,而且需要仔细分析依赖链——aggregated 和 chartData 都依赖 filteredMetrics,所以 filteredMetrics 必须先被 memo。这种级联依赖很容易出错。

React Compiler 会自动分析出:

  • filteredMetrics、aggregated、chartData 落在同一个作用域里:filteredMetrics 的类型未知,之后对它调用的 .reduce/.map 被保守地视为"可能修改它",三者的可变区间于是连成一片;这个作用域依赖 metrics、filters.categories 和 timeRange(filter 的回调另成一个小作用域,依赖 filters.categories 与 timeRange)
  • 三个子组件的 JSX 分别依赖 aggregated、chartData、filteredMetrics,外层 <div> 依赖这三个 JSX

并为每个依赖边界插入缓存逻辑。

10.5.4 编译器对已有 useMemo/useCallback 的处理

一个重要的问题:如果代码中已经有 useMemo 和 useCallback,编译器会怎么做?

答案是:编译器保留的是语义,而不是调用本身。编译器把手写的 useMemo/useCallback 视为"开发者明确声明的缓存意图"——它会验证这个意图(比如依赖数组是否与实际使用一致),并保证编译产物至少提供同等的记忆化。但在产物中,这些计算和其余代码一样被吸收进 useMemoCache 的槽位机制,原来的 useMemo/useCallback 调用通常已不复存在。

// 编译前:手写的 useMemo
function Example({ data }: Props) {
  // 编译器识别这是一个显式的缓存意图,验证并保留其语义
  const processed = useMemo(() => expensiveComputation(data), [data]);

  return <Result value={processed} />;
}

// 编译后(概念性表示):useMemo 调用被吸收进槽位机制
function Example({ data }: Props) {
  const $ = useMemoCache(4);

  let processed;
  if ($[0] !== data) {
    processed = expensiveComputation(data);
    $[0] = data;
    $[1] = processed;
  } else {
    processed = $[1];
  }

  let t0;
  if ($[2] !== processed) {
    t0 = <Result value={processed} />;
    $[2] = processed;
    $[3] = t0;
  } else {
    t0 = $[3];
  }

  return t0;
}

这意味着从手动优化迁移到编译器优化是渐进式的——你可以在启用编译器后逐步移除手写的 useMemo/useCallback,而不是一次性重写所有代码。

10.6 编译器的局限性与逃生舱

10.6.1 编译器无法处理的模式

模式一:运行时动态行为。

// 编译器无法分析 eval 或动态属性访问的模式
function Dynamic({ obj, field }: { obj: any; field: string }) {
  const value = obj[field]; // field 是动态的,编译器无法追踪具体依赖
  return <span>{value}</span>;
}

(注意 prop 不能取名 key:key 会被 React 拦下,组件里读到的是 undefined。)

对于动态属性访问,编译器无法静态得知读的是哪个属性,也就不做属性路径追踪:obj[field] 每次渲染照常读取,下游缓存直接以读出的 value 作为依赖。

模式二:外部可变状态。

// 全局可变状态是编译器的盲区
let globalCounter = 0;

function Counter() {
  // 编译器不知道 globalCounter 何时变化
  // 可能会错误地缓存包含它的计算结果
  return <span>{globalCounter}</span>;
}

编译器的静态分析无法追踪运行时的外部可变状态。如果你的代码依赖全局变量、模块级变量或通过闭包捕获的可变引用,编译器可能无法正确优化。

模式三:非幂等的渲染逻辑。

function RandomColor() {
  // Math.random() 是非确定性的,缓存它会导致行为变化
  const color = `hsl(${Math.random() * 360}, 70%, 50%)`;
  return <div style={{ backgroundColor: color }} />;
}

需要说明的是,编译器并不会识别 Math.random()、Date.now() 这类非确定性调用——它们在编译器眼里只是普通的全局函数调用(Date 只是 compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts 里 UNTYPED_GLOBALS 集合的一员),其结果依赖不变时会被照常缓存。编译器自带的快照测试就直接把这一行为固化了下来——compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/timers.expect.md 里,const now = Date.now() 被编译成没有任何依赖、只判哨兵的槽位,也就是整个组件生命周期里只求值一次:

// timers.expect.md 的 ## Code 段,节选(仅去掉了函数体的一级缩进)
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
  t0 = Date.now();
  $[0] = t0;
} else {
  t0 = $[0];
}
const now = t0;

渲染幂等是 Rules of React 对开发者的要求,而不是编译器替你检查的东西;如果组件确实需要每次渲染都重新求值,要么把这类计算移出渲染路径,要么用下节的 "use no memo" 显式退出。

10.6.2 "use no memo" 指令

React Compiler 提供了一个逃生舱——"use no memo" 指令,允许开发者禁用特定函数的编译器优化:

function DebugPanel({ state }: Props) {
  "use no memo"; // 告诉编译器不要优化这个函数

  // 这个组件故意在每次渲染时都重新计算
  console.log('DebugPanel rendered at', Date.now());

  return (
    <pre>
      {JSON.stringify(state, null, 2)}
    </pre>
  );
}

这个指令类似于 TypeScript 中的 // @ts-ignore 或 Rust 中的 unsafe 块——它是一种"我知道自己在做什么"的显式声明。

10.6.3 编译器的配置选项

React Compiler 没有独立的配置文件——它以 babel-plugin-react-compiler 的形式接入构建链,配置就是这个 Babel 插件的 options,写在 babel.config.js(或 Vite 的 @vitejs/plugin-react 的 babel.plugins、Next.js 的 experimental.reactCompiler)里:

// babel.config.js
module.exports = {
  plugins: [
    [
      'babel-plugin-react-compiler',
      {
        // 编译目标:React 大版本;17/18 需额外安装 react-compiler-runtime
        target: '19',

        // 只编译匹配的文件,便于大型项目渐进启用
        sources: (filename) => {
          return filename.indexOf('/src/') !== -1;
        },

        // 日志与调试
        logger: {
          logEvent(filename, event) {
            if (event.kind === 'CompileError') {
              console.warn(`Compiler skipped ${filename}`);
            }
          },
        },
      },
    ],
  ],
};
// next.config.js(Next.js 15 的集成方式)
module.exports = {
  experimental: {
    reactCompiler: true, // 或传入 { compilationMode: 'annotation' } 等 options
  },
};
// Next.js 16(2025-10)起该选项转正,改写在顶层:{ reactCompiler: true }

sources 配置特别有用——它允许你在大型项目中渐进地启用编译器,先对新代码或已知符合 Rules of React 的代码启用,再逐步扩大范围。

10.6.4 ESLint 插件:编译前的防护网

React Compiler 配套了一个 ESLint 插件 eslint-plugin-react-compiler,它在编辑阶段就能发现违反 Rules of React 的代码:

// .eslintrc.js
module.exports = {
  plugins: ['react-compiler'],
  rules: {
    'react-compiler/react-compiler': 'error',
  },
};

这个插件会报告编译器遇到的问题,例如:

function Problem({ user }: Props) {
  user.name = user.name.trim(); // ESLint 会报告编译器的诊断:
  // ^^^^^^^^
  // react-compiler/react-compiler:
  // Mutating component props or hook arguments is not allowed.
  // Consider using a local variable instead
  //(若被修改的是某个函数的返回值,则报
  //  "Mutating a value returned from a function whose return value should not be mutated")

  return <Profile user={user} />;
}

注意这里用的是属性赋值。若换成 items.sort() 这类对未知类型值的方法调用,编译器只当它"可能修改"(ConditionallyMutate),作用在已冻结的 props 上时会降级为读取(compiler/packages/babel-plugin-react-compiler/src/Inference/InferReferenceEffects.ts 的 case Effect.ConditionallyMutate),不会报这条诊断。

10.7 与 Vue Compiler、Svelte Compiler、Solid Compiler 的横向对比

10.7.1 四种编译策略的哲学差异

理解 React Compiler 的定位,需要将它放在前端框架编译器的坐标系中审视。四大框架的编译策略代表了四种截然不同的设计哲学:

维度 React Compiler Vue Compiler Svelte Compiler Solid Compiler
编译目标 自动记忆化 模板优化 + 响应式转换 去虚拟 DOM 细粒度响应式
运行时大小 大(保留完整运行时) 中等(Tree-shakeable) 小(编译掉框架本身) 小(无虚拟 DOM)
输入语言 标准 JSX/TSX SFC 模板 + <script setup> .svelte 自定义语法 JSX(但语义不同)
核心优化 跳过不变的计算 跳过不变的 DOM 更新 生成命令式 DOM 操作 只更新变化的 DOM 节点
虚拟 DOM 保留 保留(Vapor 模式除外) 无 无
响应式追踪 编译时推断 运行时 Proxy 编译时赋值追踪(Svelte 4 及以前);Svelte 5 runes 由编译器落到 signal 运行时,见 §18.4.1 运行时 Signal(细粒度依赖收集)

10.7.2 React Compiler vs Vue Compiler

Vue 的编译器在设计上比 React Compiler 早了很多年,但两者解决的问题维度不同。

Vue Compiler 的核心优化——静态节点缓存(cacheStatic)和 PatchFlag:

<!-- Vue 模板 -->
<template>
  <div>
    <h1>Static Title</h1>                    <!-- 静态节点,编译时标记为可缓存 -->
    <p>{{ dynamicText }}</p>                   <!-- 动态节点,标记 PatchFlag -->
    <span class="static" :id="dynamicId">     <!-- 半静态,精确标记动态属性 -->
      Fixed text
    </span>
  </div>
</template>
// Vue 编译输出(简化;Vue 3.5+ 的 cacheStatic 形态)
import { createVNode, toDisplayString, openBlock, createBlock } from 'vue';

// 静态节点不再提升到模块作用域,而是首次渲染后缓存在组件实例的 _cache 槽位里
export function render(ctx, _cache) {
  return (openBlock(), createBlock('div', null, [
    _cache[0] || (_cache[0] = createVNode('h1', null, 'Static Title', -1 /* CACHED */)), // 命中缓存即复用静态 VNode
    createVNode('p', null, toDisplayString(ctx.dynamicText), 1 /* TEXT */),
    createVNode('span', { class: 'static', id: ctx.dynamicId }, 'Fixed text', 8 /* PROPS */, ['id']),
  ]));
}

Vue 的编译器优化了虚拟 DOM 的 Diff 过程——通过 PatchFlag 告诉运行时"只有这些属性是动态的,只比较它们"。而 React Compiler 优化的是组件的重渲染过程——通过记忆化告诉运行时"这些值没变,不需要重新计算和创建"。

两者的差异根植于各自的架构选择:Vue 有模板语法,可以在编译时区分静态和动态内容;React 用 JSX,JSX 只是普通 JavaScript 表达式,编译器以"值"为单位做记忆化——不含响应式依赖的 JSX 也会被缓存(只判哨兵、首次渲染后复用,见 10.6.1 的 timers 快照),但它不像 PatchFlag 那样向运行时下发"哪些属性是动态的"的 Diff 提示。

10.7.3 React Compiler vs Svelte Compiler

Svelte 走了一条更激进的路——完全消除虚拟 DOM。Svelte 的编译器将声明式的组件代码编译为命令式的 DOM 操作:

<!-- Svelte 组件(Svelte 3/4 语法)-->
<script>
  let count = 0;
  $: doubled = count * 2;

  function increment() {
    count += 1;
  }
</script>

<button on:click={increment}>
  Count: {count}, Doubled: {doubled}
</button>
// Svelte 3/4 编译输出(概念性简化)——Svelte 5 runes 改走 signal 运行时,见 §18.4.1
function create_fragment(ctx) {
  let button;
  let t0, t1, t2;

  return {
    c() {
      // create:直接创建 DOM 节点
      button = document.createElement('button');
      t0 = document.createTextNode('Count: ');
      t1 = document.createTextNode(ctx[0]);    // count
      t2 = document.createTextNode(', Doubled: ' + ctx[1]); // doubled
      button.appendChild(t0);
      button.appendChild(t1);
      button.appendChild(t2);
    },
    m(target) {
      // mount:插入到 DOM
      target.appendChild(button);
      button.addEventListener('click', ctx[2]); // increment
    },
    p(ctx, dirty) {
      // patch:精确更新变化的部分
      if (dirty & 1) t1.data = ctx[0];         // 只更新 count 文本
      if (dirty & 2) t2.data = ', Doubled: ' + ctx[1]; // 只更新 doubled 文本
    },
    d() {
      // destroy:清理
      button.remove();
    }
  };
}

Svelte 的编译策略是"编译掉框架"——没有虚拟 DOM 的创建和 Diff,直接生成精确的 DOM 更新代码。Svelte 3/4 用 dirty 位掩码追踪哪些状态变了,p() 函数只更新对应的 DOM 节点;Svelte 5 把这层换成了 signal 运行时的细粒度订阅(§18.4.1)。

React Compiler 与 Svelte Compiler 的根本差异在于:React 的编译优化是在保留虚拟 DOM 的前提下进行的,它减少的是不必要的虚拟 DOM 创建,而不是消除虚拟 DOM 本身。Svelte 则完全跳过了虚拟 DOM 这一层抽象。

10.7.4 React Compiler vs Solid Compiler

Solid 的编译策略可能是与 React 表面最相似但内核最不同的。两者都使用 JSX,但 Solid 的 JSX 语义与 React 截然不同:

// Solid 组件(看起来像 React,但语义完全不同)
function Counter() {
  const [count, setCount] = createSignal(0);

  // 这个 console.log 只执行一次!
  // Solid 的组件函数只在创建时执行一次,不会"重渲染"
  console.log('setup');

  return (
    <button onClick={() => setCount(c => c + 1)}>
      {}
      Count: {count()}
    </button>
  );
}
// Solid 编译输出(概念性简化)
function Counter() {
  const [count, setCount] = createSignal(0);
  console.log('setup');

  // 模板在编译时被提取为 HTML 字符串
  const _tmpl = template('<button>Count: </button>');

  const el = _tmpl.cloneNode(true);
  el.addEventListener('click', () => setCount(c => c + 1));

  // 动态部分通过 effect 自动更新(模板里只有静态文本 "Count: ",动态文本节点需另行插入)
  const text = document.createTextNode('');
  el.appendChild(text);
  createEffect(() => {
    text.data = count(); // 精确更新文本节点
  });

  return el;
}

Solid 的编译器做了两件关键的事:

  1. 将 JSX 模板编译为 template() 调用(利用浏览器的 HTML 解析器批量创建 DOM)
  2. 将动态表达式包裹在 createEffect 中,通过 Signal 的自动追踪实现精确更新

10.7.5 编译策略的权衡总结

                    运行时开销 ←→ 编译时复杂度

  React             ████████░░  运行时重(VDOM + Reconciler)
  (+ Compiler)      ██████░░░░  编译器减少了不必要的 VDOM 创建

  Vue               ██████░░░░  运行时中等(VDOM + Proxy)
  (+ Vapor)         ████░░░░░░  Vapor 模式去掉 VDOM

  Svelte            ██░░░░░░░░  运行时轻(无 VDOM)

  Solid             ██░░░░░░░░  运行时轻(Signals + 直接 DOM)

React Compiler 的独特价值在于:它在不改变 React 编程模型的前提下,显著降低了性能优化的认知负担。你仍然用熟悉的 React 方式编写组件——props、state、JSX——但编译器在幕后替你做了手动优化时代的大部分记忆化工作。

这是一种保守但务实的策略。React 团队没有选择像 Svelte 或 Solid 那样重新定义编程模型,而是选择在现有模型上叠加编译时优化。这意味着迁移成本较低——对于遵循 React 规则的已有代码库,启用编译器通常不需要修改代码。

深度洞察:四种编译策略的差异,本质上反映了一个工程哲学的光谱。在"运行时灵活性"和"编译时确定性"之间,React 选择了最偏向运行时的位置——保留完整的虚拟 DOM 和 Reconciler,让编译器只做"锦上添花"的优化。Svelte 和 Solid 则站在另一端——让编译器承担尽可能多的工作,将运行时压缩到最小。Vue 处于中间——模板语法给予编译器更多信息,但仍保留虚拟 DOM 作为通用抽象。没有绝对的优劣之分,只有适合不同场景的权衡。

10.7.6 实测:React Compiler 的运行时落脚点 useMemoCache 在客户端只有 2 处代码

§10.4.3 讨论"缓存槽位分配 + useMemoCache"——React Compiler 位于 React monorepo 的 compiler/ 目录(此前在 Meta 内部闭源开发,2024 年 5 月随开源直接并入主仓库,从未有过独立的公开仓库),而编译产物落地的 runtime hook 在 packages/ 下的 react 核心包——对照 React 19.0.0:

Public API(packages/react/src/ReactHooks.js ~215)——

export function useMemoCache(size: number): Array<any> {
  const dispatcher = resolveDispatcher();
  return dispatcher.useMemoCache(size);
}

仅数行入口——其他全部委托给 dispatcher 的当前实现。

实际实现(packages/react-reconciler/src/ReactFiberHooks.js ~1202)——

function useMemoCache(size: number): Array<any> {
  let memoCache = null;
  // Fast-path: 从 wip fiber 的 updateQueue 加载已存在的 memoCache
  let updateQueue = currentlyRenderingFiber.updateQueue;
  if (updateQueue !== null) memoCache = updateQueue.memoCache;
  // Fallback: 从 current fiber clone(逐槽位数组 slice;enableNoCloningMemoCache 在 v19.0.0 为 false)
  if (memoCache == null) {
    const current = currentlyRenderingFiber.alternate;
    if (current?.updateQueue?.memoCache) {
      memoCache = {
        data: current.updateQueue.memoCache.data.map(array => array.slice()),
        index: 0,
      };
    }
  }
  // 最后兜底:分配新缓存
  if (memoCache == null) memoCache = { data: [], index: 0 };
  // ...挂回 updateQueue.memoCache;按 index 取本次调用的槽位数组,
  //    首次用 REACT_MEMO_CACHE_SENTINEL 填满 size 个槽位;index++ 后返回
}

整个 useMemoCache 实现约 80 行(L1202-1284,含一大段解释 copy-on-write 的注释)——是 React Compiler 编译产物在客户端的全部 runtime 支撑;react/compiler-runtime 入口再把它以 c 之名导出。但代码量小不等于零开销:每个被编译的组件实例都要在 fiber 的 updateQueue.memoCache 上常驻一条 $ 数组(长度就是编译器分配的槽位数),而 enableNoCloningMemoCache 在 v19.0.0 为 false(packages/shared/ReactFeatureFlags.js:123),于是每次走 update 路径都要把它们整体复制一遍——currentMemoCache.data.map(array => array.slice())(packages/react-reconciler/src/ReactFiberHooks.js:1245)。这是实打实的内存占用与每次重渲染的拷贝成本,只是被省下来的重复计算抵消掉了。服务端另有两份很小的独立实现——SSR 的 packages/react-server/src/ReactFizzHooks.js(L795 起)与 RSC 的 packages/react-server/src/ReactFlightHooks.js(L84 起)——因为那里没有 Fiber,缓存不必跨渲染保留。

两条值得记住的物理事实——

  1. useMemoCache 在 ReactFiberHooks.js 实测约 80 行(v19.0.0 为 L1202-1284)——是 §10.4.3 讨论的"缓存槽位"在 React 19 runtime 层的全部代码——而 React Compiler 本身(monorepo 的 compiler/packages/babel-plugin-react-compiler/src,v19.0.0 树中约 42,600 行 TypeScript)做的所有静态分析、HIR 构建、反应性标注、依赖追踪——都是为了生成调用这 80 行运行时函数的 $[i] 索引代码——这是 "编译器复杂性 vs 运行时极简" 的极致分工——和 《Rust 编译器与运行时揭秘》 专栏第 10 章 §10.11.3 测得的 Future trait 仅 135 行、Pin + Waker + Future 合计约 4260 行同款 "运行时合约薄、编译/外部承担重" 模式
  2. useMemoCache 在 11 处 dispatcher 上注册(ContextOnly 指向 throwInvalidHookError;Mount/Update/Rerender 三张 prod 表;MountInDEV/MountWithHookTypesInDEV/UpdateInDEV/RerenderInDEV 四张 dev 表;InvalidNested 的 Mount/Update/Rerender 三张 dev 表)——单一 hook 实现需要在各个 dispatcher 表里都接上——是 React 双轨 dev/prod hook 系统的代价;具体行号随版本漂移,读源码时搜 useMemoCache = 即可

对照 §10.7 横向对比——React Compiler 的设计是 "保留虚拟 DOM + 编译时辅助"——和 Svelte/Solid 的 "编译时消除虚拟 DOM" 相反(Vue 默认同样保留虚拟 DOM,只有 Vapor 模式去掉它)——这个差异在源码层就是:React 的 reconciler 没有因为 Compiler 而减少、Compiler 只是在 reconciler 之上加一个约 80 行的 useMemoCache 钩子;Svelte、Solid 与 Vue Vapor 则直接生成 imperative 的 DOM 操作代码。

10.8 本章小结

React Compiler 是 React 历史上最重要的架构变革之一。它标志着 React 从"一切由运行时决定"的纯运行时框架,转变为"编译时辅助 + 运行时执行"的混合架构。

关键要点:

  1. React Compiler 解决了手动优化的系统性问题:useMemo/useCallback 的泛滥不是开发者的问题,而是框架设计的债务。编译器通过自动化记忆化,将开发者从"性能焦虑"中解放出来。

  2. 编译管线采用经典编译器设计:从 Babel AST 到 HIR,经过反应性分析、作用域构建、缓存槽位分配,最终生成带有 useMemoCache 调用的优化代码。多层中间表示使得每一阶段的分析都可以在合适的抽象层级上进行。

  3. Rules of React 是编译器正确性的契约:幂等性、不可变性、纯渲染——这些规则不是新发明的,它们一直是 React 的最佳实践,编译器只是将它们从"建议"提升为了"要求"。

  4. 缓存粒度优于手写:编译器能看到整个函数的数据流图,做出比人类手动优化更精确的缓存决策——属性级依赖追踪、相邻作用域合并、跨表达式的依赖链优化。

  5. 编译器是保守的:当遇到无法安全分析的代码时,编译器选择跳过而非猜测。"use no memo" 指令提供了显式的逃生舱。

  6. React Compiler 的定位与众不同:与 Vue/Svelte/Solid 的编译器不同,它不改变编程模型,不消除虚拟 DOM,不引入新的语法。它只是让你写的普通 React 代码自动获得优化版本的性能。

在下一章中,我们将深入 JSX 的编译过程——从 React.createElement 到新的 jsx() 运行时,理解 React 在编译入口处做了哪些关键的设计决策。


思考题

  1. React Compiler 生成的代码用浅比较(!==)来判断缓存是否有效。 如果一个组件接收一个对象 prop config,且父组件每次渲染都创建新的 config 对象(即使内容相同),编译器的缓存是否会失效?这与手写 useMemo 的行为有何不同?在这种场景下,编译器如何优化父组件来解决这个问题?

  2. 编译器的反应性分析是一种前向数据流分析。 考虑以下代码模式:一个组件从 useContext 获取一个大对象,但只使用其中一个属性。编译器能否做到"只在该属性变化时才重新计算"?如果不能,请解释静态分析在这里的局限性;如果能,请描述编译器需要执行的分析步骤。

  3. 对比 React Compiler 的 useMemoCache 和 Vue 3 的响应式 computed。 两者都实现了"输入不变则输出不变"的语义,但实现机制完全不同。请从缓存失效的精确度、运行时开销、内存占用三个维度分析它们的权衡。在什么场景下 React Compiler 的方案更优?在什么场景下 Vue 的方案更优?

  4. React Compiler 在遇到 try-catch 语句时的行为是什么? try 块中的代码可能抛出异常,catch 块的执行取决于异常是否发生。这种控制流对编译器的作用域划分和缓存策略有什么影响?请构造一个具体的例子说明编译器可能需要放弃优化的场景。