React Compiler 技术原理解析

0 阅读8分钟

我们知道,React 的 beginWork 默认是贪婪的。只要父组件更新,React 无法确定子组件内部是否引用了会导致变化的数据,因此默认会重新执行所有子组件。

因此才会催生出memo、callback的这些用法,但是深入之后会发现,使用思考成本高、容易出错、维护成本高的问题,从而官方催生出了React Compiler

React Compiler是什么

React Compiler是一个构建时工具,它在编译阶段自动分析你的组件代码,注入 memoization 逻辑。

核心目标是**「自动识别组件的重渲染依赖,替代手动 memo/useCallback/useMemo」**

  • 2024 年 10 月:Beta 发布
  • 2025 年 10 月:正式发布 1.0 稳定版
  • 已在 Meta 内部(Instagram、Facebook)大规模生产验证兼容性
  • 支持 React 17、18、19(与 React 19 配合效果最佳)

如何使用

React 项目(Webpack)

babel 插件

如果当前的react版本低于19,则需要额外安装一个运行时

npm add react-compiler-runtime

安装babel

npm install babel-plugin-react-compiler -D

babel.config.js.babelrc 加:

module.exports = {
  presets: [["@babel/preset-react", { runtime: "automatic" }]],
  plugins: ["react-compiler"]
};
  1. Webpack 里确保用了 babel-loader 处理 jsx/tsx。

Vite

装官方插件,一行启用

安装

npm install @vitejs/plugin-react-compiler -D

vite.config.js

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-compiler'

export default defineConfig({
  plugins: [react()]
})

Next.js 项目

升级 Next15+,一行启用

  1. 升级到 Next.js ≥15
  2. next.config.js
const nextConfig = {
  reactCompiler: true
}

module.exports = nextConfig

如何验证 React Compiler 已生效

方式一(推荐):React DevTools

打开浏览器 React DevTools,在 Components 面板中选中任意组件,如果该组件已被 Compiler 优化,右侧会显示 Memo ✨ 标识。

方式二:查看编译产物

构建后在产物 JS 中搜索 react-compiler-runtime,能找到说明 Compiler 已参与编译。

方式三:eslint-plugin-react-compiler

npm add -D eslint-plugin-react-compiler@rc

.eslintrc 中启用后,可在开发时静态检测违反 React 规则的代码(这类代码 Compiler 会跳过优化)

实现原理

主流程

1、什么是 HIR?

HIR(High-Level Intermediate Representation,高级中间表示)是 React Compiler 自己定义的内部表示

基于 控制流图(Control· Flow Graph, CFG) 构建。

维度AST(抽象语法树)CFG(控制流图)
核心目的描述代码的语法结构(“代码长什么样”)描述代码的执行逻辑 / 流程(“代码怎么跑”)
表现形式树形结构(嵌套层级,对应语法嵌套)有向图结构(节点 + 边,对应执行路径)
关注重点语法规则执行顺序
典型用途代码解析、重构、静态分析代码优化、死代码检测、路径分析

普通的 AST 是树状结构,而 CFG 用基本块(Basic Block)+ 有向边来表达控制流:

// 源代码
function greet(x) {
  if (x > 0) {
    return "positive"
  }
  return "other"
}

对应的AST

FunctionDeclaration (name: greet)  // 函数声明节点
├─ params: [Identifier (name: x)]  // 函数参数:变量x
└─ body: BlockStatement            // 函数体(代码块)
   └─ IfStatement                  // if条件语句节点
      ├─ test: BinaryExpression    // 条件表达式:x > 0
      │  ├─ left: Identifier (x)   // 左操作数:x
      │  ├─ operator: >            // 运算符:>
      │  └─ right: NumericLiteral (0) // 右操作数:0
      ├─ consequent: BlockStatement // if成立时执行的代码块
      │  └─ ReturnStatement        // return语句
      │     └─ Literal (value: "positive") // 返回值:"positive"
      └─ alternate: null           // if不成立时无else分支
   └─ ReturnStatement              // 函数最后的return语句
      └─ Literal (value: "other")  // 返回值:"other"

对应的 CFG:

                    ┌────────────────┐
                    │  Entry Block   │  # 仅入口:接收参数x
                    └───────┬────────┘
                            │
                    ┌───────▼────────┐
                    │  BB2: 判断块    │  # 独立基本块:t1 = x > 0if (t1)       │
                    └──┬──────────┬──┘
                       │          │
                 true  │          │   false
     ┌─────────────────▼─┐      ┌─▼────────────────────┐
     │  BB3: BlockA      │      │   BB4: BlockB        │
     │ return "positive" │      │     return "other"   │
     └───────────────────┘      └──────────────────────┘

HIR:以 CFG 为骨架、吸收 AST 语义后重新组织的全新中间表示

基本块 BB1(接收参数):
  语义:x: Param<number>  // 从 AST 提取的参数语义
基本块 BB2(判断条件):
  语义:cond = BinaryOp(x, >, 0)  // 从 AST 提取的表达式语义
  流程:branch cond → BB3 / BB4  // CFG 的分支逻辑
基本块 BB3(return positive):
  语义:return Literal("positive")  // 从 AST 提取的返回值语义
基本块 BB4(return other):
  语义:return Literal("other")  // 从 AST 提取的返回值语义

为什么需要 HIR?

HIR中的CFG结构让编译器能够做数据流分析

  • 一个变量在哪些路径上被赋值?
  • 哪些值从哪个 Block 流入当前 Block?
  • 某个 JSX 表达式的所有依赖是什么?

这是后续 SSA 变换和依赖追踪的基础。

2、什么是SSA

SSA(Static Single Assignment,静态单赋值)是编译器领域的经典变换:每个变量在程序中只被赋值一次,不同赋值点用不同的变量名(带下标)来区分。

// 普通形式(同一变量被多次赋值)
let x = a;
x = b; // x 被重新赋值
use(x); // 这里的 x 是哪个版本的 x?


// SSA (每个版本独立命名)
let x1 = a;
let x2 = b; // 新版本 x2
use(x2); // 明确:用的是 x2

SSA 中的 φ 函数

当控制流在某个汇合点合并时(如 if/else 之后),引入 φ 函数来表示"来自不同路径的值的合并":

// 源代码
let result;
if (condition) {
  result = "yes";
} else {
  result = "no";
}
console.log(result);



// SSA 形式
let result1 = "yes"; // if 分支
let result2 = "no"; // else 分支
let result3 = φ(result1, result2,condition); 
console.log(result3);

SSA 让编译器能精确知道"每一个值从哪里来,到哪里去",这是自动 memoization 的核心前提

React Compiler 中的 SSA 实现

核心设计思想

React Compiler 的 SSA 实现有一个关键洞察:追踪值(values),而不是变量(variables) 。这对于 React 组件的 memoization 至关重要。

// 源代码
function Component({ theme, isDark }) {
  let styles;
  if (isDark) {
    styles = { background: 'black', color: theme.dark };
  } else {
    styles = { background: 'white', color: theme.light };
  }
  return <div style={styles}>Content</div>;
}
SSA变化过程

第一步编辑器识别出了styles 变量有两个不同的赋值点,每个都有不同的依赖:

// 编译器内部的 SSA 表示(简化版)
function Component({ theme, isDark }) {
  let styles_t0, styles_t1, styles_final;
  
  if (isDark) {
    // styles_t0 依赖: theme.dark
    styles_t0 = { background: 'black', color: theme.dark };
  } else {
    // styles_t1 依赖: theme.light  
    styles_t1 = { background: 'white', color: theme.light };
  }
  
  // φ 函数:合并不同路径的值
  styles_final = φ(styles_t0, styles_t1, isDark);
  
  return <div style={styles_final}>Content</div>;
}

第二步 :构建 HIR 指令

BuildHIR.ts 中,编译器使用以下 HIR 指令来表示 SSA:

// HIR 指令类型(简化)
type HIRInstruction = 
  // LoadLocal:加载局部变量、Place:存放变量的位置、Binding:变量标识
  | { kind: 'LoadLocal', place: Place, binding: Binding }
  // StoreLocal:存储局部变量、Value:具体变量的值
  | { kind: 'StoreLocal', place: Place, value: Value }
  // LoadGlobal:加载全局变量
  | { kind: 'LoadGlobal', binding: GlobalBinding }
  // 变量合并 
  | { kind: 'Phi', place: Place, operands: Array<Value> };

第三步 :依赖追踪

每个 SSA 值都有明确的依赖关系:

// 依赖图
styles_t0.dependencies = [theme.dark]     // 只在 isDark=true 时计算
styles_t1.dependencies = [theme.light]    // 只在 isDark=false 时计算
styles_final.dependencies = [styles_t0, styles_t1, isDark]

3、响应性推断

可变性分析

编译器在 SSA 图上做可变性分析,判断每个值是否"稳定"——即在本次渲染和上次渲染之间,如果依赖没有变化,这个值能不能安全地复用?

// ✅ 可以被缓存:纯函数,输出只依赖输入
const double = (x) => x * 2;
const result = double(props.count);

// ✅ 可以被缓存:引用稳定的函数
const handleClick = () => {
  console.log(props.name); // props.name 是依赖
};

// ❌ 不能被缓存:有外部副作用
const result = Math.random(); // 每次调用都不同

// ❌ 不能被缓存:直接修改了 props/state
props.items.push(newItem); // push方法修改了props

依赖作用域(Reactive Scope)

编译器会把组件函数分割成多个响应作用域(Reactive Scope) ,每个作用域有自己的依赖集合:

function Component({ a, b, c }) {
  // Scope 1:依赖 a
  const x = computeX(a);

  // Scope 2:依赖 b(与 a 无关)
  const y = computeY(b);

  // Scope 3:依赖 x 和 c
  return (
    <div>
      {x} {y} {c}
    </div>

  );
}

a 变化时,只有 Scope 1 重新计算;b 变化时,只有 Scope 2 重新计算。这比手写 useMemo 更细粒度。

4、代码生成:_c() 缓存槽机制

这是最核心、最直接可见的部分——编译器生成的代码长什么样

_c(N) / useMemoCache(N)

编译器在每个组件的开头插入:

const $ = _c(N); // 申请 N 个缓存槽
  • _creact/compiler-runtime 导出的函数(在 React 19 中内置)
  • N 是这个组件需要的缓存槽总数,由编译器静态计算
  • 每个缓存槽对应一个需要被记忆的值或依赖

缓存槽的读写模式

// 缓存一个计算值(等价于 useMemo)
if ($[0] !== dep) {
  // 检查:依赖是否变化?
  const result = compute(dep);
  $[0] = dep; // 存入依赖
  $[1] = result; // 存入计算结果
} else {
  result = $[1]; // 命中缓存,直接取出
}
缓存数组:  [$[0],      $[1],     $[2],      $[3]    ]
           [dep值,    computed,  fn_dep,    fn_ref  ]
            ↑ 依赖快照  ↑ 计算结果  ↑ 函数依赖  ↑ 函数引用

与 useMemo 对比

维度useMemo编译器生成的 _c()
依赖声明开发者手写 []编译器静态推断,不会漏
闭包开销每次渲染创建闭包直接内联 if/else,无闭包
条件 memo❌ 不支持(Hook 不能在条件里)✅ 支持(if 语句可以在任何地方)
可读性源码清晰生成代码较晦涩,但源码干净

总结

时间里程碑
2017Facebook 内部的 Prepack 项目尝试 JS 静态分析优化,后停止
2019React Hooks 发布,设计时刻意为未来的编译器留了接口(稳定的调用顺序等)
2021Xuan Huang 在 React Conf 演示了 React Forget 第一版原型
2022–2023Joe Savona、Sathya Gunasekaran、Mofei Zhang、Lauren Tan 重写:引入 CFG-based HIR 架构,精确度大幅提升
2024.10Beta 版发布,在 Meta 内部大规模测试
2025.10React Compiler 1.0 正式发布,生产就绪

react complier不是银弹,并不是所有情况都可以使用它,它依然拥有自己的局限性

场景编译器行为说明
try/catchbail out异常路径难以静态分析
违反 Rules of Reactbail out不安全,不做优化
非组件/Hook 的普通函数不处理编译器只管组件和 Hook
Math.random() 等不纯调用不缓存编译器识别为不稳定值
跨组件共享的昂贵计算不共享缓存每个组件实例有独立缓存
useEffect 依赖数组可能影响触发频率升级编译器版本需测试

因此,在项目中,我们可以适当的一些情况使用 useMemo / useCallback:

建议保留已有的 useMemo/useCallback,不要急着删除,等测试充分后再移除精确控制:某些场景(如 effect 依赖精确控制)仍然可以手写 useMemo 作为 escape hatch