
AI 帮我写了三个月代码,然后我打开了 package.json
AI 生成的代码坑人,从来不是因为它写得烂,而是因为它每一次都写得刚刚好——好到你不想再看第二眼。
咱们前端这两年都在经历同一个转折:从「AI 写的这玩意能用吗」,到「这段我懒得自己写了」。
上个月我接手一个内部后台。前同事离职,交接就一句话:
「挺干净的,AI 帮着写的,你看一眼就懂。」
我确实看懂了。业务逻辑清清楚楚,函数都短,注释都有,连命名都比我自己写的规矩。我当时还想,这活儿接得挺舒服。
第三天,运维在群里 @ 我。
「你们那个服务,打包时间从 4 分钟变 11 分钟了。」
我说不可能,代码就那么点。
「你自己看看装了多少东西。」
我数了一下,愣了两秒。
然后我打开了 package.json——就是那个记录「这个项目用了别人写的哪些现成工具」的清单文件。
四十七个。
我能一眼说出用途的,十一个。
剩下三十六个,我得一个一个去搜。搜完发现,有的三年没人维护了,有的功能重复,有的——这个最要命——装到的根本不是正式版。
那天下午我干了两件事:把这四十七个挨个查过去;把 AI 写的那几段"看着挺规范"的代码,拉到本地真跑了一遍。
跑完我心情有点复杂:没有一段在正常情况下出过错,但有五段,我不敢放进生产环境。
下面这 7 个坑,就是那天下午挖出来的。每一个我都跑了脚本、贴了真实结果,完整代码我单独整理了一份放在文末。
我尽量讲得让刚入行的同学也能看懂。看看你中了几条。
坑一:AI 让你装的那个包,可能是个试用版
📌 先看个场景
你在做订单后台,产品说要加个「导出 Excel 表格」。运营催得急,说下午开会要用。
这活儿你闭着眼都能写。但既然有 AI,何必自己写。
你顺手问 AI:「帮我加个 CSV 导出。」
AI 回得飞快,说装 json2csv 这个包就行,还把用法一起给了。
这个包我以前也用过,挺老牌的,社区里用的人不少。所以我连想都没想。
小白通常这么写
bashnpm install json2csv
装完,代码两行搞定,测试通过,提交合并。
看着没问题,对吧?我也这么觉得。
但问题来了
我在本地真装了一遍,然后查了查它到底给我装了什么:
code$ npm view json2csv version
version = '6.0.0-alpha.2'看到那个 alpha 了吗。
alpha 的意思是「内测版」——作者自己都还没做完,标出来是给人试用的,不是给人上生产的。
而你老老实实敲 npm install json2csv,不加任何限定,装进去的就是它。
再看这个版本是什么时候发的:
code6.0.0-alpha.2 2023-01-21
最后一次改动: 2023-01-21三年半,一次都没动过。
它还顺手拖了个东西进来。安装的时候,npm 自己就在屏幕上警告:
codenpm warn deprecated lodash.get@4.4.2: This package is deprecated.
Use the optional chaining (?.) operator instead.翻译成人话:「这个工具我们不维护了,你自己写一个问号点就能实现,别用它了。」
那真正还在维护的是哪个?我搜了一下,作者早就换了个名字重新发布:
code@json2csv/plainjs 版本 7.0.8 最后更新 2026-08-06九天前刚更新过。
AI 没骗你,json2csv 确实能导出 CSV。它只是给了你一个 2023 年的答案。
高级工程师怎么做?
装之前花十秒钟,看一眼这个包的"体检报告":
bash# 把 json2csv 换成你要装的包名就行
npm view json2csv version time.modified跑出来长这样:
codeversion = '6.0.0-alpha.2'
time.modified = '2023-01-21T11:17:01.625Z'一眼就能看出两件事:是不是正式版(版本号里带 alpha / beta 就不是),多久没人管了。
对比一下那个还在维护的:
bashnpm view @json2csv/plainjs version time.modified
codeversion = '7.0.8'
time.modified = '2026-08-06T07:19:15.406Z'一条命令,十秒钟,装之前跑一下。
打个比方
这就像点外卖看到「本店新品·尝鲜装」,你以为是主推爆款,其实是老板三年前上架、后来自己都忘了的试验品。而店里真正天天在做的那道菜,挂在另一个名字底下。
AI 是那个只会念菜单的服务员。 菜单上印着什么它就报什么,它不知道后厨三年没碰过那口锅。
💡 别走极端:不是说带 alpha 就一定不能用,有些成熟项目的内测版比别人的正式版还稳。但你得知道自己装的是什么,而不是装完三个月,在故障复盘会上才知道。
坑二:这事儿浏览器自己就能干,你还在装包
📌 先看个场景
你要在后台页面上显示「3天前」「上周五 18:30」这种时间。
问 AI,AI 说:装 date-fns。
小白通常这么写
bashnpm install date-fns
两行代码,收工。
但问题来了
我按 AI 最常推荐的顺序装了四个包,每装一个记一次账,看看硬盘上多了多少东西:
code装完这个包 项目里一共多了 占了多少地方
------------ -------------- ------------
async-retry 2 个 84K
zod 3 个 6.5M
date-fns 4 个 34M
json2csv 8 个 35M注意第三行——光是 date-fns 一个,就让项目大了 27.5M。
(说清楚:这是我这台机器上的硬盘占用,不是这个库"本身有 27M"。最后打包上线的部分会小很多。)
而我这个场景要干的那点事,浏览器和 Node 自己早就能干了:
jsconst d = new Date('2026-08-15T10:30:00Z');
// 中文长日期
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full', timeStyle: 'short' }).format(d);
// → 2026年8月15日星期六 18:30
// 相对时间
new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' }).format(-3, 'day');
// → 3天前这是我实际跑出来的结果,一个字没改。
零依赖,零体积,不用装任何东西。
高级工程师怎么做?
装包之前先问一句:这事儿浏览器自己能干吗?
code❌ 先装包再说 ✅ 先问一句
───────────────── ─────────────────
需求:显示"3天前" 需求:显示"3天前"
↓ ↓
问 AI → date-fns 浏览器自带的够用吗?
↓ ↓
装 → 项目大了 27.5M 够 → 一个包都不用装
↓ 不够 → 再装,但你知道为什么
三个月后没人敢删打个比方
家里已经有微波炉了,你又买了个加热棒。加热棒没毛病,也确实能热牛奶。
问题是三年后你搬家,收拾厨房的人看着这俩玩意儿,完全不知道当初为什么要买第二个——只好两个都留着。
💡 别走极端:说清楚,浏览器自带的那套只能搞定「显示格式」这一小块。真要算日期加减、处理时区、解析乱七八糟的日期格式,date-fns 依然是更好的选择,我自己项目里也在用。这里反对的不是装包,是没问一句就装包。
坑三:AI 写的重试,一次超时都没设
从这里开始,事情变得有点吓人了。
📌 先看个场景
你要给下单接口加个「失败了自动重试」——网络抖一下,别让用户白点一次。
问 AI,AI 给了你一段特别标准的代码。我一字未改抄下来了:
小白通常这么写
jsasync function fetchWithRetry(url, options = {}, retries = 3) {
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch(url, options);
if (response.ok || response.status < 500) {
return response;
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
if (attempt === retries) {
throw lastError;
}
await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 500));
}
}
throw lastError;
}看不懂细节没关系,你只要知道它想干的事是:请求失败了就再试,最多试三次,每次多等一会儿再试。
说实话这段写得挺好。该考虑的它基本都考虑了。
我起了个本地测试服务,把常见情况全跑了一遍:
code✅ 正常返回 32ms ✅ 前两次失败,第三次成功 1517ms ✅ 页面不存在,不该重试 7ms,直接返回 ❌ 一直失败,重试三次后放弃 3527ms
最后那条虽然是 ❌,但那是说好的——试三次都不行,本来就该放弃。
四条全对。看着挺完美,对吧?
但问题来了
我又加了一条测试:让服务器收到请求,但永远不回话。
这在线上太常见了——服务器没死,只是卡住了。
code服务器收到请求但永不响应:
...已经等了 5 秒,还在等
...已经等了 10 秒,还在等
...已经等了 15 秒,还在等
...已经等了 20 秒,还在等
...已经等了 25 秒,还在等
⏱ 30 秒到,我主动把程序掐了。它不会超时。它压根没有超时这个概念。
这段代码里没有任何一个地方规定了「等多久算等够了」。所以它会一直等下去,等到网络自己放弃——那可能是几分钟,也可能比这个服务的寿命还长。
而它的重试逻辑根本没机会启动,因为第一次请求还没结束。
用户那边看到的是:点了下单,转圈,一直转。
高级工程师怎么做?
浏览器和 Node 都自带了一个「计时闹钟」,一行就能加上:
jsconst response = await fetch('https://api.qianduandaren.com/orders', {
...options,
signal: AbortSignal.timeout(5000), // 5 秒还没回来就掐断
});AbortSignal.timeout(5000) 的意思很直白:给这次请求设个五秒的闹钟,闹钟响了还没结果,就别等了。
加完之后,同一条测试:
code服务器假死 → 3948ms 后放弃,报"超时"不到 4 秒就放弃了,不是 30 秒还在傻等。
打个比方
这就像给客服打电话,你设了「打不通就再打一次」,但没设「响多少声算没人接」。
于是电话接通了、那头没人说话,你就一直举着听筒。
你那个"再打一次"的规矩根本没机会生效——因为第一通电话还没挂。
💡 收口:重试和超时是一对。只写重试不写超时,等于给一件永远不会结束的事,安排了下一次。
坑四:一出事,所有人在同一秒一起重试
📌 先看个场景
促销活动,两百个用户同时点下单。后端刚好在扩容,短暂返回「我忙不过来」。
你的重试代码开始工作了。
但问题来了
上面那段代码,每次失败后等待的时间是固定的:第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒。
固定意味着什么?
这两百个人是同时失败的。那么他们下一次重试的时刻,也会是同一个。
我起了个「一直说我忙」的服务,放两百个客户端同时打(每人最多试三次),然后数一数请求是什么时候到的:
code❌ 原来的写法:等待时间固定
─────────────────────────────
两百个人,一共重试了 401 次
这 401 次,全部砸在两个瞬间里
第 0.5 秒 │███████████████████████ 200 次
第 1.5 秒 │███████████████████████ 201 次后端刚喘上一口气,两百个请求整整齐齐又来了。
再挂一次。
高级工程师怎么做?
让每个人等的时间带一点随机。别整齐划一。
js// 原来:所有人都等 backoff 这么久
// 现在:一半固定,一半随机,每个人落点都不一样
const delay = backoff / 2 + Math.random() * (backoff / 2);同一个实验,同样两百个人:
code✅ 加了随机之后
─────────────────────────────
两百个人,还是重试了 401 次
但摊到了 11 个不同的时间点上
第 0.2 秒 │█ 15 次
第 0.3 秒 │██████ 92 次
第 0.4 秒 │█████ 71 次
第 0.5 秒 │█ 22 次
第 0.8 秒 │█ 18 次
... 一直摊到第 1.4 秒对比一下:
code❌ 不加随机 ✅ 加了随机
───────────────── ─────────────────
重试总数 401 次 重试总数 401 次
挤在 2 个时刻 摊到 11 个时刻
最挤的一刻 201 次 最挤的一刻 92 次总量一次没少,但最挤的那一刻,压力少了一半多。
(随机本来就是随机,这个数字每跑一次都会动。我连跑两轮,一轮 92 一轮 97。你跑出来大概率也是这个量级,但不会跟我一样。)
后端不是被总量压垮的,是被那一瞬间的峰值压垮的。
打个比方
地铁故障,闸机全停了。
恢复的那一刻,如果全站所有人同一秒往前冲,闸机会立刻再挂一次。
加随机干的事,就是让每个人心里默数一个不一样的秒数再走——总人数一个不少,但队伍散开了。
💡 别走极端:内部系统、同时就三五个请求的场景,等待时间固定完全够用,加随机纯属给自己找事。这招是给「很多人会同时失败」的场景准备的。
坑五:重试之前,你得先想想会不会重复下单
这一条我一开始也漏了,是别人审我稿子的时候挑出来的。
📌 先看个场景
回头看坑三,我举的例子是「给下单接口加重试」。
这个例子本身就有问题。
但问题来了
下单是「创建」类操作。请求已经发到服务器了,订单也建好了,只是回话在路上丢了。
这时候你的重试逻辑一看「咦没收到回复」,又发了一次。
用户点了一次,下了两单。
超时和随机等待都救不了这个,反而会放大它——超时设得越短,越容易在服务器已经处理完的时候掐断重发。
高级工程师怎么做?
给每次操作发一张「号码牌」,让服务器认牌不认次数:
js// 一次用户操作,就一个号码牌
const idempotencyKey = crypto.randomUUID();
await fetchWithRetry('https://api.qianduandaren.com/orders', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify(order),
}, { timeout: 5000, retries: 2 });服务器看到同一张号码牌来第二次,就知道「这单我处理过了」,直接把上次的结果返给你,不会再建一单。
这个号码牌必须在重试之前就生成好。 如果每重试一次就换一张新牌,那等于没做。
⚠️ 但这事儿前端一个人做不完:服务器得认这个牌、得会去重、得能把上次的结果找回来。没有后端配合,前端加重试就是在给自己埋雷。
一个更保守的做法:默认只给"查询"类请求开重试,"创建/支付/扣款"类要开,必须显式声明一次,逼自己想一遍。
(这个"认牌不认次数"的设计,专业名词叫幂等。名字挺唬人,意思就是:同一件事做一遍和做十遍,结果一样。)
打个比方
你去银行柜台办转账,填完单子递进去,柜员那边卡住了没反应。
你以为没办成,又填了一张一模一样的递进去。
如果柜员不核对单号,你就转了两次。
而单号这个东西,得是你第一次填的时候就写好的——你不能每递一次就换个新单号,那样柜员永远认不出这是同一笔。
💡 收口:在"创建"类接口上开重试,是把「可能失败」换成了「可能重复」——后者往往更贵。
坑六:参数传脏了,它一次请求都不发
📌 先看个场景
重试次数你打算做成配置项,从后台读。
结果配置没读到,或者运营在后台填了个奇怪的值。
但问题来了
我给上面那段重试代码喂了几种"脏值",结果是这样的:
code配置里填的 实际会发几次请求
------------ ----------------
3 4 次 ✅ 正常
-1 1 次 ✅ 还行
不是数字 0 次 ❌ 一次都不发
配置没读到 0 次 ❌ 一次都不发
填成"无限" 永远发 ❌ 卡死,永不停止后面三行都是问题。
「一次都不发」意味着:用户点了按钮,你的代码看似"失败重试",实际上连服务器都没碰过一下。而报错信息还长得跟真的一样。
「永远发」更吓人,我实测了一下:
code把重试次数配成"无限",服务器一直失败:
⏱ 8 秒到,还在重试 —— 确认是死循环,我主动掐断配置项写成 retries: 配置.最大重试次数,而配置压根没读到,就是这个后果。
高级工程师怎么做?
在用这个数字之前,先确认它真的是个正常数字:
js// 先转成数字,再确认它不是"无限"或者"不是数字",最后才用
const parsed = Number(retries);
const maxAttempts = Number.isFinite(parsed)
? Math.max(0, Math.floor(parsed)) + 1
: 1; // 脏值一律退化成「只发一次」改完再喂一遍那些脏值:
code配置里填的 实际会发几次请求
------------ ----------------
3 4 次 ✅
-1 1 次 ✅
2.7 3 次 ✅
"3"(字符串) 4 次 ✅ 配置读出来是文本也不怕
不是数字 1 次 ✅ 至少发一次
填成"无限" 1 次 ✅ 不会卡死现在不管配置里填了什么妖魔鬼怪,最差也就是「只发一次、不重试」,绝不会一次不发,也绝不会卡死。
打个比方
表格上「年龄」那一栏,有人填了个 -1。
好一点的系统会提示你填错了;
差一点的系统会算出「你还有 -1 年退休」;
最差的系统直接白屏,报错信息还是一句谁也看不懂的英文——你连自己填错了哪一栏都不知道。
💡 收口:AI 特别擅长写"一切正常"的情况。参数填错了会怎样、网断了会怎样,这些是你的活儿。
坑七:「测试都通过了」不等于「测过出事的情况」
前面六个坑,有个共同点
它们全都不影响"一切正常"的情况。
- 内测版的包,正常导出 CSV 完全没问题
- 多装的 27.5M,功能一点不少
- 没超时的重试,服务器正常时跑得飞快
- 没随机的等待,一个人测的时候看不出来
- 重复下单,只在回话丢了的时候发生
- 配置填错,只要没人填错就永远不触发
所以你让 AI「顺便写下测试」,它写出来的测试也会全绿。
因为它测的也是"一切正常"的情况。
我把那段代码的测试结果再贴一次,你再看一眼:
code✅ 正常返回 32ms ✅ 前两次失败第三次成功 1517ms ✅ 页面不存在不该重试 7ms
三条全绿。提交,审核的人看到绿灯,合并。
没有任何一个环节做错了事。
高级工程师怎么做?
在 AI 写完测试之后,自己补三类它基本不会主动写的:
codeAI 默认会测的 你得自己补的
───────────────── ─────────────────
正常输入 → 正常输出 · 服务器卡住不回话
明显的错误 · 参数填成 0 / 负数 / 空 / 乱码
· 中途失败,占着的东西释放了吗打个比方
消防演习只演「大家排好队,从正门有序离开」。
演一百遍都是满分。
真着火那天,正门堵了。
💡 收口:绿灯只证明"你测的那些"通过了,不证明"你测对了地方"。
七个坑背后,其实是同一件事
回头看,咱们前面聊的这七个坑,没有一个是"AI 写错了"。
代码能跑,逻辑正确,测试通过,审核没意见。
它们真正的共同点是:每一个都把账记到了未来。
- 内测版的包,账在它出安全问题那天
- 多的 27.5M,账在打包时间从 4 分钟变 11 分钟那天
- 缺的那个超时,账在服务器卡住那天
- 缺的那个随机,账在大促那天
- 缺的那张号码牌,账在用户投诉扣了两次钱那天
- 缺的那句参数检查,账在运营填错配置那天
而这些"那天",往往都在写代码的人离职之后。
有意思的是,这件事已经有人在做研究了。
卡内基梅隆大学和 BNY Mellon 在今年 2 月发过一篇论文,在公司内部收了 2989 份开发者问卷。里面最反直觉的一组数字是:
86% 的人对 AI 编程工具表示满意。但大约 60% 的人估计,自己每周靠它省下来的时间不到一小时。
满意,但没省下多少时间。这两件事同时成立。
还有一项被引用得更多的:METR 在去年 7 月做过一个实验,16 名资深开发者在自己熟悉的项目里干活,用 AI 的那组 实际慢了 19%——而他们做完之后自我感觉是"快了 20%"。
(这条得加个注:那个实验用的是 2025 年初的工具,METR 自己已经在今年 2 月把这个结果标为"历史数据",说它不一定还能代表现在的模型。所以别拿它当"AI 让人变慢"的结论。)
但它有一层意思不会过期:
你觉得自己快了多少,和实际快了多少,中间可以差出 39 个百分点。
从技术回到人。
我共事过最好的工程师,从来不追求"这段我十分钟就搞定了"。
他们更在意的是——三个月后半夜被叫起来查问题的那个人,能不能在十分钟内看懂这里为什么这么写。
而那个人,常常就是三个月后的自己。
AI 把"写出来"的成本降到了几乎为零。
它没有降低的,是"养着它"的成本。
提交前的 10 秒自检清单
AI 写的代码,合并之前过一遍:
- 它给我装新东西了吗?装了几个?
- 装的是正式版吗?(版本号里有 alpha / beta 吗)
- 这个包最后一次更新是哪年?
- 这事儿原生代码自己能干吗?
- 有网络请求吗?设超时了吗?
- 有重试吗?这个接口重复调用会出事吗?
- 参数填成 0、负数、空、乱码,会怎样?
- 中途报错了,占着的东西(连接、定时器)放开了吗?
- 测试是不是只测了"一切正常"?出事的情况谁测的?
- 这个方案,如果没有 AI,我自己也会这么设计吗?
最值钱的是第 5 条和第 10 条。
第 5 条,因为它的性价比最高:加一行超时是十秒钟的事,不加可能就是一次线上事故。
第 10 条,因为如果答案是"不会",那说明这个决定不是你做的——但半夜起来修它的人是你。
几句实话
写完得说清楚三件事,免得误导人:
关于那 27.5M:那是我这台机器上的硬盘占用,不是这个库"本身有 27M"。打包上线的部分会小很多。
关于浏览器自带的功能:文中提到的 AbortSignal.timeout()、Intl 这些,我是在 Node 22 上实测的。老版本 Node 和老浏览器不一定有,你自己项目的最低版本是多少,得自己确认一遍。
关于第四个坑的数字:随机本来就是随机的,每跑一次都会变。我给的是我这两轮的结果,方向是稳的,数字不必对齐。
我是阿森,专注前端技术干货分享。觉得有用的话,点个「在看」让更多前端同行看到。
也特别建议转发给你们组里那位「反正 AI 写完我看一眼就行」的同事——不用点名,把第 5 条和第 6 条标黄就行。
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

