React 基础体系 · 第 70/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React Compiler:自动记忆化、编译约束、迁移、诊断和性能验证
React Compiler 是面向 React 组件和 Hooks 的编译器。它在构建阶段分析组件中的数据依赖,并自动插入必要的记忆化逻辑,使组件、计算结果、回调函数或 JSX 在依赖没有变化时可以复用。
这里的“自动”有两个边界:
- 它自动推导依赖,不代表所有组件都会被跳过渲染。
- 它优化的是 React 组件执行和值的复用,不替代网络缓存、服务端缓存、数据库索引或虚拟列表。
React Compiler 的正确使用方式,不是把所有 React.memo、useMemo 和 useCallback 删除后观察结果,而是先理解它需要什么代码约束,再通过诊断和生产构建验证收益。
一、先区分“重新渲染”“重新计算”和“重新提交”
讨论记忆化之前,必须区分 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 记忆化的形式化条件
一个计算可以写成:
其中:
- 是计算依赖;
- 是计算过程;
- 是计算结果。
如果满足以下条件:
f是纯函数;- 在两次计算之间,所有依赖 都没有变化;
- 结果不会因为隐藏的外部状态而改变;
- 复用旧结果不会破坏程序语义;
那么可以缓存:
下一次依赖相同时直接复用 。
在 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.price和product.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 必须保持纯度
组件渲染阶段应当是:
也就是说,在输入相同的情况下,渲染结果不应依赖无法追踪的外部变化,也不应在渲染过程中产生有意义的外部副作用。
错误示例:
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。这会产生两条问题路径:
- 父组件传入的数据被子组件改变;
- 编译器看到的输入引用没有变化,但其内部内容已经被修改。
此时“引用相同意味着输入相同”的推导失效。
改为创建新数组:
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;
}
这里还处理了竞态和取消:
query变化时,旧请求被取消;- 新请求只对应最新查询;
- 取消错误不被当作真实失败;
- 其他错误仍然暴露。
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.memo、useMemo 和 useCallback 的原始语义
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 调用的运行时函数。基本流程是:
- 安装 Compiler Babel 插件;
- 把插件接入实际处理 JSX/TSX 的构建链;
- 让 ESLint 使用对应版本的 React Hooks/Compiler 诊断;
- 先在小范围启用并构建验证;
- 再扩大编译范围。
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 处理某个组件时,至少要区分:
- 编译并优化:组件满足约束,Compiler 生成优化结果。
- 跳过编译:Compiler 发现无法安全推导,保留原始语义。
- 编译失败:配置、语法或插件链存在错误,构建失败。
“跳过编译”往往是安全策略,不应直接理解为 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 名称;
- 编译成功、跳过或失败;
- 跳过原因;
- 配置和版本。
不要依赖未公开或内部生成代码格式来判断结果。不同版本可能改变日志事件结构、跳过策略和输出代码。稳定的验证方式是:
- 使用官方支持的 Compiler 配置查看诊断;
- 使用 ESLint 提前暴露违反约束的源代码;
- 用测试和 Profiler 验证行为与性能;
- 把 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 都会重新执行,过滤操作复杂度近似为:
其中 是产品数量。Compiler 可以缓存中间结果,但只有在 products、query 和 category 都没有变化时,缓存才有意义。输入框改变 query 时,过滤必然需要重新计算;Compiler 不可能在结果依赖变化后仍然复用旧数组。
因此下面两种更新的预期不同:
- 改变
query:过滤结果可能改变,不能跳过过滤; - 父组件因无关状态更新,但
products、query、category都不变:过滤结果可以复用。
如果业务要求输入时保持响应,还可能需要把问题交给并发 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 万条,自动记忆化不能把 变成 。这时应考虑服务端搜索、索引、分页或虚拟化,而不是继续添加 useMemo。
9.1 验证实验设计
可以进行三组对照:
| 组别 | 配置 | 目的 |
|---|---|---|
| A | Compiler 关闭,保留原始代码 | 观察未优化基线 |
| B | Compiler 开启,保留手动记忆化 | 观察兼容迁移结果 |
| C | Compiler 开启,删除有证据支持范围内的手动记忆化 | 观察自动推导结果 |
每组都应固定:
- 浏览器版本;
- 生产构建方式;
- 数据规模;
- 网络条件;
- CPU 限制;
- 操作脚本;
- 采样次数。
不要只比较一次“刷新到显示完成”的时间。更有价值的操作脚本是:
- 页面首次打开;
- 输入 10 个字符;
- 切换 5 次分类;
- 父组件触发无关状态更新;
- 清空输入;
- 重复 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 会进行额外调用以帮助发现不纯渲染逻辑。并发渲染还可能开始一个渲染后放弃它,导致函数执行次数不等于用户看到的提交次数。
正确判断方式是同时看:
phase是mount还是update;actualDuration;- 实际 commit;
- 生产构建;
- 真实用户交互延迟。
不能单凭 console.log('render') 次数判断 Compiler 失败。
十一、哪些问题 Compiler 不负责解决
11.1 网络和数据缓存
以下代码是否重复请求,不由组件记忆化决定:
useEffect(() => {
fetch(`/api/products?q=${query}`);
}, [query]);
请求缓存、去重、取消、重试和错误恢复,需要由请求层、框架数据层或服务端策略负责。
11.2 大列表和复杂算法
对于 个列表项,单次过滤的复杂度仍然可能是:
记忆化只能减少“输入相同”的重复执行,不能降低输入变化时的算法复杂度。排序、图计算、全文搜索和大量 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 后,应保留三条长期验证链:
- 静态链:TypeScript、ESLint、Compiler 诊断;
- 行为链:单元测试、组件测试、端到端测试;
- 性能链:生产 Profiler、浏览器性能记录和真实用户指标。
其中任何一条都不能替代另外两条。静态检查通过,不代表请求取消正确;测试通过,不代表大列表足够快;基准变快,也不代表所有边界组件都保持了正确生命周期。
React Compiler 的核心价值,是把“组件依赖是否稳定、哪些值可以复用”从手工维护的一部分工作交给编译器推导。要获得这个价值,代码必须首先满足 React 的纯度、不可变性和 Hook 规则;接入之后,还必须区分编译覆盖率、运行时语义和实际性能。自动记忆化是一个编译优化能力,而不是对组件设计、数据获取和性能工程的替代。
系列导航与关联阅读
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论