
一句话:React Compiler 是一套「把 React 代码降到一个可分析的中间表示(HIR + SSA),识别出『哪些计算依赖响应式输入』,再自动插入
useMemoCache读写」的编译管线——它的核心概念是「Reactive Scope(响应式作用域)」,而不是「给每个变量加useMemo」。
一、它到底做了什么(一句话 vs 一个例子)
// 你写的(朴素代码)
function ProductList({ items, keyword, onSelect }) {
const visible = items.filter((it) => it.name.includes(keyword));
const sorted = [...visible].sort((a, b) => a.price - b.price);
const handleSelect = (id) => onSelect(id);
const summary = { count: sorted.length, k: keyword };
return <List items={sorted} onSelect={handleSelect} summary={summary} />;
}
// 编译器「等价地」生成的代码(形态与真实产物一致,变量名做了可读化)
import { c as useMemoCache } from 'react/compiler-runtime';
function ProductList({ items, keyword, onSelect }) {
const $ = useMemoCache(8); // ⭐ 申请 8 个「memo 槽」
let t0;
if ($[0] !== items || $[1] !== keyword) {
const visible = items.filter((it) => it.name.includes(keyword)); // 昂贵计算
t0 = [...visible].sort((a, b) => a.price - b.price);
$[0] = items; // ⭐ 记下「这次用的依赖」
$[1] = keyword;
$[2] = t0; // ⭐ 缓存结果
} else {
t0 = $[2]; // 依赖没变 → 复用
}
let t1;
if ($[3] !== onSelect) {
t1 = (id) => onSelect(id);
$[3] = onSelect;
$[4] = t1;
} else {
t1 = $[4];
}
let t2;
if ($[5] !== t0 || $[6] !== keyword) {
t2 = { count: t0.length, k: keyword };
$[5] = t0;
$[6] = keyword;
$[7] = t2;
} else {
t2 = $[7];
}
return <List items={t0} onSelect={t1} summary={t2} />;
}
⭐⭐ 从这段产物能看出四个关键设计(都是理解 Compiler 的抓手):
① 【用「数组 + 下标」而不是 Map/Object】
$[0] 是「上一次的依赖」,$[1] 是「缓存的值」,交替排布
→ 分配在数组里(连续内存、无哈希开销),比「每个变量一个对象」快得多
② 【依赖比较是手写的 if】
`if ($[0] !== items || $[1] !== keyword)` —— 编译器把「依赖数组比较」内联展开
→ 省掉了 useMemo 的「数组分配 + 框架层比较」开销
③ 【槽位是「编译期静态分配」的】
每个 memo 块的槽位下标在编译时就定死了(这里用了 0~7)
→ 运行时只是「读写数组」,没有任何「按名字查找」
④ 【每次渲染都会执行这些 if】
也就是说:**并没有省掉「比较」,只省掉了「重新计算」与「重新创建对象」**
→ 编译器优化的目标是「引用稳定性 + 避免重算」,与手写 useMemo 的目标一致
二、编译管线:AST → HIR → SSA → Reactive Scopes → 代码
⭐ React Compiler 的核心阶段(概念名,细节以源码为准):
① 【解析与降级】Babel 解析 TS/JSX → AST,然后「降低(lower)」到一个
**HIR(High-level Intermediate Representation)**:
把 JS 的控制流展开成「基本块(BasicBlock)+ 指令(Instruction)」的形式,
变成「可分析、可变换」的图结构,而不是「嵌套的 AST」。
② 【类型与函数推断】推断「哪些函数是 Hook、哪些调用可能不纯」
(Hook 具有命名约定,如 use 开头;未知调用被当作「可能有副作用」)
③ 【SSA 化】把变量转成「静态单赋值(SSA)」
—— 每个变量只被赋值一次,赋值产生新版本(如 x = φ(x₁, x₂))
→ 这是「数据流分析」的前提(数据流图才是清晰的)
④ 【识别响应式来源】找出「哪些值的来源是 props / state / Hook 返回值」
→ 这些是「响应式(reactive)」的;其余(常量、纯计算)不是
⑤ 【划分 Reactive Scopes】⭐ 核心步骤:
把「依赖同一组响应式输入、且可以安全缓存」的指令**归并成一个作用域**,
并为该作用域分配 memo 槽。
⑥ 【各类校验(Validation)】违反 React 规则的位置会被标记,
**该组件会被跳过编译**(不是报错,而是「不优化」)。
⑦ 【代码生成】把 HIR 变回 JS,并插入 `useMemoCache` 的读写逻辑。
⭐⭐ 重点理解「Reactive Scope」(这是 Compiler 最核心的概念):
一个 Reactive Scope = 「一组共享同一依赖集的计算」
+ 「它们在同一个 memo 块里被缓存」
例:上面那段代码被划成了 3 个作用域:
作用域 A:visible / sorted 依赖 {items, keyword}
作用域 B:handleSelect 依赖 {onSelect}
作用域 C:summary 依赖 {sorted(=A 的输出), keyword}
⭐ 为什么「归并」而不是「一行一个 useMemo」?
因为「同依赖的一组计算」放在一起只需要【一次】依赖检查。
如果每行都单独 memo,就会有 N 次「比较依赖数组」的开销。
→ 编译器通过「作用域合并」把开销摊薄,这也是它比手写 useMemo 更优的地方。
⭐ 两个「非响应式」的处理策略(很能说明问题):
① 【常量提升(hoisting)】
不依赖任何响应式输入的纯计算 → 直接「提到组件外」或「只算一次」
例:`const WEEKDAYS = ['周一', ...]`(在组件内定义但没有依赖)
→ 编译后它被提升为模块级常量(零运行时成本)
② 【剪枝非响应式依赖(prune non-reactive deps)】
如果一个值的依赖里「既有响应式也有非响应式」,
编译器会**只保留响应式的那部分作为依赖**——
因为非响应式的值「永远不变」,比较它们纯属浪费。
→ 这就是「为什么编译器能比手写 useMemo 更精确」的另一个原因:
人往往会「把用到的所有变量都写进依赖」,而编译器知道哪些「根本不会变」。
三、useMemoCache 的运行时形态
// react/compiler-runtime 导出的 c(即 useMemoCache)
import { c } from 'react/compiler-runtime';
const $ = c(8);
// $ 是一个「长度固定为 8 的数组」,挂在当前 Hook 槽位上
// 第一次渲染:全 undefined
// 之后每次渲染:返回【同一个数组】(所以读写它是「跨渲染持久的」)
// ✅ 一个 60 行的最小 polyfill(帮助理解它的运行时语义)
import { useRef } from 'react';
type MemoCache = Array<unknown> & { __cacheSize?: number };
/**
* useMemoCache 的最小实现:
* · 首次调用:创建一个长度为 size 的数组
* · 之后调用:返回同一个数组(所以写入的值能跨渲染保留)
*/
export function c(size: number): MemoCache {
const ref = useRef<MemoCache | null>(null);
if (ref.current === null) {
const arr = new Array(size) as MemoCache;
arr.__cacheSize = size;
ref.current = arr;
}
if (ref.current.length !== size) {
// 开发期提示:同一个组件里的 cache size 必须稳定
// (这与「Hook 调用顺序必须稳定」是同一类约束)
console.warn(
`[useMemoCache] size 从 ${ref.current.length} 变成 ${size} —— 组件结构可能不稳定`
);
// 生产实现会让它「重新分配」,但这通常意味着代码有问题
const next = new Array(size) as MemoCache;
next.__cacheSize = size;
ref.current = next;
}
return ref.current;
}
⭐⭐ 三个「为什么这样设计」的解释:
① 【为什么用数组而不是 Map / 对象?】
· 连续内存 + 整数下标 → 访问是最快的
· 无哈希计算、无隐藏类(hidden class)变化
· 编译器在编译期就知道要多少槽位(`c(8)`),无需动态扩容
② 【为什么槽位是「依赖/值」交替排布?】
因为编译产物就是 `if ($[0] !== dep) { ...; $[2] = value }` 的形式
—— 「依赖槽」与「值槽」相邻,读起来就是「一段一段」的
③ 【为什么 size 必须稳定?】
因为 `c(size)` 与 `useState` 一样是「按 Hook 槽位存」的:
如果同一次渲染里 size 变了,说明「这个组件的结构不稳定」
(通常意味着「在条件里 return」或「Hook 顺序变了」)
→ 这与「Hook 规则」是同一类约束
// ⭐ "c" 这个名字的由来(一个有意思的细节)
// 编译产物里是 `import { c as useMemoCache } from 'react/compiler-runtime'`
// 用短名是为了「减小产物体积」(在压缩后依然保持极短)
// 而且它被设计成「可以被 tree-shake 的」:不用 Compiler 的项目不会引入它
四、完整实现:一个 200 行的「Reactive Scope 分析器」
下面的实现不依赖 Babel:它接收一个「简化的组件 IR」(表达式 DAG),输出「响应式作用域的分组 + memo 槽分配」。这正是 Compiler 第 ⑤ 步的核心逻辑。
// reactive-scope.ts —— 教学版「响应式作用域分析器」
// ⚠️ 说明:这是【简化演示】,用来复现 Compiler 的核心思路(依赖传播 + 作用域合并 + 槽位分配),
// 不是 React Compiler 的真实实现(真实实现基于 HIR + SSA + 更复杂的逃逸分析)
/** ============ 1. 简化 IR:一个「表达式 DAG」 ============ */
type Instr =
| { kind: 'reactive'; id: string; source: 'props' | 'state' } // 响应式来源(props/state)
| { kind: 'const'; id: string; value: unknown } // 常量(不可变)
| { kind: 'compute'; id: string; op: string; args: string[] }; // 纯计算
interface ComponentIR {
/** 组件名(诊断用) */
name: string;
instrs: Instr[];
/** 哪些指令是「返回值的一部分」(决定「缓存它是否有意义」) */
returned: string[];
}
interface Scope {
/** 这个作用域里的所有指令 id */
members: string[];
/** 它依赖的响应式输入(按稳定顺序) */
dependencies: string[];
/** 分配到的 memo 槽位:依赖槽与值槽 */
slots: { deps: number[]; values: number[] };
}
interface AnalysisResult {
/** 被提升为「组件外常量」的指令(零运行时成本) */
hoisted: string[];
/** 每一次渲染都必须重算的指令(依赖了响应式输入但没有可缓存的部分) */
scopes: Scope[];
/** memo cache 需要的总长度 */
cacheSize: number;
}
/** ============ 2. 分析:依赖传播 + 作用域合并 + 槽位分配 ============ */
export function analyze(ir: ComponentIR): AnalysisResult {
const byId = new Map(ir.instrs.map((i) => [i.id, i]));
/** 第一步:算出「每个指令是否依赖响应式输入」(传递闭包) */
const isReactive = new Map<string, boolean>();
const computeReactive = (id: string, stack: Set<string> = new Set()): boolean => {
const cached = isReactive.get(id);
if (cached !== undefined) return cached;
if (stack.has(id)) {
// 环路(真实场景是「自引用」)→ 保守认为「响应式」
return true;
}
stack.add(id);
const instr = byId.get(id);
if (!instr) throw new Error(`未知指令:${id}`);
let result: boolean;
switch (instr.kind) {
case 'reactive':
result = true;
break;
case 'const':
result = false; // ⭐ 常量永远不响应式
break;
case 'compute':
// ⭐ 只要有一个参数响应式,这个计算就是响应式的(传递性)
result = instr.args.some((a) => computeReactive(a, stack));
break;
}
stack.delete(id);
isReactive.set(id, result);
return result;
};
for (const instr of ir.instrs) computeReactive(instr.id);
/** 第二步:常量提升 —— 「非响应式且是纯计算」的指令可以提到组件外 */
const hoisted: string[] = [];
for (const instr of ir.instrs) {
if (!isReactive.get(instr.id) && instr.kind !== 'reactive') {
const hasReactiveAncestor = ir.instrs.some(
(i) => i.kind === 'compute' && i.args.includes(instr.id) && isReactive.get(i.id)
);
// ⭐ 只有当它「不被任何响应式计算依赖」时才提升(否则必须留在原地)
if (!hasReactiveAncestor) hoisted.push(instr.id);
}
}
/** 第三步:作用域合并 —— 把「响应式指令」按「依赖集」归并 */
const scopes: Scope[] = [];
const scopeOf = new Map<string, Scope>();
let nextSlot = 0;
/** 收集一个指令的「响应式根依赖」(递归展开到 props/state) */
const collectRoots = (id: string, out: Set<string>): void => {
const instr = byId.get(id);
if (!instr) return;
if (instr.kind === 'reactive') {
out.add(instr.id); // ⭐ 根依赖(props/state 本身)
return;
}
if (instr.kind === 'compute') {
instr.args.forEach((a) => collectRoots(a, out));
}
};
for (const instr of ir.instrs) {
if (!isReactive.get(instr.id)) continue;
if (instr.kind === 'reactive') continue; // props/state 本身不需要 memo
const roots = new Set<string>();
collectRoots(instr.id, roots);
const depKey = [...roots].sort().join('|');
// ⭐ 找到「依赖集相同」的已有作用域,合并进去(这就是「作用域合并」)
let scope = scopes.find((s) => [...s.dependencies].sort().join('|') === depKey);
if (!scope) {
scope = { members: [], dependencies: [...roots].sort(), slots: { deps: [], values: [] } };
scopes.push(scope);
}
scope.members.push(instr.id);
scopeOf.set(instr.id, scope);
}
/** 第四步:为每个作用域分配槽位(依赖槽 + 值槽,交替排布) */
for (const scope of scopes) {
scope.slots.deps = scope.dependencies.map(() => nextSlot++);
scope.slots.values = scope.members.map(() => nextSlot++);
}
return { hoisted, scopes, cacheSize: nextSlot };
}
/** ============ 3. 代码生成(把分析结果变成「伪代码」字符串,便于观察) ============ */
export function emit(ir: ComponentIR, result: AnalysisResult): string {
const lines: string[] = [];
lines.push(`// 编译产物(伪代码) for ${ir.name}`);
lines.push(`import { c as useMemoCache } from 'react/compiler-runtime';`);
lines.push(`function ${ir.name}(props) {`);
lines.push(` const $ = useMemoCache(${result.cacheSize});`);
if (result.hoisted.length) {
lines.push(` // ⭐ 常量提升(不依赖响应式输入 → 提到组件外)`);
lines.push(` // hoisted: ${result.hoisted.join(', ')}`);
}
for (const scope of result.scopes) {
const depCond = scope.dependencies
.map((d, i) => `$${scope.slots.deps[i]} !== props.${d}`)
.join(' || ');
lines.push(` // scope(依赖 ${scope.dependencies.join(', ')})`);
lines.push(` if (${depCond || 'false'}) {`);
scope.members.forEach((m, i) => {
lines.push(` const ${m} = /* ${byIdText(ir, m)} */;`);
lines.push(` $${scope.slots.values[i]} = ${m};`);
});
scope.dependencies.forEach((d, i) => lines.push(` $${scope.slots.deps[i]} = props.${d};`));
lines.push(` } else {`);
scope.members.forEach((m, i) => lines.push(` const ${m} = $${scope.slots.values[i]};`));
lines.push(` }`);
}
lines.push(` return { ${ir.returned.join(', ')} };`);
lines.push(`}`);
return lines.join('\n');
}
function byIdText(ir: ComponentIR, id: string): string {
const instr = ir.instrs.find((i) => i.id === id);
if (!instr || instr.kind !== 'compute') return id;
return `${instr.op}(${instr.args.join(', ')})`;
}
// ✅ 用它跑一个例子
import { analyze, emit } from './reactive-scope';
const ir = {
name: 'ProductList',
instrs: [
{ kind: 'reactive' as const, id: 'items', source: 'props' as const },
{ kind: 'reactive' as const, id: 'keyword', source: 'props' as const },
{ kind: 'reactive' as const, id: 'onSelect', source: 'props' as const },
{ kind: 'const' as const, id: 'PAGE_SIZE', value: 20 },
// 纯常量计算(不依赖任何响应式输入)→ 应被提升
{ kind: 'compute' as const, id: 'pageSizeLabel', op: 'template', args: ['PAGE_SIZE'] },
// 依赖 items + keyword → 一个作用域
{ kind: 'compute' as const, id: 'visible', op: 'filter', args: ['items', 'keyword'] },
{ kind: 'compute' as const, id: 'sorted', op: 'sort', args: ['visible'] },
// 只依赖 onSelect → 另一个作用域(引用稳定性)
{ kind: 'compute' as const, id: 'handleSelect', op: 'arrow', args: ['onSelect'] },
// 依赖 sorted + keyword → 第三个作用域
{ kind: 'compute' as const, id: 'summary', op: 'object', args: ['sorted', 'keyword'] },
],
returned: ['sorted', 'handleSelect', 'summary', 'pageSizeLabel'],
};
const result = analyze(ir);
console.log('常量提升:', result.hoisted); // ['pageSizeLabel']
console.log('作用域数:', result.scopes.length); // 3
console.log('cache 长度:', result.cacheSize);
console.log(emit(ir, result));
⭐⭐ 这个分析器复现了 Compiler 的四个核心判断:
① 【传递性响应式分析】
只要参数里有一个响应式,这个计算就是响应式的(`compute` 的 `some(args)`)
② 【常量提升】
「不依赖响应式且不被响应式计算引用」→ 提到组件外(零成本)
③ 【作用域合并】
「依赖集相同」的计算归到同一个 memo 块 → 只做一次依赖检查
④ 【槽位静态分配】
依赖槽与值槽「交替分配」,总长度即 `useMemoCache(size)` 的 size
⚠️ 真实 Compiler 比这复杂得多(HIR 指令、SSA 的 φ 节点、控制流合并、
逃逸分析、Hook 调用的特殊处理、mutable 值的追踪等),
但「依赖传播 + 作用域合并 + 槽位分配」这三件事是相通的。
五、纯度校验:哪些代码会被「跳过编译」
⭐⭐ Compiler 强依赖「组件是纯函数」这个假设。
它有一整套校验规则(名字以 validate 开头),违反时【跳过该组件】并给出诊断。
常见被拒绝的情形(概念名,具体以源码/文档为准):
① 【渲染中读写 ref】
validateNoRefAccessInRender
❌ const el = ref.current; return <div>{el.offsetHeight}</div>;
② 【渲染中 setState】
validateNoSetStateInRender
❌ if (!ready) setReady(true);
③ 【Hook 使用不规范】
validateHooksUsage / validateNoConditionalHookCalls
❌ if (cond) { useState(0); }
④ 【把「可变值」当依赖】
validateNoMutableValuesUsedAsDeps —— 依赖里出现了「会被就地修改的对象」
⑤ 【在 Effect 里做「可以在渲染期算」的派生】
validateNoDerivedComputationsInEffects(建议直接算)
⑥ 【覆盖了手写的 useMemo/useCallback 语义】
validatePreservedManualMemoization —— 手写的 memo 不能被错误地改变行为
⑦ 【调用「未知函数」且该函数可能不纯】
编译器无法证明「第三方函数是纯的」→ 保守地不缓存它的结果
⭐ 三种「被拒绝」的后果:
· 该组件【跳过编译】(代码照常工作,只是没有自动 memo)—— ✅ 安全
· 编译器输出诊断信息(可以用 CLI / lint 插件查看)
· 构建不一定失败(除非你把它配成 error)
# ✅ 查看「哪些组件被跳过」(Compiler 的 CLI / 诊断)
npx react-compiler-healthcheck # 全量健康检查(报告可不编译的文件数)
# 或在 babel 配置里开启日志
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
// 只编译指定目录 / 排除指定文件
sources: (filename) => filename.includes('/src/features/'),
// 输出诊断
panicThreshold: 'none',
}],
],
};
六、它不能做什么(边界)
⭐⭐ 五件「Compiler 管不了」的事:
① 【结构性优化】
· Context 的订阅粒度(value 一变,所有消费者重渲染)
· 状态放得太高导致的传播范围过大
· 「children 透传」这类结构技巧
→ 这些要在写代码时决定,编译器改不了结构
② 【跨越组件边界】
它能稳定「组件内部的引用」,但「父组件传给子组件的 props 是否稳定」,
取决于「父组件自己的计算」——编译器在「每个组件内部」优化,
跨组件的引用链路仍要看整体写法(通常也能覆盖,但不如「结构优化」根本)
③ 【非 React 的开销】
DOM 数量、布局抖动、网络、第三方库的同步计算
④ 【不纯的组件】
违反纯度校验的组件会被跳过(生态里不少老代码如此)
→ 「迁移到 Compiler」的过程本身就是在「清理不纯代码」
⑤ 【「想让它不缓存」的语义需求】
如果某段代码「必须每次重算」(如依赖随机数),编译器可能仍然缓存它
(除非它检测到「不确定性来源」)→ 这类地方要显式处理
| 问题类型 | Compiler 能解决吗 | 该用什么 |
|---|---|---|
| 组件内的重复计算 | ✅ | Compiler(或手写 useMemo) |
内联函数导致 memo 失效 |
✅ | Compiler(或 useCallback) |
| Context 全量重渲染 | ❌ | 拆分 Context / 状态库选择器(见 5.9、12.8) |
| 列表 DOM 太多 | ❌ | 虚拟滚动(见 5.5) |
| 首屏包太大 | ❌ | 代码分割(见 5.6) |
| 长任务阻塞输入 | ❌ | useTransition / Web Worker |
| 不纯的组件 | ❌(跳过) | 先修代码纯度 |
七、迁移策略与风险
⭐ 五步迁移(从最安全到最大范围):
① 【体检】跑 `react-compiler-healthcheck`
→ 先知道「有多少组件会被跳过、为什么」
② 【装 lint 插件】`eslint-plugin-react-compiler`
→ 它把「诊断信息」变成「编辑器里的提示」,
让你在写代码时就避免「会被跳过」的写法
③ 【小范围启用】用 `sources` 配置只编译一部分目录
→ 观察 Profiler 与业务指标(渲染次数是否下降)
④ 【修不纯代码】把「渲染中读 ref / setState / 条件 Hook」改掉
→ 这一步本身就有价值(符合 React 规则 = 更可预测)
⑤ 【全量启用 + 保留必要的手写优化】
→ Context 的 `useMemo(value)`、结构性优化仍然需要自己写
⭐⭐ 四个真实风险:
① 【行为变化】
原来「每次渲染都重新创建实例」的代码,被编译器缓存后可能行为不同
例:`const editor = new Editor()`(原来每次都新建)
→ 这类代码本身是「坏味道」,但要先识别出来(用 lint + 测试覆盖)
② 【与手写 memo 冲突】
编译器会「尊重并保留」手写 memo 的语义,
但如果手写的 memo「依赖写错了」,编译器不会帮你修
→ 迁移前先把 `exhaustive-deps` 的警告清掉
③ 【构建产物变化带来的调试差异】
调试时看到的代码与源码不一致(需要 source map)
→ 建议「先在一个 slice 上启用」,避免「全量启用 + 线上问题」同时发生
④ 【版本与工具链耦合】
它与 React 版本、构建工具(Babel/SWC)、打包器都有耦合
→ 升级时要一起验证
八、本篇特有的坑
// ① 以为「装了 Compiler 就不用手写任何 memo」
// ⚠️ 它只覆盖「组件内部」;Context value、结构性优化仍要自己写
// ② 在渲染里读 ref / 写 ref(会被跳过编译,而且本身就是 bug)
function Bad() {
const el = ref.current; // ❌ 渲染期读 ref
return <div>{el?.offsetHeight}</div>;
}
// ③ 渲染里 setState
if (!ready) setReady(true); // ❌ 会被跳过 + 可能死循环
// ④ 条件调用 Hook
if (open) { const [x] = useState(0); } // ❌ 会被跳过
// ⑤ 依赖「每次渲染都是新实例」的第三方写法
const chart = new Chart(el, { onRender: () => setN(n + 1) }); // ⚠️ 被缓存后 onRender 只创建一次
// → 如果这个库「修改实例内部状态」,缓存实例可能带来意外耦合
// ⑥ 在 `useMemo` 里做副作用(编译器会保留它 → 但你依赖的是「每次执行」)
useMemo(() => { track(); return compute(); }, []); // ❌ 本来就错
// ⑦ 认为「Compiler 会消除所有无谓重渲染」
// ⚠️ 它消除的是「组件内部的无效计算/引用变化」,
// 「父组件重渲染带动子组件」这件事要靠 memo/结构(它也会插 memo,但有限制)
// ⑧ 在「生产环境」才发现「某些组件没被编译」
// ✅ 用 healthcheck + lint 插件在开发期就知道
// ⑨ 让 Compiler 编译「已经手写大量 memo 的老代码」→ 双重记忆化
// ⚠️ 不会出错,但可能「两层缓存」让调试更难;建议先清理冗余手写 memo
// ⑩ 依赖 Compiler 的缓存「保证只执行一次」
// ⚠️ 与 useMemo 一样,它是「性能优化」不是「语义保证」(React 可丢弃缓存)
// ⑪ 忽略「Compiler 会改变 `useRef` 初始化的时机感知」
// ✅ 记住原则:渲染必须纯,任何「依赖渲染次数」的写法都要改掉
// ⑫ 在「不纯的组件」上使用它并期待优化生效(实际被静默跳过)
// ⑬ 忘记「它需要 React 19 或 `react-compiler-runtime` 包」(旧版本要装 runtime)
// ⑭ 认为「Compiler 能解决 Context 重渲染」
// ✅ 该拆 Context 还是得拆
// ⑮ 把「被跳过」当成「编译器有 bug」
// ⚠️ 通常是代码不纯 → 修代码,而不是关掉编译器
// ⑤ 的完整处理:需要「每次新建实例」时显式说明
function ChartHost({ data }: { data: number[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<ChartInstance | null>(null);
// ⭐ 用 ref 自己管实例的生命周期(而不是「在渲染里 new」)
useEffect(() => {
const chart = new Chart(containerRef.current!);
chartRef.current = chart;
return () => { chart.destroy(); chartRef.current = null; };
}, []);
useEffect(() => {
chartRef.current?.setData(data);
}, [data]);
return <div ref={containerRef} />;
}
九、面试延伸
- 「React Compiler 的工作原理是什么?」
它是一条「编译管线」:① Babel 解析 → 把 AST 降低成 HIR(基本块 + 指令的控制流图);② 做类型与纯度推断;③ 转成 SSA 形式(每个变量只赋值一次),使数据流清晰;④ 识别响应式来源(props / state / Hook 返回值);⑤ ⭐ 划分 Reactive Scope——把「依赖同一组响应式输入、可安全缓存」的指令归并成一个作用域,并为它分配 memo 槽;⑥ 跑纯度校验(违反 React 规则的组件跳过编译);⑦ 代码生成——插入useMemoCache的读写if块。 -
「什么是 Reactive Scope?为什么要『合并』?」
Reactive Scope 是「一组共享同一依赖集的计算」,它们在同一个 memo 块里被缓存。之所以合并,是因为「同依赖的一组计算」只需一次依赖检查——如果每行都单独useMemo,就会有 N 次「依赖数组分配 + 比较」的开销。编译器通过作用域合并把固定开销摊薄,这是它相比「手写useMemo」更优的一个关键点。此外它还会剪枝非响应式依赖(只保留会变的那些)与提升常量。 -
「
useMemoCache是什么?为什么用数组而不是 Map?」
它是react/compiler-runtime暴露的运行时 Hook(编译产物里以c的短名导入),接收一个编译期就确定的 size,返回一个跨渲染保持同一引用的数组——编译产物用$[0](依赖)、$[1](值)这样的整数下标读写它。用数组而不是 Map/对象的原因:① 连续内存 + 整数下标访问最快;② 没有哈希计算与隐藏类变化;③ size 在编译期已知,无需动态扩容。另外「短名c」是为了减小产物体积。 -
「编译产物长什么样?」
是一串「带依赖检查的 if/else 块」:let t0; if ($[0] !== items || $[1] !== keyword) { t0 = /* 计算 */; $[0] = items; $[1] = keyword; $[2] = t0; } else { t0 = $[2]; }。三个观察点:① 依赖比较被内联展开(没有依赖数组的分配);② 槽位下标在编译期静态分配;③ 每次渲染仍然会执行这些if——所以它省掉的是「重算」与「重新创建对象」,而不是「比较」。 -
「哪些代码会被『跳过编译』?」
违反「组件是纯函数」这一假设的代码,常见有:① 渲染中读写 ref;② 渲染中setState;③ 条件调用 Hook;④ 把「会被就地修改的值」当依赖;⑤ 在 Effect 里做「本可在渲染期算」的派生计算;⑥ 调用「编译器无法证明是纯的」未知函数(保守不缓存)。后果是「该组件跳过编译」(代码照常运行,只是没有自动 memo),并输出诊断信息——所以「迁移到 Compiler」的过程本身就是在清理不纯代码。 -
「它能取代手写
useMemo吗?」
能取代大部分「组件内部的记忆化」,但不能取代两类东西:① 结构性优化——Context 的订阅粒度、状态放太高、children透传等(编译器改不了代码结构);② 跨组件的引用链路——虽然它也会给子组件插 memo,但「父组件传给子组件的 props 稳不稳定」仍取决于整体写法。此外「非 React 的开销」(DOM 数量、布局抖动、网络)它完全管不了。所以「理解渲染机制与引用稳定」依然必要(见 12.7)。 -
「迁移到 Compiler 有什么风险?」
四点:① 行为变化——原来「每次渲染都重新创建实例」的代码(如new Chart())被缓存后可能行为不同(这类代码本是坏味道,但要先识别);② 与手写 memo 的交互——它尊重手写 memo 的语义,但不会修手写错的依赖数组(迁移前应先清掉exhaustive-deps警告);③ 调试差异——产物与源码不一致(需要 source map),建议先小范围启用;④ 工具链耦合——与 React 版本、Babel/SWC、打包器都有关,升级要一起验证。 -
「怎么判断 Compiler 有没有生效?」
四种手段:① 跑健康检查(react-compiler-healthcheck)看「有多少文件会被跳过、原因是什么」;② 装eslint-plugin-react-compiler——把诊断变成编辑器提示;③ 看编译产物(把编译后的代码打印出来,找useMemoCache/c()——有它就是被编译了;④ 用 Profiler 对比启用前后的渲染次数与耗时(注意要在 production build 里测,见 2.19)。
一句话速记
React Compiler 把代码「降低成 HIR → 转 SSA → 识别响应式来源 → 划分 Reactive Scope → 插入
useMemoCache读写」——核心概念是「Reactive Scope = 依赖集相同的一组计算,共用一个 memo 块」(这样只需一次依赖检查,比逐行useMemo更省),同时会「提升不依赖响应式的常量」与「剪枝非响应式依赖」;运行时是react/compiler-runtime的c(size)返回的跨渲染稳定数组(用整数下标读写、size 编译期已知,因此不用 Map);违反纯度校验的组件会被跳过编译(渲染中读写 ref /setState/ 条件 Hook / 调用无法证明纯的函数);它只覆盖「组件内部的记忆化」,管不了 Context 订阅粒度、结构优化、DOM 数量、包体积、长任务;迁移路径是「体检 → 装 lint → 小范围启用 → 修不纯代码 → 全量启用」,最大风险是「依赖『每次渲染新建实例』的代码行为变化」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!