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 元素的类似处理
}
编译器将每个输入与上次渲染时的值进行比较。仅当输入改变时,它才重新计算。与手动记忆化相比,有两个重要的区别:
- 更细粒度:编译器可以记忆化条件语句后面的代码段,而
useMemo无法做到,因为 hook 不能有条件地调用。 - 无需手写依赖数组:依赖项从代码中推断,因此不可能“忘记”。
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 模式的编译器、编写测试测量渲染次数、然后逐步扩展。这样做,你就能获得更好的性能,而无需手写依赖数组。