React 基础体系 · 第 70/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Compiler:自动记忆化、编译约束、迁移、诊断和性能验证

React Compiler 是面向 React 组件和 Hooks 的编译器。它在构建阶段分析组件中的数据依赖,并自动插入必要的记忆化逻辑,使组件、计算结果、回调函数或 JSX 在依赖没有变化时可以复用。

这里的“自动”有两个边界:

  1. 它自动推导依赖,不代表所有组件都会被跳过渲染。
  2. 它优化的是 React 组件执行和值的复用,不替代网络缓存、服务端缓存、数据库索引或虚拟列表。

React Compiler 的正确使用方式,不是把所有 React.memouseMemouseCallback 删除后观察结果,而是先理解它需要什么代码约束,再通过诊断和生产构建验证收益。


一、先区分“重新渲染”“重新计算”和“重新提交”

讨论记忆化之前,必须区分 React 更新过程中的几个概念。

假设父组件因为状态变化而重新执行:

function Dashboard() {
  const [query, setQuery] = useState('');

  return (
    <>
      <SearchBox value={query} onChange={setQuery} />
      <ExpensiveChart />
    </>
  );
}

父组件 Dashboard 执行一次,并不自动意味着 DOM 一定发生变化。可以区分为:

  • 组件重新执行:函数组件函数体再次运行。
  • 计算重新执行:例如过滤数组、创建对象、生成 JSX 的表达式再次计算。
  • 协调(reconciliation):React 比较新的元素树和旧元素树。
  • 提交(commit):React 将实际变化写入 DOM 或其他宿主环境。

传统手动优化通常试图控制前两步:

const ExpensiveChart = memo(function ExpensiveChart({
  data,
}: {
  data: Point[];
}) {
  const model = useMemo(() => buildChartModel(data), [data]);

  return <Chart model={model} />;
});

这里有三层不同的缓存:

  • memo 尝试在 props 没有变化时跳过 ExpensiveChart 的函数执行。
  • useMemo 尝试复用 buildChartModel(data) 的结果。
  • useCallback 通常用于复用函数引用,使下游的 props 比较更稳定。

React Compiler 的目标,是自动完成与这些意图相近的记忆化,并根据实际数据流推导依赖,而不是要求开发者手工维护依赖数组。

但它不改变 React 的基本语义,也不保证:

<ExpensiveChart data={data} />

在每一次父组件更新时都绝对不执行。开发模式下的 Strict Mode、错误恢复、并发渲染、初始挂载和特定上下文变化,都可能造成额外执行。应观察提交结果和生产性能,而不能只用函数体执行次数推断用户体验。


二、React Compiler 所说的“自动记忆化”是什么

2.1 记忆化的形式化条件

一个计算可以写成:

y=f(x1,x2,,xn)y = f(x_1, x_2, \ldots, x_n)

其中:

  • xix_i 是计算依赖;
  • ff 是计算过程;
  • yy 是计算结果。

如果满足以下条件:

  1. f 是纯函数;
  2. 在两次计算之间,所有依赖 xix_i 都没有变化;
  3. 结果不会因为隐藏的外部状态而改变;
  4. 复用旧结果不会破坏程序语义;

那么可以缓存:

cache[(x1,,xn)]=ycache[(x_1,\ldots,x_n)] = y

下一次依赖相同时直接复用 yy

在 JavaScript 中,“没有变化”通常不是深度相等,而是依赖值的引用或基本值比较。比如:

const visibleTodos = todos.filter(todo => todo.completed);

如果 todos 是同一个数组引用,并且筛选条件没有变化,编译器可以认为这个计算的输入没有变化。若父组件重新执行,但 todos 引用没有变,重新筛选的必要性就降低了。

相反,下面的代码每次都会构造新数组:

const visibleTodos = [...todos].filter(todo => todo.completed);

从 JavaScript 语义看,这个新数组本身就是新的值。编译器只有在能够确认“新数组的生成过程依赖哪些值”且结果可安全复用时,才可能消除重复计算;不能简单把“内容看起来相同”当作“引用相同”。

2.2 编译器优化的对象不只有组件

React Compiler 可以围绕组件和 Hooks 的数据流进行分析。实际优化可能涉及:

  • 组件输出;
  • JSX 元素;
  • 事件处理函数;
  • 计算结果;
  • 派生对象和数组;
  • 自定义 Hook 内部的中间结果。

因此,下面这种手写代码:

function ProductPage({ product }: { product: Product }) {
  const priceText = useMemo(
    () => formatPrice(product.price, product.currency),
    [product.price, product.currency],
  );

  const handleBuy = useCallback(() => {
    addToCart(product.id);
  }, [product.id]);

  return (
    <ProductSummary
      priceText={priceText}
      onBuy={handleBuy}
    />
  );
}

在适合编译的情况下,编译器可能推导出:

  • priceText 只依赖 product.priceproduct.currency
  • handleBuy 只依赖 product.id
  • 下游 JSX 可以在相关输入不变时复用。

这并不意味着编译器把代码简单转换为下面这种固定形式:

const priceText = useMemo(...);
const handleBuy = useCallback(...);

实现细节属于编译器内部机制,不能把生成代码当成稳定 API。开发者应依赖源代码语义、编译诊断和性能结果,而不是依赖某种具体的产物结构。

2.3 自动记忆化不等于自动深比较

考虑这段代码:

function UserCard({ user }: { user: User }) {
  return <div>{user.name}</div>;
}

若父组件每次都创建新对象:

<UserCard user={{ name: currentName }} />

user 的引用每次都不同。编译器不能无条件假定两个不同对象一定等价,因为对象可能包含不可见字段、原型、访问器或其他语义。

更可靠的写法是让数据边界稳定:

const user = useMemo(
  () => ({ name: currentName }),
  [currentName],
);

return <UserCard user={user} />;

不过,在启用 Compiler 的项目中,是否仍需要这段手动记忆化,应由实际诊断和性能验证决定。重点不是“任何对象都必须稳定”,而是不要误以为 Compiler 会自动进行任意深度比较。


三、Compiler 依赖哪些编译约束

React Compiler 能够优化的前提,是组件和 Hooks 遵守 React 的语义约束。很多约束并非 Compiler 新增,而是 React 本来就要求的规则;Compiler 只是需要更严格地依赖这些规则进行静态分析。

3.1 组件和 Hook 必须保持纯度

组件渲染阶段应当是:

output=render(props,state,context)output = render(props, state, context)

也就是说,在输入相同的情况下,渲染结果不应依赖无法追踪的外部变化,也不应在渲染过程中产生有意义的外部副作用。

错误示例:

let renderCount = 0;

function Report() {
  renderCount += 1;

  return <span>{renderCount}</span>;
}

这里的输出依赖模块级可变变量。若 Compiler 复用一次渲染结果,renderCount 的变化可能不再反映到界面,程序语义就会改变。

正确的计数应该放到 React 状态中:

function Report({ value }: { value: number }) {
  return <span>{value}</span>;
}

如果目的是统计渲染次数,应使用开发诊断、Profiler 或独立的观测机制,而不是让渲染函数修改全局状态。

3.2 不要修改 props、state 或上下文对象

错误代码:

function TodoList({ todos }: { todos: Todo[] }) {
  todos.sort((a, b) => a.title.localeCompare(b.title));

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

sort() 会原地修改 todos。这会产生两条问题路径:

  1. 父组件传入的数据被子组件改变;
  2. 编译器看到的输入引用没有变化,但其内部内容已经被修改。

此时“引用相同意味着输入相同”的推导失效。

改为创建新数组:

function TodoList({ todos }: { todos: Todo[] }) {
  const sortedTodos = [...todos].sort((a, b) =>
    a.title.localeCompare(b.title),
  );

  return (
    <ul>
      {sortedTodos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

现代 TypeScript 还可以使用更明确的不可变类型:

type TodoListProps = {
  todos: readonly Todo[];
};

readonly 不能阻止运行时所有修改,但能让 TypeScript 更早发现直接调用会修改数组的方法。

3.3 Hook 调用顺序不能依赖条件

错误代码:

function Panel({ enabled }: { enabled: boolean }) {
  if (enabled) {
    useEffect(() => {
      subscribe();
      return unsubscribe;
    }, []);
  }

  return <div />;
}

enabled 在两次渲染之间变化时,Hook 的调用顺序变化,React 无法正确对应前后状态槽位。

应该把条件放进 Hook 内部:

function Panel({ enabled }: { enabled: boolean }) {
  useEffect(() => {
    if (!enabled) {
      return;
    }

    subscribe();
    return unsubscribe;
  }, [enabled]);

  return <div />;
}

编译器诊断通常会把这类问题报告出来,因为违反 Hook 调用规则的代码无法安全优化。

3.4 Effect 的依赖必须表达真实数据流

错误示例:

function SearchResults({ query }: { query: string }) {
  useEffect(() => {
    fetchResults(query);
  }, []);
  
  return null;
}

Effect 读取了 query,却把依赖数组写成空数组。问题不是“少写了一个性能优化项”,而是逻辑错误:query 改变后,Effect 不会按预期重新运行。

正确写法:

function SearchResults({ query }: { query: string }) {
  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    }).catch(error => {
      if (error.name !== 'AbortError') {
        throw error;
      }
    });

    return () => controller.abort();
  }, [query]);

  return null;
}

这里还处理了竞态和取消:

  1. query 变化时,旧请求被取消;
  2. 新请求只对应最新查询;
  3. 取消错误不被当作真实失败;
  4. 其他错误仍然暴露。

Compiler 可以分析依赖,但不能替你决定网络请求的取消策略、错误展示策略或服务端缓存策略。

3.5 不要把“渲染时读取可变外部值”当作普通输入

例如:

const config = mutableConfig;

function Widget() {
  return <div>{config.theme}</div>;
}

config.theme 可能在 React 不知情时被修改。组件没有通过 props、state 或 context 声明这个依赖,编译器很难安全推导其生命周期。

应将变化纳入 React 数据流,或者使用专门的外部 store 订阅机制:

function Widget() {
  const theme = useSyncExternalStore(
    configStore.subscribe,
    configStore.getSnapshot,
    configStore.getServerSnapshot,
  );

  return <div>{theme}</div>;
}

外部 store 必须提供稳定、可比较的 snapshot 语义。直接读取全局可变对象不是等价替代。


四、手动记忆化与自动记忆化的关系

4.1 React.memouseMemouseCallback 的原始语义

React.memo 是组件级的 props 比较机制:

const Avatar = memo(function Avatar({
  url,
  name,
}: {
  url: string;
  name: string;
}) {
  return <img src={url} alt={name} />;
});

默认比较不是深比较,而是对 props 的浅层比较。对象、数组和函数只要引用不同,就可能被视为变化。

useMemo 缓存一个计算结果:

const filtered = useMemo(
  () => items.filter(item => item.active),
  [items],
);

useCallback 缓存一个函数引用:

const onSelect = useCallback(
  (id: string) => selectItem(id),
  [selectItem],
);

它们都存在维护成本:

  • 依赖数组可能写错;
  • 额外的缓存本身有内存和管理成本;
  • 过度使用会让代码更难阅读;
  • 错误的稳定化可能掩盖状态或 Effect 设计问题。

4.2 Compiler 不会替你修复错误依赖

下面的代码即使使用了 useMemo,仍然是错误的:

const label = useMemo(
  () => formatPrice(price, currency),
  [price],
);

计算读取了 currency,但依赖数组没有包含它。Compiler 的存在不会使错误依赖自动变正确。正确代码是:

const label = useMemo(
  () => formatPrice(price, currency),
  [price, currency],
);

启用 Compiler 后,手动记忆化通常不必为了“让组件可编译”而大量增加。已有的、语义正确且对性能有证据支持的手动记忆化可以保留;删除或保留应通过 Compiler 诊断和基准测试决定。

4.3 "use memo""use no memo" 指令

React Compiler 支持通过源码指令控制特定范围的编译行为。常见形式是:

function LargeLegacyWidget(props: Props) {
  "use no memo";

  return <LegacyWidget {...props} />;
}

这类指令必须写在函数体开头,形式上类似 JavaScript 的 directive prologue。它不是普通字符串注释。

使用 "use no memo" 的合理原因包括:

  • 某个第三方库与编译后的数据流假设不兼容;
  • 正在隔离一个需要逐步迁移的遗留组件;
  • 某段代码有已知的编译行为问题,需要临时绕过。

它不是性能优化开关。关闭记忆化通常意味着放弃该范围的 Compiler 优化,应该配合 issue、测试或基准记录原因。

在支持的 Compiler 配置和版本中,也可以用 "use memo" 对特定函数显式表达“希望编译”。具体指令支持范围、默认编译模式和框架配置会随 Compiler 版本变化,因此应以当前安装版本的 React Compiler 文档和诊断结果为准,不能把指令当作跨版本不变的运行时 API。


五、在 React 19 项目中接入 Compiler

React Compiler 是构建期工具,不是通过 import 调用的运行时函数。基本流程是:

  1. 安装 Compiler Babel 插件;
  2. 把插件接入实际处理 JSX/TSX 的构建链;
  3. 让 ESLint 使用对应版本的 React Hooks/Compiler 诊断;
  4. 先在小范围启用并构建验证;
  5. 再扩大编译范围。

5.1 Babel 项目的接入形式

React 19 项目可以安装:

npm install -D babel-plugin-react-compiler eslint-plugin-react-hooks

若项目已经使用 Babel,插件配置的基本形态如下:

// babel.config.js
module.exports = function (api) {
  api.cache(true);

  return {
    presets: [
      // 项目已有的 preset,例如 JSX、TypeScript 或框架 preset
    ],
    plugins: [
      ['babel-plugin-react-compiler', {
        // 使用当前版本文档支持的配置项
      }],
    ],
  };
};

这里不能机械复制空的 presets 到真实项目。Babel 配置必须保留项目原有的 JSX、TypeScript 和目标环境处理方式;Compiler 需要接在最终能够识别 React 组件和 Hook 的编译链中。

如果项目使用 Vite 的 React 插件,接入通常表现为把 Babel 插件交给 React 插件:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

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

该配置的前提是项目使用的 @vitejs/plugin-react 版本支持通过 Babel 选项传递插件,并且没有被其他自定义转换链覆盖。运行:

npm run build

预期结果不是某个固定的文件大小或性能数字,而是:

  • TypeScript 和 JSX 构建成功;
  • Compiler 没有把不兼容代码变成运行时错误;
  • 生成的产物能在生产模式启动;
  • 测试和关键交互仍然通过。

对于 Next.js、Expo、React Native 等框架,应使用框架当前版本提供的 Compiler 配置入口。框架可能已经集成 Compiler,也可能仍要求显式开启。不要同时在框架内部和自定义 Babel 配置中重复接入同一个插件,否则可能导致重复转换或难以定位的构建问题。

5.2 React 版本边界

本文示例以 React 19 为基础。React 19 项目通常不需要为 Compiler 额外引入旧版本兼容运行时。

如果项目是 React 17 或 React 18,是否能使用取决于 Compiler 当前版本和项目构建方式;某些场景需要 react-compiler-runtime。兼容运行时的版本、安装方式和支持范围必须与实际 Compiler 版本匹配,不能因为项目成功安装了插件,就推断运行时一定兼容。

5.3 渐进式启用

大型项目不宜第一步就把所有包都改成“必须编译”。更安全的迁移顺序是:

编译单个页面
  ↓
运行单元测试和交互测试
  ↓
检查 Compiler/ESLint 诊断
  ↓
对比生产构建性能
  ↓
扩大到一个业务包
  ↓
扩大到整个应用

如果 Compiler 提供了当前版本支持的逐步编译模式,可以先采用只编译显式标记或满足条件的范围,再逐步切换到更广泛的自动推断模式。模式名称和配置字段属于版本敏感内容,应以安装版本的配置文档为准,不应把某个版本的实验配置直接复制到另一个版本。


六、客户端边界与服务端边界

6.1 "use client" 不是 Compiler 开关

在 React Server Components 架构中:

'use client';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

'use client' 表示该模块进入客户端组件边界,需要发送到客户端并允许使用状态、Effect 和事件处理器。

它不表示:

  • 必然启用 Compiler;
  • 必然禁用 Compiler;
  • 组件只能在浏览器构建;
  • 服务端组件不经过任何编译。

Compiler 负责的是编译阶段的组件分析;RSC 的客户端/服务端边界则负责模块归属和可用 API。两者是正交概念。

6.2 服务端组件中的收益不同

服务端组件通常在服务端渲染为 RSC payload 或 HTML。它们没有浏览器交互状态,因而客户端级别的“跳过重复渲染”收益通常不是主要收益。

Compiler 仍可能对服务端组件和共享组件进行编译分析,但以下问题不由自动记忆化解决:

  • 数据请求是否重复;
  • fetch 是否命中框架缓存;
  • 服务端渲染是否被 CDN 缓存;
  • 数据库查询是否高效;
  • RSC payload 是否过大;
  • 客户端边界是否放置合理。

例如:

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);

  return <ProductDetails product={product} />;
}

这里的性能重点是 getProduct 的请求、缓存、序列化和客户端边界,而不是给 ProductDetails 加多少 useMemo

如果 ProductDetails 是客户端组件,跨边界传递的 props 还必须满足框架要求的可序列化约束。函数不能从服务端组件直接作为普通 props 传入客户端组件;Compiler 不会改变这个 RSC 协议限制。

6.3 服务端和客户端的故障路径不同

客户端组件中,错误可能表现为:

  • 交互后界面没有更新;
  • Effect 使用旧闭包;
  • 事件处理器引用或状态更新行为变化;
  • 生产构建与开发构建表现不同。

服务端组件中,错误可能表现为:

  • 构建阶段无法处理某个模块;
  • 服务端运行时访问了浏览器 API;
  • RSC 序列化失败;
  • 服务端请求被错误缓存;
  • 客户端收到过大的 payload。

所以迁移时必须分别测试客户端交互和服务端请求/流式渲染,不能只打开一个浏览器页面确认“能显示”。


七、迁移手动记忆化代码的正确顺序

7.1 先建立行为基线

迁移前记录至少三类信息:

function ProductList({ products }: { products: Product[] }) {
  return (
    <Profiler
      id="ProductList"
      onRender={(
        id,
        phase,
        actualDuration,
        baseDuration,
      ) => {
        console.log({
          id,
          phase,
          actualDuration,
          baseDuration,
        });
      }}
    >
      <List products={products} />
    </Profiler>
  );
}

需要关注:

  • 首次挂载时间;
  • 输入、切换、滚动等交互的更新耗时;
  • 更新期间实际提交的组件;
  • 长任务数量;
  • 内存是否持续增长;
  • 网络和服务端指标是否受影响。

Profiler 的回调数据适合开发和测试环境,不应把它原样打进生产日志。生产验证应使用正式构建和真实或接近真实的数据量。

7.2 先修复规则问题,再启用 Compiler

错误代码:

const cached = useMemo(() => {
  return items.sort(compare);
}, [items]);

这段代码即使有 useMemo,仍然会修改 items。正确迁移是:

const cached = useMemo(() => {
  return [...items].sort(compare);
}, [items]);

随后才决定是否保留 useMemo。如果 Compiler 能安全处理该计算,手动缓存可能不再必要;如果这个计算确实昂贵且手动形式清晰,也可以保留。

7.3 不要批量删除所有手动 API

下面这段手动缓存可能承担的不只是“少执行一次”:

const options = useMemo(
  () => ({
    roomId,
    serverUrl,
  }),
  [roomId, serverUrl],
);

useEffect(() => {
  const connection = createConnection(options);
  connection.connect();

  return () => connection.disconnect();
}, [options]);

如果直接删除 useMemo

const options = { roomId, serverUrl };

useEffect(() => {
  const connection = createConnection(options);
  connection.connect();

  return () => connection.disconnect();
}, [options]);

options 每次渲染都是新对象,Effect 可能每次都断开并重连。Compiler 也许能优化这个模式,也许会因为上下文或配置不编译;在没有验证之前,批量删除会改变生命周期行为。

更容易推导的写法是把对象创建放进 Effect:

useEffect(() => {
  const options = { roomId, serverUrl };
  const connection = createConnection(options);

  connection.connect();

  return () => connection.disconnect();
}, [roomId, serverUrl]);

此时 Effect 的真实依赖直接出现在依赖数组中,代码的因果关系更清楚。


八、诊断:编译成功不等于代码已被优化

8.1 三种不同的结果

Compiler 处理某个组件时,至少要区分:

  1. 编译并优化:组件满足约束,Compiler 生成优化结果。
  2. 跳过编译:Compiler 发现无法安全推导,保留原始语义。
  3. 编译失败:配置、语法或插件链存在错误,构建失败。

“跳过编译”往往是安全策略,不应直接理解为 bug。Compiler 的优先级是保持 React 语义,而不是为了覆盖率强行转换所有函数。

8.2 ESLint 诊断

React 官方的 ESLint 插件不仅包含传统 Hooks 规则,较新版本还可能包含与 Compiler 相关的规则和诊断。典型配置形态如下:

// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';

export default [
  reactHooks.configs.flat.recommended,
];

实际使用时应让 eslint-plugin-react-hooks 与 React/Compiler 版本保持兼容,并查看已安装版本导出的配置名称。某些版本提供更完整的推荐配置,名称可能不同。

下面的代码应被诊断:

function BadComponent({ value }: { value: number }) {
  const result = useMemo(() => value * 2, []);

  return <span>{result}</span>;
}

因为 value 被读取却没有列入依赖。修复:

function GoodComponent({ value }: { value: number }) {
  const result = useMemo(() => value * 2, [value]);

  return <span>{result}</span>;
}

还应关注以下类别:

  • Hook 调用顺序错误;
  • 组件渲染期副作用;
  • 修改 props 或 state;
  • 不兼容的库模式;
  • 手动记忆化依赖错误;
  • 可能破坏 Compiler 推导的语法或运行时行为。

ESLint 诊断是静态信号,不是性能证明。没有诊断只说明代码更符合约束,不能证明某次交互变快。

8.3 构建日志和跳过原因

在接入阶段,应启用当前 Compiler 版本支持的日志或诊断回调,记录:

  • 文件名;
  • 组件或 Hook 名称;
  • 编译成功、跳过或失败;
  • 跳过原因;
  • 配置和版本。

不要依赖未公开或内部生成代码格式来判断结果。不同版本可能改变日志事件结构、跳过策略和输出代码。稳定的验证方式是:

  1. 使用官方支持的 Compiler 配置查看诊断;
  2. 使用 ESLint 提前暴露违反约束的源代码;
  3. 用测试和 Profiler 验证行为与性能;
  4. 把 Compiler 版本锁定在可复现的构建环境中。

8.4 第三方库兼容性

第三方库可能返回代理对象、可变对象,或者依赖某些函数引用每次变化。比如某个库错误地把“回调引用变化”当作刷新信号:

function Wrapper() {
  const callback = () => {
    refreshExternalWidget();
  };

  return <ExternalWidget onRefresh={callback} />;
}

Compiler 若将 callback 稳定化,外部库若依赖引用变化触发逻辑,就可能暴露库本身的隐含契约。

正确修复方向通常是:

  • 升级第三方库;
  • 按库文档使用显式更新 API;
  • 将真正变化的数据作为参数传入;
  • 在确认范围后对特定组件使用 "use no memo"
  • 为该组件补充集成测试。

不应把所有第三方组件都标记为 "use no memo",否则会失去大部分收益,也会隐藏真正的库兼容问题。


九、一个完整的性能验证算例

假设有一个搜索页面:

type Product = {
  id: string;
  name: string;
  category: string;
};

function ProductSearch({
  products,
}: {
  products: readonly Product[];
}) {
  const [query, setQuery] = useState('');
  const [category, setCategory] = useState('all');

  const filteredProducts = products.filter(product => {
    const matchesQuery = product.name
      .toLowerCase()
      .includes(query.toLowerCase());

    const matchesCategory =
      category === 'all' || product.category === category;

    return matchesQuery && matchesCategory;
  });

  return (
    <>
      <input
        value={query}
        onChange={event => setQuery(event.target.value)}
      />

      <select
        value={category}
        onChange={event => setCategory(event.target.value)}
      >
        <option value="all">全部</option>
        <option value="book">图书</option>
        <option value="tool">工具</option>
      </select>

      <ProductGrid products={filteredProducts} />
    </>
  );
}

每次输入一个字符时,ProductSearch 都会重新执行,过滤操作复杂度近似为:

T(n)=O(n)T(n) = O(n)

其中 nn 是产品数量。Compiler 可以缓存中间结果,但只有在 productsquerycategory 都没有变化时,缓存才有意义。输入框改变 query 时,过滤必然需要重新计算;Compiler 不可能在结果依赖变化后仍然复用旧数组。

因此下面两种更新的预期不同:

  • 改变 query:过滤结果可能改变,不能跳过过滤;
  • 父组件因无关状态更新,但 productsquerycategory 都不变:过滤结果可以复用。

如果业务要求输入时保持响应,还可能需要把问题交给并发 API:

const [query, setQuery] = useState('');
const [deferredQuery, setDeferredQuery] = useState('');

useEffect(() => {
  const id = setTimeout(() => setDeferredQuery(query), 0);
  return () => clearTimeout(id);
}, [query]);

但更典型的 React 写法是:

const deferredQuery = useDeferredValue(query);

然后使用 deferredQuery 做昂贵过滤。这里的作用是允许 React 延后非紧急更新,不是记忆化。两者解决不同问题:

  • Compiler:依赖不变时减少重复计算或重复渲染;
  • useDeferredValue:在依赖变化时调整更新优先级;
  • useTransition:把某次状态更新标记为非紧急;
  • 虚拟列表:减少实际渲染的可见元素数量。

如果 products 有 100 万条,自动记忆化不能把 O(n)O(n) 变成 O(1)O(1)。这时应考虑服务端搜索、索引、分页或虚拟化,而不是继续添加 useMemo

9.1 验证实验设计

可以进行三组对照:

组别 配置 目的
A Compiler 关闭,保留原始代码 观察未优化基线
B Compiler 开启,保留手动记忆化 观察兼容迁移结果
C Compiler 开启,删除有证据支持范围内的手动记忆化 观察自动推导结果

每组都应固定:

  • 浏览器版本;
  • 生产构建方式;
  • 数据规模;
  • 网络条件;
  • CPU 限制;
  • 操作脚本;
  • 采样次数。

不要只比较一次“刷新到显示完成”的时间。更有价值的操作脚本是:

  1. 页面首次打开;
  2. 输入 10 个字符;
  3. 切换 5 次分类;
  4. 父组件触发无关状态更新;
  5. 清空输入;
  6. 重复 10 次。

需要观察:

  • 输入到可交互的延迟;
  • 每次交互的提交耗时;
  • 长任务数量和最长时长;
  • 过滤计算是否在无关更新中重复发生;
  • 首屏 JavaScript 体积是否变化;
  • 内存是否因缓存增加而持续增长。

自动记忆化通常改善的是更新路径,不一定改善首次加载。Compiler 本身还可能增加少量编译生成代码,因此“运行时更快”和“产物更小”不能假设为同一方向。


十、常见失败表现与定位路径

10.1 失败:Compiler 接入后构建失败

优先检查:

npm ls react react-dom babel-plugin-react-compiler eslint-plugin-react-hooks
npm run build

检查结果:

  • 是否存在多个 React 版本;
  • Babel 插件是否被正确加载;
  • TypeScript、JSX 的处理顺序是否被自定义配置改变;
  • 框架是否已经内置 Compiler,又被重复配置;
  • Node.js 和构建工具是否满足项目要求。

恢复方式是先移除 Compiler 插件或关闭框架开关,确认原始构建仍然可用,再用最小页面重新接入。不要在构建链和业务代码同时大规模修改,否则无法确定故障来源。

10.2 失败:界面显示旧数据

首先区分是 Compiler 问题还是原有依赖错误。检查:

const label = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName],
);

如果 lastName 改变而 label 不变,这是不完整依赖造成的旧值,不是 Compiler 应该修复的行为。

还要检查:

  • 是否修改了 props/state;
  • 是否从模块级变量读取可变值;
  • 是否把不稳定对象错误地放入 Effect 依赖;
  • 是否使用了违反 Hook 规则的条件调用;
  • 是否只在开发模式测试而没有测试生产构建。

10.3 失败:某个外部组件行为改变

隔离范围:

function LegacyEditor(props: EditorProps) {
  "use no memo";

  return <ThirdPartyEditor {...props} />;
}

然后为该组件添加集成测试,验证:

  • 初始化;
  • 外部值更新;
  • 用户输入;
  • 销毁和重新挂载;
  • 回调触发;
  • 受控/非受控模式。

如果关闭编译后恢复,下一步不是永久保留指令,而是确定第三方组件依赖了哪个不稳定引用或可变对象,并优先升级或修复集成方式。

10.4 失败:Profiler 显示“执行次数更多”

开发环境可能启用了 Strict Mode,React 会进行额外调用以帮助发现不纯渲染逻辑。并发渲染还可能开始一个渲染后放弃它,导致函数执行次数不等于用户看到的提交次数。

正确判断方式是同时看:

  • phasemount 还是 update
  • actualDuration
  • 实际 commit;
  • 生产构建;
  • 真实用户交互延迟。

不能单凭 console.log('render') 次数判断 Compiler 失败。


十一、哪些问题 Compiler 不负责解决

11.1 网络和数据缓存

以下代码是否重复请求,不由组件记忆化决定:

useEffect(() => {
  fetch(`/api/products?q=${query}`);
}, [query]);

请求缓存、去重、取消、重试和错误恢复,需要由请求层、框架数据层或服务端策略负责。

11.2 大列表和复杂算法

对于 nn 个列表项,单次过滤的复杂度仍然可能是:

O(n)O(n)

记忆化只能减少“输入相同”的重复执行,不能降低输入变化时的算法复杂度。排序、图计算、全文搜索和大量 DOM 节点需要分别考虑算法、索引、分页、Web Worker 或虚拟化。

11.3 状态设计和 Effect 架构

如果一个组件因为状态放置过高而频繁更新,Compiler 可能减少部分重复工作,但不会改变状态更新的传播范围。把只影响局部交互的状态下移,通常比给整棵子树添加手动缓存更直接。

同样,Compiler 不会自动消除不必要的 Effect。很多 Effect 实际上只是把一个派生值绕了一圈写回状态:

const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

可以直接派生:

const fullName = `${firstName} ${lastName}`;

这减少了一次状态更新和一次额外渲染,属于状态建模问题,不是记忆化问题。


十二、生产采用时的判断标准

一个组件适合交给 Compiler,通常需要满足:

  • 渲染逻辑纯;
  • props、state 和 context 按不可变方式处理;
  • Hook 规则正确;
  • Effect 依赖表达真实数据流;
  • 外部 store 遵守订阅和 snapshot 约束;
  • 第三方库没有依赖未声明的引用变化;
  • 有测试覆盖关键交互;
  • 有生产构建基线可供比较。

采用 Compiler 后,应保留三条长期验证链:

  1. 静态链:TypeScript、ESLint、Compiler 诊断;
  2. 行为链:单元测试、组件测试、端到端测试;
  3. 性能链:生产 Profiler、浏览器性能记录和真实用户指标。

其中任何一条都不能替代另外两条。静态检查通过,不代表请求取消正确;测试通过,不代表大列表足够快;基准变快,也不代表所有边界组件都保持了正确生命周期。

React Compiler 的核心价值,是把“组件依赖是否稳定、哪些值可以复用”从手工维护的一部分工作交给编译器推导。要获得这个价值,代码必须首先满足 React 的纯度、不可变性和 Hook 规则;接入之后,还必须区分编译覆盖率、运行时语义和实际性能。自动记忆化是一个编译优化能力,而不是对组件设计、数据获取和性能工程的替代。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。