陌上人如玉
公子世无双

4.15 React Compiler 深入

4.15 React Compiler 深入

一句话: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} />;
}

九、面试延伸

  1. 「React Compiler 的工作原理是什么?」
    它是一条「编译管线」:① Babel 解析 → 把 AST 降低成 HIR(基本块 + 指令的控制流图);② 做类型与纯度推断;③ 转成 SSA 形式(每个变量只赋值一次),使数据流清晰;④ 识别响应式来源(props / state / Hook 返回值);⑤ ⭐ 划分 Reactive Scope——把「依赖同一组响应式输入、可安全缓存」的指令归并成一个作用域,并为它分配 memo 槽;⑥ 跑纯度校验(违反 React 规则的组件跳过编译);⑦ 代码生成——插入 useMemoCache 的读写 if 块。

  2. 「什么是 Reactive Scope?为什么要『合并』?」
    Reactive Scope 是「一组共享同一依赖集的计算」,它们在同一个 memo 块里被缓存。之所以合并,是因为「同依赖的一组计算」只需一次依赖检查——如果每行都单独 useMemo,就会有 N 次「依赖数组分配 + 比较」的开销。编译器通过作用域合并把固定开销摊薄,这是它相比「手写 useMemo」更优的一个关键点。此外它还会剪枝非响应式依赖(只保留会变的那些)与提升常量。

  3. 「useMemoCache 是什么?为什么用数组而不是 Map?」
    它是 react/compiler-runtime 暴露的运行时 Hook(编译产物里以 c 的短名导入),接收一个编译期就确定的 size,返回一个跨渲染保持同一引用的数组——编译产物用 $[0](依赖)、$[1](值)这样的整数下标读写它。用数组而不是 Map/对象的原因:① 连续内存 + 整数下标访问最快;② 没有哈希计算与隐藏类变化;③ size 在编译期已知,无需动态扩容。另外「短名 c」是为了减小产物体积。

  4. 「编译产物长什么样?」
    是一串「带依赖检查的 if/else 块」:let t0; if ($[0] !== items || $[1] !== keyword) { t0 = /* 计算 */; $[0] = items; $[1] = keyword; $[2] = t0; } else { t0 = $[2]; }。三个观察点:① 依赖比较被内联展开(没有依赖数组的分配);② 槽位下标在编译期静态分配;③ 每次渲染仍然会执行这些 if——所以它省掉的是「重算」与「重新创建对象」,而不是「比较」。

  5. 「哪些代码会被『跳过编译』?」
    违反「组件是纯函数」这一假设的代码,常见有:① 渲染中读写 ref;② 渲染中 setState;③ 条件调用 Hook;④ 把「会被就地修改的值」当依赖;⑤ 在 Effect 里做「本可在渲染期算」的派生计算;⑥ 调用「编译器无法证明是纯的」未知函数(保守不缓存)。后果是「该组件跳过编译」(代码照常运行,只是没有自动 memo),并输出诊断信息——所以「迁移到 Compiler」的过程本身就是在清理不纯代码。

  6. 「它能取代手写 useMemo 吗?」
    能取代大部分「组件内部的记忆化」,但不能取代两类东西:① 结构性优化——Context 的订阅粒度、状态放太高、children 透传等(编译器改不了代码结构);② 跨组件的引用链路——虽然它也会给子组件插 memo,但「父组件传给子组件的 props 稳不稳定」仍取决于整体写法。此外「非 React 的开销」(DOM 数量、布局抖动、网络)它完全管不了。所以「理解渲染机制与引用稳定」依然必要(见 12.7)。

  7. 「迁移到 Compiler 有什么风险?」
    四点:① 行为变化——原来「每次渲染都重新创建实例」的代码(如 new Chart())被缓存后可能行为不同(这类代码本是坏味道,但要先识别);② 与手写 memo 的交互——它尊重手写 memo 的语义,但不会修手写错的依赖数组(迁移前应先清掉 exhaustive-deps 警告);③ 调试差异——产物与源码不一致(需要 source map),建议先小范围启用;④ 工具链耦合——与 React 版本、Babel/SWC、打包器都有关,升级要一起验证。

  8. 「怎么判断 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 → 小范围启用 → 修不纯代码 → 全量启用」,最大风险是「依赖『每次渲染新建实例』的代码行为变化」。

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

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫