React Compiler:让开发者忘掉 memo 这件事

一、从"React Forget"说起

React Compiler 最早由 Meta 工程师 黄玄(Xuan Huang)Joe Savona 主导,2021 年立项时的代号叫 "React Forget"

这个名字取得很妙------"让开发者忘掉手动 memo 这件事"

时间线:

  • 2021:立项,Meta 内部实验
  • 2023 React Conf:首次公开演示
  • 2024 May:正式改名 React Compiler,开源 Beta
  • 2024 Oct:进入 Release Candidate
  • 2025:Instagram、Quest Store 等 Meta 全线产品上生产
  • 2026 现在:React 19 生态推荐搭配使用

要理解它为什么值得单独发一个编译器,得先看它解决的痛。

二、痛点:手写 memo 三宗罪

React 的默认渲染模型是"父 render → 子全部 render"。为了避免不必要的重渲染,开发者被迫手写:

jsx 复制代码
const value = useMemo(() => computeExpensive(a, b), [a, b]);
const handler = useCallback(() => doSomething(x), [x]);
const MemoChild = memo(Child);

这套 API 有三大罪状:

1. 心智负担重

每写一个组件都要想:这里要不要 memo?依赖数组对不对?props 稳定吗?很累。

2. 容易写错

依赖数组漏一个变量 → stale closure(读到过期的 state)。这种 bug 隐蔽、难查、生产环境才复现。

3. 过度使用反而更慢

useMemo 本身有开销:

  • 每次 render 都要读缓存对象(内存)
  • 比较依赖数组(CPU)
  • 更新缓存槽(内存写)

当计算本身比"比较 + 缓存"还便宜时,memo 就是纯亏。 比如:

jsx 复制代码
const sum = useMemo(() => a + b, [a, b]); // 负优化

a + b 一个 CPU 周期就算完,比较 [a, b] 还得读两次数组、比较两次值------给一个 O(1) 的加法套了一个更贵的判断,纯赔本。

三、React Compiler 的核心思路

一句话:开发者写"看起来每次都重算"的代码,编译器在构建时自动帮你插入等价的 memoization。

markdown 复制代码
                    ┌─────────────────────┐
   你写的 JSX ────► │  React Compiler     │ ────► 优化后的 JS
   (无 memo)      │  (Babel plugin)     │       (自动缓存)
                    └─────────────────────┘

它本质是一个 Babel 插件 ,在构建时对函数组件和自定义 Hook 做静态分析代码转换

编译示例

源码:

jsx 复制代码
function Profile({ user }) {
  const fullName = user.firstName + ' ' + user.lastName;
  const handleClick = () => alert(fullName);
  
  return (
    <div>
      <Avatar url={user.avatar} />
      <button onClick={handleClick}>{fullName}</button>
    </div>
  );
}

编译后(伪代码):

jsx 复制代码
function Profile({ user }) {
  const $ = useMemoCache(6);   // 分配一个缓存数组

  let fullName;
  if ($[0] !== user.firstName || $[1] !== user.lastName) {
    fullName = user.firstName + ' ' + user.lastName;
    $[0] = user.firstName;
    $[1] = user.lastName;
    $[2] = fullName;
  } else {
    fullName = $[2];
  }

  let handleClick;
  if ($[3] !== fullName) {
    handleClick = () => alert(fullName);
    $[3] = fullName;
    $[4] = handleClick;
  } else {
    handleClick = $[4];
  }

  // 连 JSX 本身也被缓存!
  let jsx;
  if ($[5] !== user.avatar /* 其他依赖 */) {
    jsx = (
      <div>
        <Avatar url={user.avatar} />
        <button onClick={handleClick}>{fullName}</button>
      </div>
    );
    $[5] = /* ... */;
  } else {
    jsx = /* cached */;
  }

  return jsx;
}

关键点:JSX 表达式本身会被缓存。 这是手写 memo 几乎不会做的事------你不会给整个 return 语句包一层 useMemo,但编译器会。

四、内部原理:HIR 与数据流分析

React Compiler 内部是一个完整的编译器管线:

css 复制代码
JS/JSX 源码
    ▼
AST(Babel 语法树)
    ▼
HIR(High-level IR,React 团队自研的中间表示)
    ▼
控制流分析(Control Flow Graph)
    ▼
可变性分析(Mutability Inference)------ 哪些值会被修改
    ▼
依赖分析(Dependency Inference)------ 每个值依赖哪些输入
    ▼
Reactive Scope 划分 ------ 哪些代码共享一个缓存槽
    ▼
代码生成(插入 useMemoCache)

其中最难的是可变性分析。举个例子:

jsx 复制代码
function Foo({ items }) {
  const result = [];
  items.forEach(x => result.push(x * 2));   // result 是可变的
  return <List data={result} />;
}

编译器要判断出 result 虽然是 const,但内容会变化,需要把整个 push 循环视为一个"反应域(reactive scope)",只有 items 变了才重新执行。

五、避免负优化的核心规则

这是最精妙的部分------编译器怎么知道该 memo 什么、不该 memo 什么?

内部有一套决策链:

markdown 复制代码
对每个表达式/值:
  1. 这个值是不是"逃逸"了本次 render?   ── 没逃逸 → 不 memo
  2. 这个值是不是原始类型(primitive)?  ── 是    → 大概率不 memo
  3. 计算本身贵不贵?                     ── 不贵  → 通常不单独 memo
  4. 能不能和其他值合并进同一个作用域?   ── 能    → 合并共享缓存槽
  5. 依赖里有没有恒定不变的东西?         ── 有    → 从依赖里剔除

规则 1:Escape Analysis(逃逸分析)

只有离开当前 render 作用域的值才值得缓存。

什么叫"逃逸"?

  • 作为 JSX 属性传给子组件
  • 作为 JSX children
  • 从组件/hook 返回
  • 传给 useEffect 依赖
  • 存进 ref、订阅回调等
jsx 复制代码
function Foo({ items }) {
  const count = items.length;           // 本地用,不 memo
  const doubled = count * 2;            // 本地用,不 memo
  const list = items.map(x => x * 2);   // 传给子组件,会 memo
  
  console.log(count, doubled);
  return <List data={list} />;
}

原因countdoubled 就算变了也不影响子组件(它们没被传出去),缓存它们毫无意义。

规则 2:Primitive 基本不 memo

原始类型(number/string/boolean)几乎不会被单独缓存。

原因:

  • 原始类型比较是 O(1),本来就不慢
  • 不会导致子组件 memo 失效("张三" === "张三" 恒为 true)
  • 缓存 primitive = 花一个内存槽存一个值,收益基本为零
jsx 复制代码
const greeting = `Hello, ${user.name}`;   // string,不单独 memo
const isAdult = user.age >= 18;           // boolean,不单独 memo

例外 :如果 primitive 的计算贵(比如 expensiveFn().toString()),编译器会因为"计算成本高"而 memo。

规则 3:Reactive Scope 合并(最精妙)

多个值如果依赖相同、总是一起变,就共享一个缓存槽。

jsx 复制代码
function Chart({ data, theme }) {
  const xAxis  = computeXAxis(data);
  const yAxis  = computeYAxis(data);
  const colors = pickColors(theme);
  const legend = buildLegend(data, theme);
  
  return <Canvas x={xAxis} y={yAxis} colors={colors} legend={legend} />;
}

手写会写 4 个独立的 useMemo,4 组依赖数组,4 次比较。

编译器分析出

  • xAxisyAxis 依赖完全一样(都是 data)→ 合并成一个 Scope
  • colors 单独 Scope(只依赖 theme
  • legend 单独 Scope(依赖 data + theme

编译后只做 3 次比较(不是 4 次),逻辑相关的值聚在一起,缓存局部性更好。

规则 4:依赖裁剪(Dependency Pruning)

编译器能识别哪些"依赖"其实是不变的常量,从依赖里剔除。

jsx 复制代码
const MULTIPLIER = 2;   // 模块级常量

function Foo({ x }) {
  const result = x * MULTIPLIER;   // 依赖里不会包含 MULTIPLIER
  return <Bar value={result} />;
}

同理,import 进来的函数、useState 的 setter、useRef 的 ref 对象,都会被识别为"恒稳定",不计入依赖。

规则 5:本地临时值不 memo

只在当前作用域用一次的中间值直接算,不进缓存。

jsx 复制代码
const filtered = items.filter(x => x.active);   // 中间产物
const result = filtered.map(x => x.value);      // 用完 filtered 就没了
return <List data={result} />;

filtered 只在下一行用了一次,直接内联进 result 的计算,或跟 result 合并进同一个 Scope。

六、成本模型(Cost Model)

内部有一个粗略的成本估算:

操作 相对成本
Primitive 计算(+、-、比较) 极低
字符串拼接
对象/数组字面量 低(但创建了新引用)
箭头函数 低(但创建了新引用)
数组遍历(map/filter/reduce) 中~高
调用未知函数 高(保守估计)

判断逻辑:"计算成本 > memo 开销" 且 "值会逃逸" 才 memo。对不确定的函数调用,保守假设它可能贵,倾向于 memo。

七、手写 vs Compiler:一个对照实验

jsx 复制代码
function Search({ query, users }) {
  const trimmed = query.trim();
  const upperQuery = trimmed.toUpperCase();
  const filtered = users.filter(u => u.name.includes(trimmed));
  const handleClick = () => console.log(trimmed);
  
  return (
    <div>
      <Header title={upperQuery} />
      <List data={filtered} onItemClick={handleClick} />
    </div>
  );
}

新手手写版(存在负优化):

jsx 复制代码
const trimmed     = useMemo(() => query.trim(), [query]);              // ❌ 字符串操作太便宜
const upperQuery  = useMemo(() => trimmed.toUpperCase(), [trimmed]);   // ❌ 同上
const filtered    = useMemo(() => users.filter(...), [users, trimmed]); // ✅ 数组遍历,值得
const handleClick = useCallback(() => ..., [trimmed]);                  // ✅ 函数引用,值得

Compiler 优化版:

jsx 复制代码
// 便宜的 primitive → 不 memo
const trimmed = query.trim();
const upperQuery = trimmed.toUpperCase();

// 数组、传给子组件 → memo
if ($[0] !== users || $[1] !== trimmed) {
  filtered = users.filter(...);
}

// 函数、传给子组件 → memo
if ($[3] !== trimmed) {
  handleClick = () => ...;
}

// JSX 也 memo
if (/* deps changed */) {
  jsx = <div>...</div>;
}

差异:编译器跳过了对便宜 primitive 的 memo(避免负优化),又比手写多做了 JSX 级别的 memo(增加收益)。

八、Rules of React:编译器安全的前提

编译器要能安全地插入 memoization,前提是你的代码遵守 Rules of React------因为如果代码有副作用、可变引用、突变 props,缓存出来的东西可能是错的。

编译器会在构建时检查:

规则 违反例子
组件/Hook 是纯函数 在 render 里改全局变量
不修改 props/state props.list.push(x)
Hook 遵循 Rules of Hooks if (x) useState(0)
Ref 只在 effect/handler 里读写 render 里 ref.current++

如果检测到违规,编译器会跳过这个组件不做优化 ,同时给出 lint 警告。这是它的防御机制------宁可不优化,也不生成错误代码

配套的 eslint-plugin-react-compiler 会把这些规则前置到编辑器里报错。

九、实际应用

9.1 安装(React 19+)

bash 复制代码
npm install --save-dev babel-plugin-react-compiler
npm install --save-dev eslint-plugin-react-compiler

Vite 配置:

js 复制代码
import react from '@vitejs/plugin-react';

export default {
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-react-compiler', {}]],
      },
    }),
  ],
};

Next.js(14.3+):

js 复制代码
module.exports = {
  experimental: {
    reactCompiler: true,
  },
};

9.2 渐进式采纳

编译器提供三种模式:

  1. 全量模式(默认):所有组件都编译
  2. Opt-in 模式 :只编译带 'use memo' 指令的组件
  3. Opt-out 模式 :默认编译,用 'use no memo' 跳过
jsx 复制代码
function Heavy() {
  'use memo';  // 主动开启
  // ...
}

function Legacy() {
  'use no memo';  // 兜底关闭
  // ...
}

老项目推荐 :先全量跑一遍编译器,看 lint 报错多不多,逐步修复;不能立刻修的用 'use no memo' 兜底。

9.3 实际收益(Meta 官方数据)

  • Instagram Web:初始加载提升 12%,交互响应提升 2.5×
  • Quest Store:几乎所有页面 render 次数减少 20~50%
  • 代码库改造 :删掉了数千行手写的 useMemo / useCallback / memo

9.4 最受益的场景

场景 收益
大列表(Table/List) 极大
多输入框表单 极大
图表 / 可视化 极大
深嵌套组件树
静态展示页 一般

十、和其他框架的对比

方案 优化时机 更新粒度 心智模型
Vue 3 编译期 + 运行时 组件 + 静态节点 响应式(getter 追踪)
Solid.js 编译期 signal 级(极细) 组件只跑一次
Svelte 编译期 DOM 节点级 编译到 imperative
React + Compiler 编译期 表达式级 每次重新执行 + 自动缓存

React Compiler 的路线独特之处:保留了"函数每次重新执行"的心智模型(React 的宗教),但通过编译器让它性能接近细粒度响应式方案。

是一个非常"React 式"的解法------不改哲学,改工具链。

十一、局限与注意事项

  • 不能解决网络请求 / IO 慢 ------ 编译器只优化 render
  • 不能解决业务逻辑写得差 ------ O(n²) 算法还是慢
  • 首次渲染无变化 ------ 主要优化后续更新
  • class 组件不支持 ------ 只处理函数组件和 Hook
  • 缓存空间开销 ------ 每个组件多分配一个数组,虚拟长列表要评估
  • 依赖代码规范 ------ 违反 Rules of React 的老代码会被跳过

十二、给你的建议

  1. 新项目直接开 ------ React 19 + Compiler + eslint 插件三件套
  2. 老项目先加 lint ------ 把 Rules of React 违规修完再打开编译器
  3. 写代码时忘掉 memo ------ 专注业务,让编译器操心性能
  4. 性能瓶颈还是要 profile ------ 编译器不是银弹

十三、总结

React Compiler = 编译期自动 memoization。

它不改变 React 的编程模型,而是把开发者过去手写的 memo / useMemo / useCallback 通过静态分析自动生成,粒度更细、覆盖更全、出错更少

它的核心武器是四板斧:

  • 逃逸分析:只 memo 会影响下游的值
  • Reactive Scope 合并:智能分组,避免重复比较
  • 依赖裁剪:剔除恒稳定的依赖
  • 成本模型:跳过便宜操作,避免负优化

结果是:手写 memo 的正收益全部拿到,负收益全部避免。

这也是 Meta 敢在 Instagram 生产环境全线铺开的原因------它比人写得更精准、更均衡。本质上,它把"memoization 策略"这件事从"依赖开发者的经验"变成"编译器的静态分析问题"。

React Fiber 解决了时间维度 的性能问题(长任务不阻塞主线程),React Compiler 解决了空间维度的性能问题(不必要的 render 被自动跳过)。至此,React 才算真正补齐了它十年来的两个大坑。

忘掉 memo 吧,让编译器操心。

相关推荐
xiyueyezibile1 小时前
“自迭代” Skill 的 team-pitfalls 的演进
前端·后端·ai编程
林恒smileZAZ1 小时前
`Array(100).map(() => 1)` 为什么全为空?
前端·javascript
小巧的砖头2 小时前
C#会重蹈覆辙吗?系列之2:反射及元数据的性能问题
开发语言·前端·c#
KaMeidebaby2 小时前
卡梅德生物技术快报|蛋白的叠氮基修饰:实操解析:核酸模板耦合蛋白的叠氮基修饰实现靶蛋白定点共价标记
前端·人工智能·物联网·算法·百度
濮水大叔2 小时前
在大型 Vue 项目中,如何有效管理复杂的状态?“散装版” vs “套装版”
前端·vue.js·typescript
玄星啊2 小时前
可修改:代码的生存底线
前端·ai编程
没落英雄3 小时前
3. DeepAgents 实战 - Memory Skills 与上下文工程
前端·人工智能·架构
Bigger3 小时前
对不起!我错怪你了,UnoCSS:一次和 AI 一起排查 DevTools 卡顿的经历
前端·css·人工智能
laowang3573 小时前
依赖故障时,日志体系需要埋哪些关键节点才能快速定位是安装失败、解析失败还是运行时缺失?
前端·javascript·vue.js·webpack·node.js