我手写了一版 React Compiler:AI 最常漏的 3 个 memo 场景

0 阅读3分钟

React Compiler 1.0 正式发布快一年,我上周才真正把它装进项目。起因很简单:我用 Claude Code 生成的组件越堆越多,手写 memo 和 useCallback 的纪律早就崩了,想看看这个「自动 memo」能不能兜底。

我挑了 5 个典型组件,实测它们的转译输出。结果比预期有意思:干净的代码真的一个 memo 都不用写,但 AI 特别爱写的 3 种写法,轻则让编译器整组件罢工,重则表面编译成功、缓存悄悄冻结,数据变了 UI 不动。

这篇文章就干一件事:把编译器在这 3 种代码面前拒绝做的事,我手动写一遍。先把 5 个用例的判决放前面:

用例典型场景编译器判决
基线:过滤列表干净代码全自动记忆化
就地改 stateuser.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 心法只剩三条:

  1. 写代码时当编译器不存在——不手写 useMemo 和 useCallback,把精力花在:别就地改对象、别把 hooks 写进 if、别在组件里读组件外的可变全局
  2. 不放心就跑一遍转译——看输出里有没有 compiler-runtime,依赖有没有被丢出缓存 key
  3. 只有编译器明确跳过的组件,才回退到手写 memo——手写不是新范式,是兜底

你的项目里,AI 生成的组件这三种写法各占几成?评论区报个数。如果你已经全量开了 React Compiler,遇到「编译成功但行为不对」的案例,求你贴出来——那种案例现在全网都缺。