乐于分享
好东西不私藏

AI 没干掉前端,它干掉的是「打字」

AI 没干掉前端,它干掉的是「打字」

前端让人恐慌,从来不是因为 AI 会写组件,而是因为我们中的很多人,一直把打字当成了本事。

咱们前端最近这半年,谁的信息流里没被「前端已死」洗过一遍?

翻来覆去都是同一个模板:有人录一段二十秒的屏,敲一句 prompt——调个接口,把结果渲染成表格,让用户能筛选能排序,再加个导出按钮。镜头一切,一个能跑的后台管理页。评论区最高赞永远是那四个字。

后台也常有刚工作一两年的同学问我:森哥,我是不是该转后端了?

我没急着回,先把那句 prompt 原样复制,自己跑了一遍。

两分钟,真出来了。数据拉回来了,表头点一下会排序,导出点一下掉了个 CSV。第一屏截图发出去,没人看得出这是机器写的。

说句实话,否认这一点是没有意义的。"AI 写不出能用的前端代码",这条防线在 2024 年就已经守不住了,现在还拿它当论据,只会显得我们不敢面对。

所以我换了个玩法。我没去挑它代码写得好不好看,我只干了一件事——把 mock 数据从 20 行改成了 5 万行。

然后我拿着这个表格,一个一个问它:这里你为什么这么选?

它答不上来。不是答错了,是它压根不知道这里有个「选」字——因为没人告诉过它,这张表将来会长到多大、谁在用、拿这个数去干嘛。

后来我回那位同学:先别聊转不转。你先跟我过一遍下面这 7 个判断。你平时真正会主动过一遍的有几个,你心里就有底了。

判断一:那个 sort,到底该在哪跑

📌 先看个场景。

你在做订单后台。运营要看全量订单,按下单时间排序。测试环境 20 条数据,生产环境 5 万条起,大促能到 30 万。

小白通常这么写(AI 也是一模一样这么写的):

// 按下单时间排序const sorted = rows.sort((a, b) =>newDate(a.createdAt) - newDate(b.createdAt));

刚写完看着一点毛病没有,对吧?语义清楚,一行搞定,code review 都懒得说什么。

但问题来了。 这一行里藏着三个坑,而且一个比一个安静。

坑一:new Date 在比较函数里,每比一次算两个。

我给 Date 加了个计数器,实测这行代码在 5 万行上跑了多少次:

比较次数:       149,510Date 构造次数:  299,020      ← 每次比较构造两个,a 一个 b 一个

这里有个反直觉的地方:教科书上的 n·log₂n 在 5 万行是 78 万次,实测只有 15 万。因为 V8 用的是 TimSort,它会先找出数据里已有的顺序段——同样 5 万行,比较次数取决于数据本身有多乱

已经有序 / 完全逆序:   49,999 次真随机:               714,562 次     ← 差了 14 倍

公式告诉你增长趋势,实测才能告诉你真实的常数和数据分布,两者替代不了对方。下面是梯度扫描,同一份数据只换比较函数:

行数        数字直接比      比较函数里 new Date   5000       0.9ms            1.8ms  20000       2.4ms           12.0ms  50000       6.3ms           45.7ms   ← 已经逼近 50ms 这个参考值 300000      61.9ms          511.4ms1000000     515.4ms         2301.5ms

环境:Node.js v22.23.2 / Linux 沙箱,单次运行未取多轮中位数。这是 V8 里的纯计算耗时,不含浏览器渲染。 我换个脚本重跑同一档 5 万行,拿到的是 51.3ms 而不是 45.7ms——单次基准的抖动就有这么大,别信任何单点数字,包括我的。该看的是量级和曲线形状。

最要命的不是最后一行的 2.3 秒,是前两行。5000 行的时候慢的那个才 1.8ms,你在开发环境基本测不出来。等你发现它,是运营在群里说「点一下要转半天」的时候。

坑二:rows.sort() 是原地改的。

rows.sort():  原数组也被改了吗 -> true,原数组现在是 [1,2,3]toSorted():   原数组保持 [3,1,2],新数组 [1,2,3],同一个引用吗 -> false

在 React 里这是个隐雷:你以为只是算了个派生值,实际上把 state 里那个数组本身翻了个个儿。有时候界面不更新,有时候更新了但对不上,取决于你在哪一步比较了引用。

坑三(这个最毒):脏数据来了,排序会安静地排错。

真实订单里总有几条 createdAt 是 null 或者格式不对。这里有两种翻车方式,而且它们长得不一样:

new Date(null).getTime()          = 0     -> 1970-01-01  ← 不报错,被当成 1970 年Date.parse(null)                  = NaNnew Date("不是个日期")             = Invalid Datenew Date("不是个日期").getTime()   = NaN

第一种:null 被 new Date 悄悄解释成了 1970 年。 上面那句小白写法用的正是 new Date,所以这条数据不会报错、不是 NaN,它就是安安静静地排到了列表最前面,顶着一个错误的年份。

第二种:拿到 NaN 比较器返回 NaN 时,规范要求把它当作 0 处理——也就是"这两个相等"。结果不是"整个数组不动",而是排序关系整体失去可靠性。我拿只有一两个坏值的数据实跑:

坏值在中间  排序前: 05-01  03-01  null  01-01  09-01  07-01            排序后: 01-01  03-01  05-01  null  07-01  09-01两个坏值    排序后: ... 01-01  02-01  03-01  null  01-01  04-01 ...                                            ↑ 同一个日期被劈成了两段

元素确实动了,看着也挺像排好了,但它没排对。

而且它的表现还不稳定:坏值多的时候可能整个数组纹丝不动,坏值少的时候就是上面这样——排了,但排错这比彻底不干活难查得多,因为你没法用"它没反应"来定位它。

高级工程师怎么做? 三个坑一起堵上:

// TypeScriptinterface Order {  readonly id: number;  readonly createdAt: string | null;}functionsortByCreatedAt(rows: readonly Order[]): Order[] {// 时间戳只解析一次const decorated = rows.map((row) => ({ row, ts: Date.parse(row.createdAt ?? '') }));// decorated 是 map 刚建出来的新数组,在它身上 sort 动不到 rows  decorated.sort((a, b) => {const aBad = Number.isNaN(a.ts);const bBad = Number.isNaN(b.ts);if (aBad && bBad) return0;if (aBad) return1;   // 无效日期一律沉底,而不是随机穿插if (bBad) return-1;return a.ts - b.ts;  });return decorated.map((d) => d.row);}

(JS 版把类型标注去掉就是,逻辑一个字不改。)

注意这里我用的是 sort 不是 toSorted坑二讲的是"别在别人的数组上原地排",而 decorated 是这个函数自己刚 map 出来的,在它身上原地排天经地义,没必要再复制一份。(我量过,这个量级下两者耗时几乎没差别;但 toSorted 确实会多分配一个等长数组,数据再大就不一定可以忽略了。)

所以判断点不是"哪个 API 更新更好",是"手上这个数组是不是你自己的"。

打个比方。

第一种写法就像在厨房切土豆丝:切一刀,翻开菜谱确认一下"要切多细",再切一刀,再翻一次。刀工没问题,土豆也没问题,问题是你翻了近三十万次菜谱。而且你还是在人家原来那筐土豆上直接切的,切完那筐就回不去了。

💡 别走极端:这不是让你给每个 sort 都套一层 map。几百行数据怎么写都行,多包一层反而多两次遍历。真正的判断点是「这份数据未来会长到多大」和「这批数据干不干净」,而不是它今天多大。

判断二:卡住的到底是排序,还是别的?

「数据量大就丢给 Worker」——这话你我都说过,但多大算大?

我写了个心跳监视器来量:每 16ms 跑一次定时器,看排序期间最长的一次心跳间隔有多大。

规模        排序本身    主线程最长心跳间隔    丢给 Worker 后 50000       12ms            43ms                21ms200000       44ms           100ms                21ms500000      124ms           223ms                20ms

5 万行那档,排序本身才 12ms。 这跟很多人(包括我)想象中的"大数据量必卡"差得挺远——真正拉开差距的是 20 万行往上。

(21ms 是这个沙箱定时器的基线抖动,不是卡顿。两个最大值可能来自不同的调度抖动,所以别拿 43 减 21 去推"排序额外占了多少"——它只说明这一档还没拉开明显差距。)

⚠️ 必须说清楚:这是 Node 的 worker_threads 实验,只能当调度代理看,不能换算成浏览器掉几帧。 浏览器主线程上还压着样式计算、布局、绘制和别的脚本,预算比我这个空跑的沙箱紧得多。

那什么算"明显"?业界有个现成的刻度:浏览器把主线程连续占用 50ms 以上的任务称为长任务(Long Task),因为 RAIL 模型要求对用户输入的响应控制在 100ms 以内。(web.dev · Optimize long tasks)

拿这把尺子回头看判断一:5 万行的 new Date 写法是 45.7ms,修好之后 6.3ms,两种写法差了一个量级。慢的那个在我的 Node 实验里逼近 50ms;至于它在你的页面里构不构成长任务,得放进真实页面的 Performance 面板看,我这儿给不了答案。

打个比方。

Worker 就是隔壁屋请了个帮厨。你要切一整筐土豆,喊他过来划算;你就切两根葱,喊人、交代、等他端回来,还不如自己顺手切了——我量过,起一个 Worker 加一次通信往返要 13ms,比 5000 行排序本身还贵。

💡 别走极端:别记"多少行以上就上 Worker",也别记 100ms 这种拍脑袋的阈值。该问的是「这段计算落在哪条路径上」——同样 80ms,放在一个可以延后的非关键任务里,和放在每次键盘输入的同步路径上,影响完全不同。(首屏也别想当然,那 80ms 一样会挤占加载和可交互时间,只是要单独评估。)

而且,真正让浏览器卡住的往往根本不是排序,是下一节这件事。

判断三:5 万行要不要真的塞进 DOM

📌 先看个场景。

排序修好了,8ms 出结果。运营点了一下表头,页面还是白屏两秒。

为什么? 因为那 8ms 只是把数组排好。接下来这 5 万行要变成 5 万个 <tr>,每个 <tr> 里还有八个 <td>——至少 45 万个元素节点,还没算每个单元格里的文本节点,而每一个都要创建、算样式、参与布局。

高级工程师怎么做? 只渲染屏幕上那十几行,其余的用一个撑高度的占位元素顶着。这就是虚拟滚动。

打个比方。

你去图书馆查资料,要用到一整排书架的书。虚拟滚动是:只把手边正在翻的那三本抱到桌上,看完放回去再拿下一本。全量渲染是:把整排书架搬到桌上,然后开始找。桌子塌了。

💡 别走极端:虚拟滚动不是白拿的。页内 Ctrl+F 可能搜不到没渲染的行、打印可能只印出可见部分、读屏软件如果没补 aria-rowcount 和 aria-rowindex,感知到的行数就只有留在 DOM 里的那些。这些都是可以单独适配的,不是必然读错——但你得知道要适配,需求文档上不会写。 判断点是:这张表的典型用法是「扫一眼前二十行」,还是「Ctrl+F 找那一行」。

判断四:分页排序,排的可能是个假的

📌 先看个场景。

接口是分页的,一页 20 条。用户点了「金额」表头,前端把当前这 20 条排了一下,最高的顶上去。

看着完全没问题,对吧? 表格确实排序了,动画也顺滑。

但问题来了。 我拿 100 条数据实跑了一遍:

第 1 页排序后,前端显示的「金额最高」: { id: 19, amount: 703 }全量数据里真正的「金额最高」        : { id: 27, amount: 999 }❌ 差了 296,而且用户翻到第几页,这个「最高」就跟着变第 3 页点同一个表头,「金额最高」变成了: { id: 54, amount: 998 }

这不是慢,这是错。 用户看到一个自称"按金额从高到低"的表格,第一行写着 703,而系统里真实的最高值是 999。运营拿这个数去开会了。

这类 bug 特别毒——它不报错、不白屏、不进 Sentry,界面上一切正常。它只是安静地给出错误答案。(跟判断一那个 NaN 是一路货色。)

高级工程师怎么做? 在"接口只给当前页"这个前提下,产品语义上基本就两种选择:要么把排序参数交给服务端,让它在全量上排完再切页;要么大方承认这就是"当前页排序",在表头上写清楚。最糟的是现在这样:长得像全量排序,实际是当页排序。

❌ 前端排当前页                    ✅ 排序参数交给服务端──────────────────────            ──────────────────────GET /orders?page=1                GET /orders?page=1   ↓ 拿到 20 条                       &sort=amount&order=descpage.sort(...)                       ↓   ↓                              服务端在全量上排完再切页显示「最高 703」                    显示「最高 999」翻页后「最高」还会变                 翻页后仍然是 999

打个比方。

班主任要排全年级成绩,但他手上只有三班的名单。他把三班排得明明白白,然后在黑板上写「年级第一:李某某」。李某某在三班确实是第一。

💡 别走极端:如果这张表总共就几百条、接口一次能全给,前端排完全合理,别为了"规范"硬加一套服务端排序参数。判断点是「一页之外还有没有数据」,不是"排序该在前端还是后端"。

判断五:一个红条,代表了四件完全不同的事

📌 先看个场景。 AI 生成的组件长这样:

try {const res = await fetch('https://api.qianduandaren.com/orders');  setRows(await res.json());catch (e) {  setError('加载失败,请重试');}

看着挺完整的:请求包了 try/catch,失败也给了提示。但你把它接到一个真会返回 500 的服务上试试:

❌ 没进 catch。res.ok=false, status=500, 拿到的 data = { error: 'boom' }   -> setRows(data) 会把这个错误对象当成表格数据塞进去❌ 200 但返回的不是数组,也没进 catch。Array.isArray = false✅ 只有连不上才会 reject: TypeError

fetch 只在网络层出问题时才 reject。 HTTP 500、404 在它眼里都是"请求成功送达了",res.ok 是 false,但 catch 一动不动。所以上面这段代码遇到 500,会把 { error: 'boom' } 当成表格数据 setRows 进去。

这是 MDN 上白纸黑字写着的行为,但它太反直觉了——光读代码是读不出来的,这段 try/catch 长得实在太像"已经处理过了"。 得先补上 res.ok 和数据结构的判断,才轮到真正的问题。

而真正的问题是:那一句「加载失败,请重试」,压着四件完全不同的事。

  • 后端服务挂了 → 用户该等等再刷新
  • 通了但返回的字段名变了 → 刷新一万遍也没用,该报给你
  • 筛选条件组合出来本来就是零结果 → 这压根不是错误,用户该改条件
  • 表格好好的,只是导出那一下失败了 → 他不该看到整页红,只需要重点一次导出

四种情况,一句「加载失败,请重试」。其中两种,用户按你说的重试,重试一辈子也不会好。

高级工程师怎么做? 让状态本身把这几种分开,编译器帮你盯着别漏:

exporttype GridState =  | { kind: 'ok'; rows: readonly Row[] }  | { kind: 'empty-no-data' }                          // 后台本来就没数据  | { kind: 'empty-filtered'; hint: string }           // 筛选把数据筛没了  | { kind: 'network-down' }                           // 请求压根没送出去  | { kind: 'http-error'; status: number }             // 送到了,服务端说不行  | { kind: 'bad-payload'; detail: string }            // 服务端说行,但给的形状不对  | { kind: 'partial'; rows: readonly Row[]; failed: string }; // 表格好着,导出挂了exportfunctionmessageOf(state: GridState): string{switch (state.kind) {case'ok':             return`共 ${state.rows.length} 条`;case'empty-no-data':  return'还没有数据,去创建第一条吧';case'empty-filtered'return`没有符合条件的记录,${state.hint}`;case'network-down':   return'连不上服务器,检查下网络再重试';case'http-error':     return httpMessage(state.status);case'bad-payload':    return`数据格式不对(${state.detail}),已上报`;case'partial':        return`表格是好的,但${state.failed}失败了,可以单独重试`;  }}

注意 http-error 和 bad-payload 是分开的两种:**一个是"服务端说不行",一个是"服务端说行、但给的东西不对"**——后者是契约变了,重试一万遍也一样,该报警的部门都不同。

而拆到这儿还不够。HTTP 错误内部还得再分一层,因为 401、403、404 跟 500 完全不是一回事:

// 值得等一会儿再试的const RETRYABLE_STATUS = new Set([408425429500502503504]);functionhttpMessage(status: number): string{if (status === 401return'登录状态过期了,重新登录一下';if (status === 403return'你的账号没有查看这批数据的权限';if (status === 404return'这个列表不存在,检查下链接对不对';if (status === 429return'操作太频繁了,稍等一下再试';// 兜底也必须跟重试策略对齐,否则 422、409 又会掉回「让你重试」if (RETRYABLE_STATUS.has(status)) return`请求暂时失败(${status}),稍后重试`;if (status >= 400 && status < 500return`请求没法完成(${status}),检查下筛选条件或者找管理员`;return`请求失败(${status})`;}exportfunctionisRetryable(state: GridState): boolean{switch (state.kind) {case'network-down'returntrue;case'http-error':   return RETRYABLE_STATUS.has(state.status);case'bad-payload':  returnfalse;default:             returnfalse;  }}

401 得把人送去登录页,403 得告诉他找谁开权限,404 得让他别刷了。如果这些都返回"可重试",你只是把"一个红条走天下"换成了"一个重试按钮走天下"。

⚠️ 兜底分支最容易漏。 你把 401/403/404 一个个列出来了,感觉挺周全,然后 422、409 走到最后那行 return,又变回了"稍后重试"——而按钮是灰的。提示和行为对不上,比两个都错还让人困惑。

所以这两个函数得共用同一个 RETRYABLE_STATUS,而且值得写个测试把 400 到 599 全扫一遍,确认"文案说重试"和"真的能重试"永远同进同退:

一致性总扫:文案说重试的,isRetryable 必须也说 true✅ 400-599 共 200 个状态码,文案与重试策略全部一致

💡 另外说明一下:上面这套是订单查询这类 GET 请求的策略。非幂等请求(下单、支付、提交表单)能不能自动重试是另一个问题,重复提交的代价可能比失败还高;429 和 503 还该优先读响应头里的 Retry-After,而不是自己拍一个等待时间。

这一节的意思就在这儿:拆状态不是为了多写几句提示语,是为了后面每一步处理都能分开。拆完之后所有分支干的事还一样,那确实白拆了。

这套状态机我跑了 27 个用例,全过。而且用了可辨识联合之后还有个好处:以后再加一种失败态,哪个分支忘了处理,tsc 会直接把你拦在编译期。

打个比方。

一个红条走天下,就像小区大门口贴张纸写「有问题」。停水是它、电梯检修是它、门禁坏了也是它。业主看完这张纸,掌握的信息量跟没看一样,只能挨个打电话问。

💡 别走极端:不是每个组件都要五种状态。内部用的调试面板,一句「挂了」足够。判断点是「用户看完这句话,知不知道下一步该干嘛」——如果他只能干瞪眼或者盲目重试,那这句话就没写对。

判断六:筛选完少了两千行,看不见屏幕的人怎么知道

📌 先看个场景。

用户点了「只看待处理」,表格从 5000 行变成 47 行。屏幕上一切都很清楚。

但对一个用读屏软件的人来说,刚才什么都没发生。 他按了一下按钮,然后一片安静。不知道筛选生效了没有,也不知道现在剩多少条。

这类问题的特点是:它很容易漏出常规验收清单,因为它在屏幕上不可见。 键盘 Tab 顺序、表头能不能回车触发排序,这些至少还能测出来;"筛选后要不要播报行数"这种,如果没人明确提出来,产品、测试和 AI 都可能漏掉。

高级工程师怎么做? 挂一个活区域,让变化被念出来:

<div aria-live="polite" className="sr-only">  {`已筛选,当前 ${visibleRows.length} 条记录`}</div>

⚠️ 这一段我没法自测。 沙箱里没有浏览器,更没有读屏软件。aria-live 的实际播报行为、各家读屏软件的差异,我只能按 WAI-ARIA 规范写,**不能说"我测过"**。你要验证的话,打开系统自带的读屏软件听一遍(macOS 上是 VoiceOver,Windows 上可以用 NVDA),这是我给不了你的那部分。

说白了,只改屏幕不广播,等于默认了所有人都在盯着那块屏。

💡 别走极端:别把整个表格塞进 aria-live,那会变成每次变动都念一长串,比不念更烦人。只播报变化的结论(还剩多少条),不播报内容本身。

判断七:两个组件同时要同一份数据

📌 先看个场景。

页面顶部一个「待处理 12 单」的角标,下面是订单表格。两个组件各自在 useEffect 里拉了一次 /orders。一个接口,两条一模一样的请求。

高级工程师怎么做? 正在飞的请求存起来,后来的人直接搭车:

functioncreateFetcher(realFetch{const inFlight = newMap(); // key -> Promise,只存「正在飞的」return(key) => {const hit = inFlight.get(key);if (hit) return hit;const p = realFetch(key).finally(() => {// 关键:不管成功失败,飞完了就从表里摘掉      inFlight.delete(key);    });    inFlight.set(key, p);return p;  };}

说个实话,这段我第一版是没有那个 .finally 的。 看着挺干净:有就复用,没有就发。然后我跑了一遍失败路径,当场现原形:

第一次失败:第一次 500点重试:还是 第一次 500 —— 真实请求发了 1 次,按多少下都一样

那个 rejected 的 Promise 永久留在了表里。用户点重试,拿到的还是同一个已经失败的 Promise,页面上什么都没变,因为根本没有新请求发出去。 他会以为是网络问题,一直点,一直失败。

打个比方。

宿舍点外卖。三个人同时想吃同一家,合成一单省配送费——对的。但第一单送到楼下被撤了,你还攥着那张废单据不肯扔,后面谁再说要吃,你都把它递过去说"点过了"。

💡 别走极端:这十行是给你看清"里面在处理什么"的,真上生产别自己造——SWR、TanStack Query 早把这些边界趟过了。另外两点:key 不能只是接口名,查询参数、筛选条件、当前用户、语言,凡是会改变响应内容的维度都得进去;还有,只合并并发的请求,用户手动点刷新那次不能合,他要的就是最新的。

所以,该转后端吗

上面 7 个判断,有个共同点:没有一个是"代码写得不够好"。

每一个都是"这儿有个岔路口,往左往右取决于代码里根本看不到的东西"——数据会长到多大、这批数据干不干净、用户是扫一眼还是精确查找、他看不看得见屏幕、这个功能明天会不会被复制到另外五个页面。

而这也顺带回答了那个问题。

转后端为什么不解决问题?因为 CRUD 就是 CRUD。一个 controller 和一个 component,在 prompt 眼里没有区别,它写 controller 跟写 grid 一样快。你从这个房间搬到那个房间,会发现同一件事已经在那边发生了。

那往哪走?

❌ 换个房间                      ✅ 往下扎,或者往上抬─────────────────                ─────────────────────前端 CRUD → 后端 CRUD             基建:你的代码怎么上线的                                 设计:这个界面凭什么长这样同样能被 prompt 写出来             产品:这个需求为什么被提出来从零学一套新东西                   都建立在你已有的东西上

基建是往下扎。构建为什么慢、Docker 那一层写坏了会让部署多花几分钟、CI 该在哪一步把不合格的代码拦下来——这不是新语言,是你自己写的代码通往生产环境的那条路。顺带说个数据:**按 GitHub 2025 年 8 月的月度贡献者口径,TypeScript 首次登上第一,约 264 万名贡献者,同比增长 66.6%**(Octoverse 2025;这是 GitHub 自己的口径,报告里注明了其他榜单方法论不同、排名可能不一样,数据是引用的不是我测的)。工具链这块地,你不是外人。

设计是往上抬。这个功能该一开始就全展开还是折起来、空状态该解释自己还是引导下一步、这个等待该用骨架屏还是转圈——AI 能给你生成一堆方案,但它替不了团队去定义:这款产品,什么才算好。

产品是让前面那些取舍从"说得通"变成"选对了"。主线程还是 Worker、分页还是无限滚动、要不要虚拟滚动,这些问题都没有标准答案,只有"对这批用户、这个数据量、这个业务场景"的答案。知道客户当初为什么提这个需求的人,能选对;不知道的,只能猜——跟 prompt 猜得一样。

三个方向,都不用你从零学。它们就长在你现在站的这块地上。

根子上是同一件事

这里得说句公道话:AI 并不是"只看得见一个屏幕"。

现在的 coding agent 能读整个仓库、能跑测试、能翻日志,也确实会主动提醒你一些边界情况。上面那些梯度数据、NaN 的排序表现、.finally 漏掉之后的失败路径,我全是让机器跑出来的,一行断点都没打。

所以真正的稀缺,从来不在"亲手把代码敲出来"。

在于把约束找出来,并且为选择负责。 这份数据一年后会长到多大、这批数据脏不脏、用户拿这个数字去做什么决定、错一次的后果谁来担——这些没被写进上下文的东西,AI 无法稳定替你补齐,更无法替你承担业务后果。它给不出承诺,只有你能。

过去十年,这个能力一直被打字掩盖着。我们每天在敲,敲得很忙,忙到看起来很像在思考。屏幕点亮了,功能上线了,谁也没问过那句"为什么这么选"。

现在打字免费了,遮羞布也就没了。

我共事过最好的那几个前端,代码不见得写得多漂亮。他们的共同点是:接到需求,第一反应不是"怎么实现",而是"这玩意儿最多会有多少条数据""这页谁在用""出错的时候用户下一步能干嘛"。

他们问的这些,需求文档上一句都没有。

AI 没干掉前端。它只是让写代码越来越便宜,让判断和责任越来越贵。

📋 10 秒自检清单

下次接到一个"列表 + 筛选 + 排序"的需求,交出去之前扫一遍:

  1. 这份数据一年后大概多少条?我按那个量级测过吗?
  2. 比较函数里有没有 new DatelocaleCompare 这种每次都重算的东西?
  3. 排序用的是 sort 还是 toSorted?我有没有把 state 里的数组原地改了?
  4. 脏数据(null、格式不对)进来会怎样?null 会不会被当成 1970 年排到最前面?
  5. 接口是分页的吗?如果是,我这个排序排的是全量还是当前页?
  6. 这段计算,是不是正好发生在用户点了一下、正等着反应的时刻?
  7. 失败态有几种?fetch 的 res.ok 我判断了吗?
  8. 筛选完行数变了,读屏软件能知道吗?
  9. 这个请求有没有别的组件也在发?失败之后重试能好吗?key 够不够全?
  10. 上面这些,哪几条是我自己决定的,哪几条是照着模板抄的?

最值钱的是第 4、5、10 条。

第 4 和第 5 条,是这份清单里「不报错但会给出错误答案」最典型的两个——这一类坑其实不止它们俩(fetch 那个 500、原地改数组,都可能安静地产出错误结果),但这两个最容易被写进周报、进决策。

第 10 条,因为它才是这篇文章真正想说的:代码谁写的不重要,判断是谁做的、责任是谁担的,才重要。

💬 聊聊

我以前也是那种"接到需求先开写"的人。看到需求文档写"支持排序",第一反应是查 antd Table 的 sorter 怎么配,而不是问一句"这表最多多少行"。这毛病改了挺久。

所以想问问大家:

  1. 上面 7 个判断,你日常真正会主动过一遍的有几个?(诚实点,我自己也就 5 个)
  2. 你踩过最贵的那个"看着没问题但其实是错的"坑是哪个?是不是也是那种不报错的?
  3. 如果明天开始你写的所有组件都由 AI 生成,你觉得你还剩下什么?

我是阿森,专注前端技术干货分享。觉得有用的话,点个「在看」让更多前端同行看到。

另外特别建议转发给你团队里那个正在纠结要不要转后端的同学——尤其是刚工作两三年、手里活儿全是 CRUD 的那个。 他现在最需要的不是一门新语言,是有人告诉他:他每天忽略掉的那些岔路口,才是他真正的资产。

参考资料

  • GitHub Octoverse 2025:https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/
  • web.dev · Optimize long tasks(50ms 长任务定义):https://web.dev/articles/optimize-long-tasks
  • MDN · fetch()(为什么 404/500 不会 reject):https://developer.mozilla.org/zh-CN/docs/Web/API/Window/fetch