React Compiler:告别手动 useMemo 和 useCallback

1. 概述

很多 React 项目在整个代码库中都充斥着 useMemo、useCallback 和 React.memo。目的是避免不必要的重新渲染,但代价是代码更冗长,且容易出现依赖项错误。React Compiler 将记忆化工作转移到构建时:它分析组件并在必要的地方自动插入缓存。

本文介绍了 React Compiler 是什么以及为什么它受到关注。你将学到如何在 Vite 项目中启用编译器、如何逐步迁移、如何为记忆化行为编写测试,以及何时应避免使用。

2. 手动记忆化的问题

考虑一个订单列表页面,有过滤框和不相关的计数器:

import { useState, useMemo, useCallback, memo } from "react";

type Order = { id: number; customer: string; total: number };

const OrderList = memo(function OrderList({
  orders,
  onSelect,
}: {
  orders: Order[];
  onSelect: (id: number) => void;
}) {
  return (
    <ul>
      {orders.map((o) => (
        <li key={o.id} onClick={() => onSelect(o.id)}>
          {o.customer}: {o.total}
        </li>
      ))}
    </ul>
  );
});

export function OrdersPage({ orders }: { orders: Order[] }) {
  const [keyword, setKeyword] = useState("");
  const [clicks, setClicks] = useState(0);

  const filtered = useMemo(
    () => orders.filter((o) => o.customer.includes(keyword)),
    [orders, keyword]
  );

  const handleSelect = useCallback((id: number) => {
    console.log("Selected order", id);
  }, []);

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
      <OrderList orders={filtered} onSelect={handleSelect} />
    </>
  );
}

这段代码可以正确运行,但有三个易碎的地方:

  • 如果在 OrderList 中移除 memo,useMemo 和 useCallback 几乎就没用了。
  • 如果忘记在依赖数组中包含 keyword,过滤后的列表会显示旧数据。
  • 只要新增一个对象字面量属性(如 style={{ color: "red" }}),整个记忆化工作就会被破坏。

手动记忆化是一个隐式的系统。任何修改组件的人都必须理解它,错误通常直到有人进行性能分析时才会显露。

3. React Compiler 是什么

React Compiler 是一个在构建时运行的工具,通常以 Babel 插件的形式出现。它读取组件和 hook 的代码,理解数据流,然后为以下内容生成带有缓存的代码:

  • 计算值:等效于 useMemo。
  • 函数:等效于 useCallback。
  • JSX 元素:等效于用 memo 包装子组件。如果元素的输入没有改变,编译器会重用旧元素,React 会跳过重新渲染该子树。

下面的代码说明了编译后代码的思路。这是一个简化版本,不是真实输出:

function OrdersPage({ orders }) {
  const $ = useMemoCache(6); // 与组件实例相关联的缓存数组
  const [keyword, setKeyword] = useState("");

  let filtered;
  if ($[0] !== orders || $[1] !== keyword) {
    filtered = orders.filter((o) => o.customer.includes(keyword));
    $[0] = orders;
    $[1] = keyword;
    $[2] = filtered;
  } else {
    filtered = $[2];
  }
  // handleSelect 和 JSX 元素的类似处理
}

编译器将每个输入与上次渲染时的值进行比较。仅当输入改变时,它才重新计算。与手动记忆化相比,有两个重要的区别:

  1. 更细粒度:编译器可以记忆化条件语句后面的代码段,而 useMemo 无法做到,因为 hook 不能有条件地调用。
  2. 无需手写依赖数组:依赖项从代码中推断,因此不可能“忘记”。

4. 为什么 React Compiler 受到关注

有几个原因使得这个主题受到广泛关注:

  • 减少样板代码:代码回归“纯 React”形式,更易阅读和审查。
  • 更好的默认性能:团队无需专家分析就能避免不必要的重新渲染。
  • 强制遵循 React 规则:编译器只能优化“正确的”代码。因此它附带一个 lint 工具来检测违反规则的代码,即使你还未启用编译器。
  • 渐进兼容:可以为单个目录或组件启用,无需重写整个应用。

注意:编译器不是在所有地方都能让代码运行更快。它减少了重复渲染的工作。如果你的瓶颈是网络、包大小或在每次输入改变时运行的繁重循环,编译器无法解决。

5. 在项目中启用 React Compiler

5.1. 使用 Vite 安装

安装插件和 lint 工具。包名可能会随时间变化,因此请查阅你使用的 React 版本的官方文档。

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

将插件添加到 Vite 配置中:

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

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

编译器应该首先在 Babel 插件链中运行,因为它需要在其他插件转换代码之前读取原始代码。

如果项目使用较旧版本的 React,编译器需要额外的运行时包和 target 选项来生成兼容代码。这部分取决于版本,请在配置前查阅文档。

对于 Next.js 或 Expo 等框架,通常有专门的配置标志来启用编译器。标志名在版本之间有过更改,因此请阅读框架的发行说明,而不是复制旧配置。

5.2. 检查编译器是否工作

打开 React DevTools。被编译器优化的组件会在名称旁有“Memo ✨“标签。没有这个标签意味着编译器跳过了该组件,通常是因为违反了第 6 节的规则。

5.3. 启用 lint

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

export default [
  reactHooks.configs.recommended,
];

Lint 会提前报告编译器将跳过或可能运行错误的代码段。你应该在编译器之前启用 lint 并修复所有警告。

6. React 规则:编译器工作的条件

编译器假设组件在渲染时是纯函数。下面的例子违反了这一点:

export function CartSummary({ items }: { items: { price: number }[] }) {
  items.sort((a, b) => b.price - a.price); // 直接修改了 prop
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>Top item: {items[0]?.price}, total: {total}</p>;
}

items.sort 直接修改了传入的数组。有了缓存,编译器可能无法识别 items 已经“改变”,因为引用保持不变,UI 会显示错误。Lint 会警告类似:

error  Mutating a value returned from a function whose return value should not be mutated  react-hooks/...

具体消息取决于插件版本。修复方法是创建副本:

const sorted = [...items].sort((a, b) => b.price - a.price);

常见的违规:

  • 修改 props、state 或 hook 返回的值。
  • 在渲染期间读写 ref.current。
  • 有条件地调用 hook 或在循环内调用。
  • 直接在组件体中产生副作用,例如写入模块变量或调用 API。

遇到这样的代码时,编译器通常会跳过该组件,而不是破坏它。这是安全的行为,但你会失去优化收益。

7. 在大型项目中逐步迁移

7.1. 选择加入模式

对于大型代码库,你可以仅编译标记的组件:

// vite.config.js (片段)
plugins: [["babel-plugin-react-compiler", { compilationMode: "annotation" }]],
export function InvoiceTable({ invoices }: { invoices: Invoice[] }) {
  "use memo";
  // 此组件会被编译
}

在函数体开始处的 "use memo" 指令告诉编译器处理此组件。其他组件保持不变。

7.2. 临时排除组件

当全局启用但某个组件出现问题时,可以暂时为其禁用编译器:

export function LegacyChart(props: ChartProps) {
  "use no memo";
  // 旧代码使用会修改数据的库
}

注意:"use no memo" 是临时的逃生口。你应该记录原因并创建任务来修复,不要让它成为永久的默认设置。

7.3. 那些旧的 useMemo 呢?

编译器可以与已有 useMemo/useCallback 的代码配合工作,所以你不需要立即删除它们。批量删除可能会改变微妙的行为,尤其是当值被用作 useEffect 的依赖时。应该逐步删除,伴随测试。对于新组件,直接编写纯代码,让编译器处理。

8. 为记忆化行为编写测试

你可以验证子组件在无关状态改变时不会重新渲染。下面的例子使用 Vitest 和 React Testing Library,使用已启用编译器的相同 Vite 配置运行:

npm install -D vitest @testing-library/react @testing-library/user-event jsdom
// OrdersPage.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";

const renderSpy = vi.fn();

function OrderList({ orders }: { orders: string[] }) {
  renderSpy();
  return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
}

function OrdersPage() {
  const [clicks, setClicks] = useState(0);
  const orders = ["Alice", "Bob"]; // 不使用 useMemo
  return (
    <>
      <button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
      <OrderList orders={orders} />
    </>
  );
}

describe("OrdersPage 与 React Compiler", () => {
  it("仅计数器改变时不重新渲染 OrderList", async () => {
    render(<OrdersPage />);
    await userEvent.click(screen.getByRole("button"));
    await userEvent.click(screen.getByRole("button"));

    expect(screen.getByRole("button")).toHaveTextContent("Clicks: 2");
    expect(renderSpy).toHaveBeenCalledTimes(1);
  });
});

解释:

  • orders 是数组字面量,没有用 useMemo 包装。没有编译器,每次渲染都会创建新数组,OrderList 会重新渲染。
  • 有编译器,orders 数组和 <OrderList /> 元素都会被缓存,因为它们不依赖于 clicks。
  • renderSpy 仅被调用一次,在首次渲染时。

预期结果:

✓ OrdersPage 与 React Compiler > 仅计数器改变时不重新渲染 OrderList
Test Files  1 passed (1)

如果禁用编译器,最后的断言会失败,值为 3。

toHaveTextContent 匹配器需要在 setup 文件中添加 @testing-library/jest-dom。

注意:在组件体中调用 spy 是副作用。这种做法仅在测试中用来测量渲染次数。不要在生产代码中应用。

9. 何时不应使用(或应谨慎)

  • 代码库大量违反 React 规则:编译器会跳过大多数组件,或更糟的是暴露原本被隐藏的 bug。请先按 lint 建议修复。
  • 基于变异的库:某些状态或表单库在原处修改对象,并期望组件在每次渲染时读取新值。对于使用它们的组件,可能需要 "use no memo"。
  • 自定义构建管道:如果不使用 Babel 或工具链不支持插件,集成会很困难。请预先检查 bundler 的支持。
  • 小型应用、交互少:收益相比构建时间增加和调试复杂性可能不值得。
  • 需要为 effect 保证引用稳定:编译器记忆化是为了优化,不是语义保证。如果逻辑的正确性取决于值保持相同的引用,例如防止 effect 重新运行,应该明确保留 useMemo/useCallback 来表达意图。

此外,调试时浏览器运行的代码与你编写的代码不同。Source map 有帮助,但你应该熟悉 DevTools 和编译器的 playground 工具,以便在需要时查看输出。

10. 结论

React Compiler 不会消灭 useMemo 和 useCallback,但将它们从日常工具转变为特殊情况的逃生口。前提条件是代码必须遵循 React 规则。因此,第一步始终是启用 lint 并修复违规。

建议的步骤:启用 lint、修复警告、为几个组件启用 annotation 模式的编译器、编写测试测量渲染次数、然后逐步扩展。这样做,你就能获得更好的性能,而无需手写依赖数组。

reactreact-compilerperformancefrontendtech-trends