React Compiler 1.0 正式发布快一年,我上周才真正把它装进项目。起因很简单:我用 Claude Code 生成的组件越堆越多,手写 memo 和 useCallback 的纪律早就崩了,想看看这个「自动 memo」能不能兜底。
我挑了 5 个典型组件,实测它们的转译输出。结果比预期有意思:干净的代码真的一个 memo 都不用写,但 AI 特别爱写的 3 种写法,轻则让编译器整组件罢工,重则表面编译成功、缓存悄悄冻结,数据变了 UI 不动。
这篇文章就干一件事:把编译器在这 3 种代码面前拒绝做的事,我手动写一遍。先把 5 个用例的判决放前面:
| 用例 | 典型场景 | 编译器判决 |
|---|---|---|
| 基线:过滤列表 | 干净代码 | 全自动记忆化 |
| 就地改 state | user.name = e.target.value | 整组件跳过 |
| hooks 塞进 if | 按模式开关副作用 | 整组件跳过 |
| let 累加计算 | render 里 for 循环求和 | 能编译(意外) |
| 读组件外可变全局 | i18n / 单例配置 | 编译成功,缓存冻结 |
三个「跳过/冻结」就是我说的 AI 最常漏的场景。下面逐个拆,每个都给编译器的真实输出和手写修复。
实验怎么搭的
不依赖任何构建器,Babel 直接转译,判据只有一条:输出里出现 import { c as _c } from "react/compiler-runtime" 就是记忆化成功;输出和输入一字不差,就是整组件跳过。
npm install @babel/core babel-plugin-react-compiler @babel/preset-react
const babel = require('@babel/core');
const out = babel.transformSync(src, {
presets: [require('@babel/preset-react')],
plugins: [['babel-plugin-react-compiler', {}]],
configFile: false,
});
console.log(out.code.includes('compiler-runtime') ? 'MEMOIZED' : 'SKIPPED');
Next.js 和 Vite 用户走各自的官方构建插件,行为一致;搭这个最小环境只是为了看清编译器到底改了什么。版本:babel-plugin-react-compiler@1.0.0,完整用例和输出都在文末的仓库式附录里可复现。
先看基线:干净代码真的不用写 memo
AI 生成的「正常」组件大概长这样——过滤列表,没有一处手写 memo:
export function TodoList({ todos, onDone }) {
const [query, setQuery] = useState('');
const visible = todos.filter((t) => t.title.includes(query));
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{visible.map((t) => (
<button key={t.id} onClick={() => onDone(t.id)}>
{t.title}
</button>
))}
</div>
);
}
按老经验,这段代码每次 render 都要:重新算 visible、给两个内联函数造新身份。如果子组件包了 memo,全部白包。
编译器输出(节选):
// onChange 只创建一次(sentinel 哨兵判断)
if ($[7] === Symbol.for("react.memo_cache_sentinel")) {
t4 = e => setQuery(e.target.value);
$[7] = t4;
}
// 整段列表 JSX 的缓存 key:onDone、query、todos
if ($[0] !== onDone || $[1] !== query || $[2] !== todos) {
// visible.map 那一大块 JSX 只在依赖变化时重算
}
我以前会手写的 useMemo 和 useCallback,它全补上了,连 deps 数组都替你算好。这就是 1.0 的基本盘:写干净的代码,memo 的事不用你管。
问题在于,AI 生成的代码不总是干净的。
场景一:就地改 state,编译器直接撂挑子
AI 生成表单组件时特别爱这么写,省一行 spread:
const [user, setUser] = useState({ name: '', email: '' });
function handleNameChange(e) {
user.name = e.target.value; // 就地改
setUser(user); // 原对象传回去
}
老 React 规则下这已经是 bug:改的是原对象,别处若还有 memo 组件引用同一个对象,浅比较直接失灵。而编译器的反应更干脆——整个组件跳过,输出和输入一字不差,连警告都不给。
满怀期待装上 React Compiler,这个组件得到 0 字节优化。
手写修复,把编译器想干的事手动补上:
function handleNameChange(e) {
setUser({ ...user, name: e.target.value });
}
改完重过编译器,自动记忆化全部回来。根源不是性能,是「可变性」:整套缓存建立在「东西不会偷偷变」的假设上,就地 mutation 击穿假设,编译器宁可放弃优化。
场景二:hooks 塞进 if,整组件零优化
条件渲染一多,AI 生成这种结构不稀奇:
if (mode === 'live') {
useEffect(() => {
const t = setInterval(() => setResults([]), 5000);
return () => clearInterval(t);
}, []);
}
这个例子里我甚至给 filtered 手写了 useMemo。结果编译器的输出和输入一字不差。
注意这个细节:跳过的是整个组件,不是我新加的那部分。手写的 useMemo 原样保留,但组件里其余该自动记忆化的内联函数、JSX 块,一个都没优化。违反 Rules of Hooks 的代码,编译器拒绝处理,同样不报错。
手写修复没什么花样,hooks 提到顶层无条件调用,条件逻辑放进 hook 内部:
useEffect(() => {
if (mode !== 'live') return;
const t = setInterval(() => setResults([]), 5000);
return () => clearInterval(t);
}, [mode]);
好在这类问题能在写代码时拦住:装上 eslint-plugin-react-hooks 的官方推荐配置,IDE 直接标红,不用等编译器沉默地躺平。
场景三(最阴):读组件外的可变全局
前两个场景,编译器至少「诚实地躺平」。这个场景它骗你。
AI 生成组件时,如果配置、字典、单例 store 挂在模块作用域,很容易写出这种代码:
const uiText = { zh: { submit: '提交' }, en: { submit: 'Submit' } };
let lang = 'zh';
export function SubmitButton() {
const [sending, setSending] = useState(false);
return (
<button disabled={sending} onClick={() => setSending(true)}>
{uiText[lang].submit}
</button>
);
}
编译器判决:MEMOIZED,看起来皆大欢喜。但把输出翻出来看一眼:
if ($[1] !== sending) { // 缓存 key 只有 sending
t1 = jsxDEV("button", {
disabled: sending,
onClick: t0,
children: uiText[lang].submit, // lang 被烤进这个缓存块
});
$[1] = sending;
$[2] = t1;
} else {
t1 = $[2]; // lang 变了,也从这里拿旧值
}
lang 是模块级的 let,编译器既追踪不到谁会改它,也没把它放进缓存 key。于是 uiText[lang].submit 被永久烤进 keyed on sending 的缓存块——切了语言,哪怕父组件重渲染,按钮上还是旧文案。
手写修复是把它变成 React 能追踪的东西,最起码做成 prop,讲究一点用 context 或 useSyncExternalStore 订阅:
export function SubmitButton({ lang }) {
// lang 现在是编译器能看见的依赖,缓存 key 里就有它
}
三个场景里我最怕这个。前两个好歹是「没优化」,性能损失是渐变的;这个是「优化过头的正确性 bug」,而且编译器不报警告——AI 生成代码又恰恰偏爱模块级单例。
一个反直觉发现:let 累加我猜错了
动手前我背过社区的老结论:「复杂 let 重赋值会让编译器跳过优化」。所以专门写了一个 render 里 for 循环累加、中途改 label 的组件,预期它跳过。
实际输出:MEMOIZED,循环整块进了缓存,key 是 items:
if ($[0] !== items || $[1] !== total) {
for (const item of items) { /* 累加 + 改 label */ }
$[0] = items;
$[1] = total;
}
1.0 对纯本地 let 的追踪能力比我预期强,「let 一定跳过」的老经验已经过时。别背结论,跑一遍就知道。
现在我的做法
装上编译器和官方 lint 之后,我的 memo 心法只剩三条:
- 写代码时当编译器不存在——不手写 useMemo 和 useCallback,把精力花在:别就地改对象、别把 hooks 写进 if、别在组件里读组件外的可变全局
- 不放心就跑一遍转译——看输出里有没有 compiler-runtime,依赖有没有被丢出缓存 key
- 只有编译器明确跳过的组件,才回退到手写 memo——手写不是新范式,是兜底
你的项目里,AI 生成的组件这三种写法各占几成?评论区报个数。如果你已经全量开了 React Compiler,遇到「编译成功但行为不对」的案例,求你贴出来——那种案例现在全网都缺。