React 19 内核探秘
第10章 React Compiler 深度剖析
第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 开发者会立即指出几个"性能问题":
sortedProducts每次渲染都会重新计算,即使products没有变化handleClick每次渲染都是新的函数引用onClick={() => handleClick(product.id)}每次渲染为每个 item 创建新的闭包- 如果
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 团队对这个问题的认知经历了几个阶段:
-
2018 年 10 月:React 16.6 推出
React.memo;2019 年 2 月 Hooks 随 React 16.8 落地,带来useMemo/useCallback,将优化能力交给开发者 -
2021 年:React Conf 上首次提出 React Forget(React Compiler 的前身),明确承认手动优化是不可持续的
-
2023 年:React Labs 系列博客持续播报 Forget 的研发进展,架构从简单的 Babel 插件演进为独立编译管线
-
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 当天),首个 beta19.0.0-beta-9ee70a1-20241017发布于 2024-10-18,首个 rc19.1.0-rc.1发布于 2025-04-21,1.0.0发布于 2025-10-07;react-compiler-runtime的1.0.0同日发布。注意本专栏锚定的源码树是 2024-12 的v19.0.0tag,覆盖不到 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;
}
当编译器检测到无法安全优化的代码时,它有两种策略:
- 局部降级:对无法分析的部分跳过记忆化,其余部分继续优化
- 完全跳过:对整个函数放弃编译器优化,保持原始行为(默认
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;
}
注意编译器做了什么:
useEffect的回调函数被缓存(等价于手写useCallback)useEffect的依赖数组被缓存(避免每次创建新数组引用)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 的编译器做了两件关键的事:
- 将 JSX 模板编译为
template()调用(利用浏览器的 HTML 解析器批量创建 DOM) - 将动态表达式包裹在
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,缓存不必跨渲染保留。
两条值得记住的物理事实——
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 测得的Futuretrait 仅 135 行、Pin + Waker + Future 合计约 4260 行同款 "运行时合约薄、编译/外部承担重" 模式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 从"一切由运行时决定"的纯运行时框架,转变为"编译时辅助 + 运行时执行"的混合架构。
关键要点:
-
React Compiler 解决了手动优化的系统性问题:
useMemo/useCallback的泛滥不是开发者的问题,而是框架设计的债务。编译器通过自动化记忆化,将开发者从"性能焦虑"中解放出来。 -
编译管线采用经典编译器设计:从 Babel AST 到 HIR,经过反应性分析、作用域构建、缓存槽位分配,最终生成带有
useMemoCache调用的优化代码。多层中间表示使得每一阶段的分析都可以在合适的抽象层级上进行。 -
Rules of React 是编译器正确性的契约:幂等性、不可变性、纯渲染——这些规则不是新发明的,它们一直是 React 的最佳实践,编译器只是将它们从"建议"提升为了"要求"。
-
缓存粒度优于手写:编译器能看到整个函数的数据流图,做出比人类手动优化更精确的缓存决策——属性级依赖追踪、相邻作用域合并、跨表达式的依赖链优化。
-
编译器是保守的:当遇到无法安全分析的代码时,编译器选择跳过而非猜测。
"use no memo"指令提供了显式的逃生舱。 -
React Compiler 的定位与众不同:与 Vue/Svelte/Solid 的编译器不同,它不改变编程模型,不消除虚拟 DOM,不引入新的语法。它只是让你写的普通 React 代码自动获得优化版本的性能。
在下一章中,我们将深入 JSX 的编译过程——从 React.createElement 到新的 jsx() 运行时,理解 React 在编译入口处做了哪些关键的设计决策。
思考题
-
React Compiler 生成的代码用浅比较(
!==)来判断缓存是否有效。 如果一个组件接收一个对象 propconfig,且父组件每次渲染都创建新的config对象(即使内容相同),编译器的缓存是否会失效?这与手写useMemo的行为有何不同?在这种场景下,编译器如何优化父组件来解决这个问题? -
编译器的反应性分析是一种前向数据流分析。 考虑以下代码模式:一个组件从
useContext获取一个大对象,但只使用其中一个属性。编译器能否做到"只在该属性变化时才重新计算"?如果不能,请解释静态分析在这里的局限性;如果能,请描述编译器需要执行的分析步骤。 -
对比 React Compiler 的
useMemoCache和 Vue 3 的响应式computed。 两者都实现了"输入不变则输出不变"的语义,但实现机制完全不同。请从缓存失效的精确度、运行时开销、内存占用三个维度分析它们的权衡。在什么场景下 React Compiler 的方案更优?在什么场景下 Vue 的方案更优? -
React Compiler 在遇到
try-catch语句时的行为是什么?try块中的代码可能抛出异常,catch块的执行取决于异常是否发生。这种控制流对编译器的作用域划分和缓存策略有什么影响?请构造一个具体的例子说明编译器可能需要放弃优化的场景。