AI写前端代码的15个隐藏坑:从状态管理到异步处理的Code Review实战 1. 为什么AI写的代码一眼看上去没问题跑起来全是坑团队里最近半年有个明显变化提交上来的代码越来越“工整”了。变量命名规范、注释齐全、缩进统一甚至每个函数都贴心地写了JSDoc。但诡异的是这些看起来无可挑剔的代码合并进主干之后线上问题反而变多了。我一开始以为是巧合直到连续三次在Code Review里抓到同一类问题一个useEffect里依赖数组写成了空数组但内部却引用了外部状态一个异步函数用try/catch包住了await但catch块里只写了console.log错误被静默吞掉还有一个列表渲染的key用了数组索引在删除中间项时导致组件状态错乱。这些问题的共同点是代码在语法层面完全正确在逻辑层面却经不起推敲。它们不是那种一眼就能看出来的低级错误而是藏在“看起来合理”的外表下的隐患。后来我意识到这些提交大概率有AI辅助生成的成分——不是说AI写的代码不能用而是AI写代码有它自己的一套“惯性思维”这些惯性如果不经过人工审查就会变成线上事故的种子。这篇文章就是我这半年在Code Review中反复踩坑、反复总结的产物。我会把AI写前端代码时最容易出现的15个坑逐一拆开讲清楚每个坑长什么样、为什么AI容易犯、在Review时怎么快速识别、以及正确的写法应该是什么。不管你是Review别人代码的那个人还是自己用AI辅助写代码的那个人这些内容都能帮你省下不少排查时间。提示本文讨论的是“如何识别和修正AI生成代码中的常见问题”不是否定AI辅助编程的价值。AI能极大提升编码效率但审查环节必须由人来把关。2. 状态管理类坑AI最容易在“响应式”上翻车前端框架的响应式系统是AI最容易出错的地方之一。原因很简单AI的训练数据里充斥着各种版本的代码示例不同框架、不同版本的写法混在一起它生成的代码往往“形似而神不似”。下面这几个坑我在Review里几乎每周都能遇到。2.1 依赖数组的“选择性失明”React的useEffect、useMemo、useCallback都依赖依赖数组来决定何时重新执行。AI生成这类代码时最常见的错误是依赖数组不完整。// AI常见的写法 useEffect(() { fetchData(userId); }, []); // 依赖数组为空但内部用了userId这段代码的问题在于当userId变化时fetchData不会重新执行页面显示的还是旧用户的数据。AI之所以容易犯这个错是因为它在训练数据里见过大量“只在组件挂载时执行一次”的示例于是把空依赖数组当成了一种默认模式。Review时的识别方法很简单看useEffect内部引用了哪些外部变量然后逐一核对依赖数组是否包含它们。如果项目里配了ESLint的react-hooks/exhaustive-deps规则这类问题会被自动捕获但很多人为了“消除警告”会手动加注释禁用规则这反而把问题藏得更深了。正确的做法是老老实实把依赖补全useEffect(() { fetchData(userId); }, [userId]);如果确实只想在挂载时执行一次那内部就不应该引用任何会变化的props或state。这一点在Review时要特别较真因为它的后果往往是“数据不更新”这种难以复现的bug。2.2 闭包陷阱setState里的“过期值”另一个高频问题是闭包捕获了过期的状态值。AI生成的事件处理函数里经常出现这样的写法const [count, setCount] useState(0); const handleClick () { setTimeout(() { setCount(count 1); // 这里的count是闭包捕获的旧值 }, 1000); };如果用户在1秒内点了三次最终count只会变成1而不是3。AI犯这个错的原因是它把setCount(count 1)当成了一个“标准写法”但没有考虑异步场景下闭包的问题。Review时看到setState里直接引用了状态变量本身就要多留个心眼。正确的写法是用函数式更新setCount(prev prev 1);这样每次更新都基于最新的状态值不受闭包影响。这个坑在计数器、购物车数量、分页加载等场景里特别常见Review时优先检查这些地方。2.3 状态提升与组件通信的“想当然”AI在生成组件间通信代码时倾向于把所有状态都放在父组件里然后通过props一层层往下传。这在简单场景下没问题但当组件层级变深时就会出现“props drilling”问题。更隐蔽的坑是AI有时会在子组件里直接修改父组件传来的props比如// 子组件里 const Child ({ list }) { list.push(newItem); // 直接修改props return div{list.length}/div; };这在React里是严格禁止的因为props是只读的。AI之所以会这么写是因为它在某些非React的代码模式里见过类似操作或者混淆了“引用类型可以修改内容”和“props不可变”这两个概念。Review时看到子组件里对props做push、splice、直接赋值等操作一律打回。正确做法是通过回调函数通知父组件来更新状态或者用不可变的方式创建新数组。3. 异步处理类坑Promise和async/await的“表面功夫”异步代码是前端开发里最容易出问题的部分也是AI生成代码时错误率最高的区域之一。AI能写出语法正确的async/await但对错误处理、并发控制、执行顺序的理解经常不到位。3.1 try/catch包了个寂寞这是我在Review里见到最多的异步坑async function fetchUserData() { try { const res await fetch(/api/user); const data await res.json(); return data; } catch (error) { console.log(error); // 只打印不处理 } }这段代码的问题在于catch块只做了console.log错误被“吞掉”了。调用方拿到的是undefined后续代码如果直接访问data.name就会报错而且报错信息跟原始错误完全无关排查起来非常痛苦。AI之所以爱这么写是因为它的训练数据里有大量“演示用”的代码示例这些示例为了简洁往往只写console.log。但在生产代码里错误必须被正确处理要么向上抛出要么返回一个明确的错误状态要么给用户一个友好的提示。Review时看到catch块里只有console.log或空语句就要追问这个错误到底应该怎么处理如果调用方需要知道失败就应该throw error如果不需要至少也要返回一个明确的默认值。3.2 并行请求写成了串行AI在生成多个异步请求时经常无脑地用await逐个执行async function loadData() { const user await fetchUser(); const orders await fetchOrders(); const messages await fetchMessages(); return { user, orders, messages }; }这三个请求之间没有依赖关系完全可以并行执行。串行执行意味着总耗时是三个请求之和而并行只需要最长的那个。在弱网环境下这个差距可能是几百毫秒到几秒。正确的写法是用Promise.allasync function loadData() { const [user, orders, messages] await Promise.all([ fetchUser(), fetchOrders(), fetchMessages() ]); return { user, orders, messages }; }Review时看到连续的await就要判断这些请求之间是否有依赖。如果没有依赖就应该改成并行。这个优化对用户体验的影响非常直接值得在Review时专门提出来。3.3 错误边界缺失一个请求失败拖垮整个页面AI生成的数据加载逻辑往往假设所有请求都会成功。一旦某个请求失败整个页面就可能白屏。比如const [data, setData] useState(null); useEffect(() { fetchData().then(setData); }, []); if (!data) return Loading /; return div{data.name}/div;如果fetchData失败data永远是null页面就永远停在Loading状态。用户不知道发生了什么只能刷新重试。Review时要检查每个异步请求是否有失败状态的处理是否有重试机制是否给用户展示了错误信息这些在AI生成的代码里经常是缺失的。一个更健壮的写法是维护一个状态机const [state, setState] useState({ status: idle, data: null, error: null }); useEffect(() { setState({ status: loading, data: null, error: null }); fetchData() .then(data setState({ status: success, data, error: null })) .catch(error setState({ status: error, data: null, error })); }, []); if (state.status loading) return Loading /; if (state.status error) return Error message{state.error.message} /; return div{state.data.name}/div;这样每种状态都有明确的UI对应用户不会陷入“永远加载中”的困境。4. 渲染与性能类坑AI对“重渲染”缺乏敏感度前端性能问题里大部分都跟不必要的重渲染有关。AI生成代码时往往只关注“功能能不能跑通”对渲染次数、内存泄漏、列表性能等问题缺乏敏感度。下面这几个坑在Review时如果放过后期优化成本会很高。4.1 内联对象和函数每次渲染都是新的AI特别喜欢在JSX里直接写内联对象和箭头函数Child style{{ color: red, fontSize: 14 }} onClick{() handleClick(item.id)} /这段代码每次父组件渲染时style对象和onClick函数都是全新的引用。如果Child用了React.memo这些新引用会导致memo失效子组件跟着一起重渲染。AI犯这个错的原因是内联写法在语法上更简洁而且在很多教程里被当作“正常写法”。但在性能敏感的场景下这种写法会带来不必要的渲染开销。Review时的判断标准是如果子组件用了React.memo、useMemo或useCallback做了优化那么传给它的props就必须保持引用稳定。否则优化就白做了。正确的做法是用useMemo和useCallback缓存const style useMemo(() ({ color: red, fontSize: 14 }), []); const handleClick useCallback((id) { /* ... */ }, []); Child style{style} onClick{() handleClick(item.id)} /注意onClick这里仍然创建了新函数如果Child对onClick的引用敏感还需要进一步优化。这个细节在Review时要根据实际场景判断不能一刀切。4.2 列表key用索引删除时状态错乱这是React列表渲染的经典坑AI几乎每次都会踩{items.map((item, index) ( Item key{index} data{item} / ))}用索引做key在列表顺序不变时没问题。但一旦有删除、排序、插入操作React就会复用错误的组件实例导致状态错乱。比如一个带输入框的列表删除第一项后第二项的输入框里可能还留着第一项的内容。AI之所以爱用索引是因为它在训练数据里见过大量key{index}的示例而且这个写法在简单场景下确实能跑通。但在真实业务里列表操作是常态用索引做key迟早出问题。Review时看到key{index}一律要求改成唯一ID{items.map(item ( Item key{item.id} data{item} / ))}如果数据里没有唯一ID就要在数据层生成一个比如用crypto.randomUUID()或者后端返回的ID。这个改动成本很低但能避免很多诡异bug。4.3 内存泄漏组件卸载后还在setStateAI生成的异步代码经常忘记处理组件卸载的情况useEffect(() { fetchData().then(data { setData(data); // 如果组件已卸载这里会报警告 }); }, []);在React 18之前这会在控制台报“Cant perform a React state update on an unmounted component”警告。虽然React 18之后这个警告被移除了但内存泄漏的问题依然存在——请求还在继续回调还在执行只是结果被丢弃了。更严重的是如果回调里有副作用比如修改全局状态、触发其他请求这些副作用在组件卸载后依然会执行可能导致难以追踪的bug。Review时要检查每个异步操作是否有取消机制是否在组件卸载时清理了定时器、事件监听、订阅正确的写法是用AbortController或一个标志位useEffect(() { let cancelled false; fetchData().then(data { if (!cancelled) setData(data); }); return () { cancelled true; }; }, []);或者用AbortController直接取消请求useEffect(() { const controller new AbortController(); fetchData({ signal: controller.signal }).then(setData).catch(() {}); return () controller.abort(); }, []);这个坑在AI生成的代码里出现频率极高Review时看到异步操作没有清理逻辑就要提出来。5. 类型与边界类坑AI对“异常输入”缺乏想象力AI生成代码时默认假设输入都是“正常”的。但真实世界里用户会输入空字符串、超长文本、特殊字符接口会返回null、undefined、空数组。这些边界情况AI很少主动处理。5.1 可选链和空值合并的缺失AI生成的代码经常直接访问深层属性const userName response.data.user.name;如果response.data.user是null这行代码直接报错。AI之所以不写可选链是因为它的训练数据里很多示例为了简洁省略了这些检查。Review时看到连续的点访问就要问中间任何一层可能是null或undefined吗如果是就要加可选链const userName response?.data?.user?.name ?? 未知用户;注意??和||的区别??只在null和undefined时取默认值而||会在所有假值包括0、、false时取默认值。AI有时会混用这两个导致0被错误地替换成默认值。5.2 数组方法的“想当然”AI在使用数组方法时经常假设数组一定存在且非空const firstItem items[0].name;如果items是空数组items[0]是undefined访问.name就报错。AI很少主动加空数组检查。Review时要检查数组是否可能为空是否可能为null如果是就要加保护const firstItem items?.[0]?.name ?? 暂无数据;另一个常见问题是find方法的返回值处理const user users.find(u u.id targetId); console.log(user.name); // user可能是undefinedfind找不到时返回undefinedAI经常忘记处理这种情况。Review时看到find、filter、reduce等方法的返回值被直接使用就要检查是否有空值保护。5.3 数字计算的精度陷阱AI在生成金额计算、百分比计算等代码时经常直接用浮点数const total price * quantity * 0.1; // 计算税费浮点数计算在前端是经典坑0.1 0.2 ! 0.3。AI生成的代码如果涉及金额必须用整数分或专门的库来处理。Review时看到涉及金额的浮点运算就要提出用Math.round或者转换成整数分来计算const totalInCents Math.round(price * 100) * quantity; const taxInCents Math.round(totalInCents * 0.1); const total taxInCents / 100;这个坑在电商、金融类项目里特别重要Review时不能放过。6. 代码风格与可维护性类坑AI的“过度热情”和“过度省略”AI生成代码时有两个极端要么过度注释、过度封装要么过度省略、过度简写。这两个极端都会影响代码的可维护性。6.1 注释写了等于没写AI特别喜欢写这种注释// 获取用户数据 const getUserData () { /* ... */ }; // 设置加载状态 setLoading(true);这些注释只是把函数名翻译了一遍没有任何额外信息。真正有价值的注释应该解释“为什么”而不是“是什么”为什么这里要加延迟为什么这个请求要重试三次为什么这个状态要用useRef而不是useStateReview时看到“翻译型”注释可以建议删掉或者改成解释意图的注释。代码本身已经说明了“做什么”注释应该补充“为什么这么做”。6.2 过度封装一个函数只做一件事但做了十个函数AI有时会把简单的逻辑拆成很多小函数const formatPrice (price) { const withDecimals addDecimals(price); const withCurrency addCurrency(withDecimals); return withCurrency; }; const addDecimals (price) price.toFixed(2); const addCurrency (price) ¥${price};这种拆分在逻辑复杂时是好事但在这里只是把一行代码拆成了三行增加了阅读时的跳转成本。Review时要判断这个封装是否带来了实际好处如果只是“为了封装而封装”可以建议合并。6.3 魔法数字和硬编码字符串AI生成的代码里经常出现魔法数字if (status 3) { /* ... */ } setTimeout(fn, 86400000);3是什么状态86400000是多少毫秒这些都需要读者去猜。AI之所以这么写是因为它在训练数据里见过大量“演示用”的代码这些代码为了简洁直接用字面量。Review时要求把魔法数字提取成常量const STATUS_APPROVED 3; const ONE_DAY_MS 24 * 60 * 60 * 1000; if (status STATUS_APPROVED) { /* ... */ } setTimeout(fn, ONE_DAY_MS);这样代码的意图就清晰了后续修改也只需要改一处。7. 我的Review检查清单和实操建议上面梳理了15个坑但Review时不可能每次都从头到尾过一遍。我自己的做法是维护一个检查清单按优先级排序每次Review时重点看高风险区域。7.1 按优先级排序的检查清单优先级检查项风险等级P0异步操作的错误处理是否完整高P0组件卸载后是否有状态更新高P0列表key是否用了唯一ID高P1useEffect依赖数组是否完整中高P1并行请求是否被写成串行中P1深层属性访问是否有空值保护中P2内联对象/函数是否影响memo中低P2魔法数字是否提取为常量低P2注释是否解释了“为什么”低P0的问题必须在合并前修复P1的问题建议修复P2的问题可以视情况处理。这个清单不是死的要根据项目类型调整。比如电商项目要特别关注金额计算后台系统要特别关注权限判断。7.2 给AI辅助编程的实用建议如果你自己也在用AI辅助写代码下面几个习惯能帮你减少很多Review时的返工第一让AI解释它的选择。生成代码后追问一句“为什么这里用空依赖数组”或者“这个catch块为什么只打印不抛出”AI往往会暴露出它的假设。如果它的假设不成立你就知道哪里需要改。第二给AI提供上下文。不要只丢一个函数名让它生成把相关的类型定义、接口约定、业务规则一起给它。上下文越完整AI生成的代码越贴合实际需求。第三把Review清单喂给AI。你可以把上面的检查清单作为提示词的一部分让AI在生成代码后自己先过一遍。虽然不能完全替代人工Review但能过滤掉一部分低级问题。第四重点关注AI的“自信区”。AI在生成它熟悉的代码模式时往往不会加额外的保护逻辑。比如它写useEffect时很少主动加清理函数写try/catch时很少主动throw。这些“自信区”就是你需要重点检查的地方。7.3 一个真实的Review案例最后分享一个我最近处理的案例。有个提交里包含这样一个自定义Hookfunction useUserData(userId) { const [data, setData] useState(null); useEffect(() { fetch(/api/user/${userId}) .then(res res.json()) .then(setData); }, []); return data; }这段代码有四个问题依赖数组缺少userId没有错误处理没有取消机制没有加载状态。我让提交者改成了这样function useUserData(userId) { const [state, setState] useState({ status: idle, data: null, error: null }); useEffect(() { if (!userId) return; const controller new AbortController(); setState({ status: loading, data: null, error: null }); fetch(/api/user/${userId}, { signal: controller.signal }) .then(res { if (!res.ok) throw new Error(HTTP ${res.status}); return res.json(); }) .then(data setState({ status: success, data, error: null })) .catch(error { if (error.name ! AbortError) { setState({ status: error, data: null, error }); } }); return () controller.abort(); }, [userId]); return state; }改动之后这个Hook能正确处理用户切换、请求失败、组件卸载等情况。调用方也能根据status展示不同的UI。这个案例里AI生成的原始代码“看起来没问题”但实际使用时会遇到各种边界情况。Review的价值就在于把这些边界情况提前暴露出来。我在实际使用中发现AI辅助编程的效率提升是实实在在的但前提是Review环节不能省。把上面这些坑记在心里Review时就能更快地定位问题而不是被“看起来没问题”的代码迷惑。