AI 写的 React 组件,合并前必查的 6 个坏味道——每个都有代码对比

先说一句:这篇不是让你别用 AI 写 React。

我自己每天用 Claude Code + Cursor,产出效率至少翻倍。但要说得罪的话------AI 生成的 React 代码,直接合并的风险,比你想象的高

不是因为 AI 语法错。语法它比你还熟。

是因为 React 的"最佳实践"是一堆上下文相关 的判断:什么时候用 useEffect、什么时候不用;state 该放哪一层;key 用什么;要不要 memo。这些判断需要看这段代码所在的整个组件树,而 AI 大多数时候只看到你贴过去的那几行。

于是就有了下面这 6 个几乎每天都能在 AI 生成的 PR 里看到的坏味道。每一个我都给出真实的对比代码,你合并前扫一眼就能看出来。


1. 把渲染时能算的东西塞进 useEffect

这是 AI 生成 React 代码里最普遍的坏味道,没有之一。

AI 常见写法

tsx 复制代码
function OrderList({ orders }: { orders: Order[] }) {
  const [total, setTotal] = useState(0);
  const [filtered, setFiltered] = useState<Order[]>([]);

  useEffect(() => {
    const filteredOrders = orders.filter(o => o.status === 'paid');
    setFiltered(filteredOrders);
    setTotal(filteredOrders.reduce((sum, o) => sum + o.amount, 0));
  }, [orders]);

  return (
    <div>
      <p>合计: {total}</p>
      <ul>{filtered.map(o => <li key={o.id}>{o.name}</li>)}</ul>
    </div>
  );
}

为什么坏

三层问题:

  1. 多渲染一轮。orders 变 → 组件渲染(拿到旧 total/filtered) → useEffect 跑 → setState → 再渲染一次。用户能感知的白屏/闪一下,很多来源于此。
  2. state 和 props 会脱同步。如果哪天你多加一个 setFiltered 的分支忘了同步 total,数据就错了。
  3. 依赖数组稍微写错就是死循环。AI 写这类代码经常忘依赖或多依赖。

正确写法

派生数据直接在渲染阶段算,不要 state,也不要 effect:

tsx 复制代码
function OrderList({ orders }: { orders: Order[] }) {
  const filtered = orders.filter(o => o.status === 'paid');
  const total = filtered.reduce((sum, o) => sum + o.amount, 0);

  return (
    <div>
      <p>合计: {total}</p>
      <ul>{filtered.map(o => <li key={o.id}>{o.name}</li>)}</ul>
    </div>
  );
}

如果 filter 真的很贵(比如上万条+复杂计算),再考虑 useMemo先别急着优化

判断标准

看到 AI 生成的 useEffect,第一个问自己:"这个逻辑,能不能直接写在 return 上面?" 大多数情况都能。


2. 把 props 复制到 useState

AI 常见写法

tsx 复制代码
function UserForm({ user }: { user: User }) {
  const [name, setName] = useState(user.name);
  const [email, setEmail] = useState(user.email);

  return (
    <>
      <input value={name} onChange={e => setName(e.target.value)} />
      <input value={email} onChange={e => setEmail(e.target.value)} />
    </>
  );
}

看上去很合理:表单要能编辑,所以得有 state。

为什么坏

useState(user.name) 里的 user.name 只在组件第一次挂载时被读一次 。之后父组件传入新的 user,name 也不会更新。

这个 bug 特别隐蔽------本地开发切换用户能看到,但 AI 写完你跑一遍单个用户流程,PR 就过了。

上线之后一切换用户,表单显示上一个用户的数据,老板会问:"你测过没?"

正确写法

明确表单是"受控还是非受控"。两种解:

A. 完全受控,state 在父组件

tsx 复制代码
function UserForm({ user, onChange }: { 
  user: User; 
  onChange: (patch: Partial<User>) => void 
}) {
  return (
    <>
      <input 
        value={user.name} 
        onChange={e => onChange({ name: e.target.value })} 
      />
      <input 
        value={user.email} 
        onChange={e => onChange({ email: e.target.value })} 
      />
    </>
  );
}

B. 内部 state,但要用 key 强制重挂载

tsx 复制代码
<UserForm key={user.id} user={user} />

一切换 user.id,React 会销毁旧组件重新创建,state 也会用新的 initial value。

判断标准

看到 useState(props.xxx),九成是错的。要么把 state 提上去,要么用 key 隔离。


3. useEffect 里 fetch,不处理竞态

AI 常见写法

tsx 复制代码
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser);
  }, [userId]);

  return user ? <div>{user.name}</div> : <Skeleton />;
}

你能一眼看出问题吗?

为什么坏

用户快速点击不同的用户 A → B → C,fetch 会按顺序发出 ,但返回顺序不一定

如果 A 的请求走网络慢,C 已经回来了显示了 C 的名字,然后 A 的响应姗姗来迟------页面会突然变回 A 的数据

这不是理论,这是每一个做过用户列表页的前端都栽过的坑。AI 几乎从不主动处理。

正确写法

AbortController 或 stale flag:

tsx 复制代码
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    const ctrl = new AbortController();
    fetch(`/api/users/${userId}`, { signal: ctrl.signal })
      .then(r => r.json())
      .then(setUser)
      .catch(err => {
        if (err.name !== 'AbortError') throw err;
      });
    return () => ctrl.abort();
  }, [userId]);

  return user ? <div>{user.name}</div> : <Skeleton />;
}

或者更省心,直接用数据请求库:SWR、React Query、TanStack Query 都自带竞态处理。

判断标准

useEffect 里但凡出现异步,cleanup 函数一定要写。没写就是坏味道。


4. 列表 key 用 index

AI 常见写法

tsx 复制代码
{items.map((item, i) => (
  <ListItem key={i} data={item} />
))}

写这一行时 AI 甚至不会犹豫。

为什么坏

当列表顺序变化中间插入删除时,React 会用 key 去匹配前后两次渲染的元素。用 index 做 key,等于告诉 React:"第一位永远是第一位"------但你换了内容它认不出来。

后果:

  1. 列表项里如果有 <input>,用户输入到一半,你在上面插入一项,焦点就跑了
  2. 列表项如果是带内部 state 的组件,state 会串位
  3. 动画/transition 会错乱

正确写法

数据本身的稳定 id:

tsx 复制代码
{items.map(item => (
  <ListItem key={item.id} data={item} />
))}

如果数据真的没 id,前端可以在拿到数据时用 crypto.randomUUID() 或自增计数补一个,但要保证同一个数据永远拿同一个 id------不能每次渲染新生成。

判断标准

看到 key={index} key={i} key={idx},直接怀疑。除非这个列表永远不会重排、不会插入删除、不会有内部 state------才 100% 安全。


5. 内联对象 / 函数,搭配 memo 组件 = memo 失效

AI 常见写法

tsx 复制代码
const Row = React.memo(({ style, onClick, data }: Props) => {
  return <div style={style} onClick={onClick}>{data.name}</div>;
});

function Table({ rows }: { rows: Row[] }) {
  return rows.map(row => (
    <Row
      key={row.id}
      style={{ padding: 8, borderBottom: '1px solid #eee' }}
      onClick={() => console.log(row.id)}
      data={row}
    />
  ));
}

看上去合理:子组件 memo 了,应该优化了。

为什么坏

style={{ padding: 8 }}onClick={() => ...} 每次渲染都是新的引用

React.memo 默认用 Object.is 做浅比较,新旧引用不等 → memo 直接失效 → 每次父组件渲染,所有 Row 都重渲染。

你以为你优化了,其实一点没优化,还多了个浅比较的开销。

正确写法

内联对象提到外面(如果是纯静态的):

tsx 复制代码
const rowStyle = { padding: 8, borderBottom: '1px solid #eee' };

function Table({ rows }: { rows: Row[] }) {
  const handleClick = useCallback((id: string) => {
    console.log(id);
  }, []);

  return rows.map(row => (
    <Row
      key={row.id}
      style={rowStyle}
      onClick={handleClick}
      rowId={row.id}
      data={row}
    />
  ));
}

const Row = React.memo(({ style, onClick, rowId, data }: Props) => {
  return <div style={style} onClick={() => onClick(rowId)}>{data.name}</div>;
});

注意:让 Row 内部生成 () => onClick(rowId) 才能保证 onClick 是稳定引用。

判断标准

看到 React.memo 出现,立即扫一眼调用处的 props------如果传的是 {...}[...]() => ...,memo 直接白包。


6. useMemo / useCallback 无脑加

这条和上一条正好相反------AI 也经常反过来滥用 memo

AI 常见写法

tsx 复制代码
function Greeting({ name }: { name: string }) {
  const greeting = useMemo(() => `Hello, ${name}!`, [name]);
  const handleClick = useCallback(() => alert(greeting), [greeting]);

  return <button onClick={handleClick}>{greeting}</button>;
}

三行代码,两个 hook。

为什么坏

useMemouseCallback 本身有成本:

  • 每次渲染都要跑依赖数组的浅比较
  • React 内部要维护一个额外的记忆槽
  • 代码可读性直线下降

一个字符串拼接,直接计算比 useMemo 快十倍。给 button 传的 onClick 也不是 memo 组件,useCallback 完全没意义。

滥用 memo 的代码,通常比不加 memo 更慢------因为该做的事情没少做,还多做了一层浅比较。

正确写法

tsx 复制代码
function Greeting({ name }: { name: string }) {
  const greeting = `Hello, ${name}!`;
  return <button onClick={() => alert(greeting)}>{greeting}</button>;
}

只在下面两种情况才加 memo:

  1. 计算真的贵(比如上万条数据的 filter/sort/reduce)
  2. 值/函数要作为 prop 传给 memo 组件,或者作为 useEffect/useMemo 的依赖

除此之外------不加就是对的

判断标准

看到 useMemo/useCallback,反问一句:"如果去掉它,哪里会重新计算/重渲染?" 答不上来,就删掉。


最后:一份合并前 60 秒 checklist

如果你没时间读完全文,把下面这段贴到你的 Code Review 模板里:

bash 复制代码
□ useEffect 里的逻辑,能不能挪到渲染阶段?
□ useState(props.xxx) 出现了吗?出现就是错。
□ useEffect 里的 fetch,有没有 AbortController 或 cleanup?
□ 列表 key 是不是稳定 id?不是就改。
□ React.memo 组件的 props,有没有内联对象/函数/数组?
□ useMemo/useCallback,如果删掉会怎样?说不上来就删。

六个问题,合并前扫一眼一分钟搞定。


一句话总结

AI 生成的 React 代码,语法都对,但 React 不是纯粹语法。

它是一套"什么时候该干什么、什么时候该什么都不干"的判断力。AI 看不到你的组件树、看不到用户如何交互、看不到线上的性能数据------这些判断,现在还得你来做。

用 AI 写代码不丢人,合并前不看 AI 写的什么,才丢人。

相关推荐
Highcharts.js1 小时前
软件开发公司为什么选择 Highcharts?
前端·前端框架·echarts·数据可视化·技术选型·highcharts·图表工具
多加点辣也没关系1 小时前
JavaScript|第13章:原始类型的方法
开发语言·javascript·ecmascript
এ慕ོ冬℘゜2 小时前
深入理解 JavaScript 事件体系:Window、鼠标与键盘事件详解
开发语言·javascript·okhttp
阿祖zu2 小时前
企业 AI 转型之痛,个人提效十倍不止,组织效率仍止步不前?
前端·aigc·agent
摇滚侠3 小时前
SpringBoot3+Vue3 全套视频教程 45-51
前端·javascript·vue.js
触底反弹4 小时前
🔥 RAG 到底是怎么工作的?掰开揉碎了给你讲明白!
javascript·人工智能·后端
Hilaku4 小时前
前端大裁员背后的恐慌:我们还剩什么底牌?
前端·javascript·程序员
云空4 小时前
《Three.js 3D实例大全》
开发语言·javascript·3d·three.js
咖啡无伴侣4 小时前
unplugin-auto-import 使用详解+面试高频考点
前端·架构