复盘这个项目里最费时间的几个 bug,有个共同点:没有一个是因为不懂某个 API。
全都是因为相信了一件不该相信的东西——错误信息、日志、Console、自己写的探测手段、自己对控制流的记忆。
下面八条,每条配一个真实案例。
一、错误信息会撒谎,而且这是最贵的一种 bug
有个函数有四个 return null 分支:功能未启用、没抓到数据、模型调用失败、JSON 解析失败。而调用方把所有的 null 一律报成同一句话:
「没抓到这个会话的对话内容」
于是用户的日志里出现了这样自相矛盾的两行:
[agent] 抓到对话 34 条(源=会话日志接口)[agent] 结果: { ok: false, error: '没抓到会话 … 的对话' }真实死因是模型调用超时——手动跑单次就要 16.5 秒,而超时设的是 30 秒,批量连打被限流后必然触顶。
但被那句假错误信息带着,我去验了:取数接口(46 条 ✅)、id 快照(正常 ✅)、解析函数(35 条 ✅)、跨域(同源 ✅)、登录态(带全 ✅)、后台兜底(完好 ✅)。
数据层从头到尾都是好的,方向全错,整整四轮。
两条规则:
① 错误信息必须对应真实分支。 宁可多写几个分支,也别用一句话兜住所有失败——它会把后来者(包括三个月后的你)钉在错误的排查方向上好几轮。
② 日志里出现「A 成功」紧跟着「A 失败」这种自相矛盾时,先怀疑错误信息本身,别怀疑 A。
修法很朴素:把失败原因记进一个模块级变量,由调用方如实透出。
二、日志也是代码写的,代码有 bug 时日志同样会撒谎
另一轮排查里,日志中有一句:
textarea 实读: ""我围着这句话查了三轮:谁把它清空了?什么时候清的?是不是有个轮询在覆盖?
那句日志本身是错的。 它用的读取函数里有个正则,会把 <br /> 一起剥成空字符串,于是把一个有 85 个字的 textarea 读成了空。
内容从来没丢过,是读法把它读没了。
用户两次跟我说「明明填写成功了」「我觉得是校验的时候出的问题」——两次都是对的,而我两次被自己写的日志说服了。
现场实测优先于日志推断。 打开 DevTools,手动跑一遍,看真实的值。
三、日志「缺了一段」,比日志报错更有信息量
内容脚本配了 all_frames: true,一个页面跑五份。
某个批量任务报失败,我去看日志,发现缺了整整一段——「切换会话」「表单就绪」「拿到锁」全都没有,直接跳到最后报错。
我的第一反应是「数据层出问题了」,于是手动验证:同样的参数调一次取数接口,ok: true,数据完整;直接打后端,几十条都在。接口、参数、解析、登录态,全对。
绕了很久才反应过来:那段日志不是「没打」,是打在另一个 frame 里了。失败的那个实例是另一个 frame 里的副本,它持有的 DOM 引用早就失效了。
排查多实例问题,先看日志「缺了什么」,而不是盯着报错本身。 日志缺一整段 = 那是另一个实例在跑。
四、改判据没用的时候,先看控制流
这个形状我栽了两次,一模一样。
有一条很长的瀑布式函数,中间散着好几个硬 return。往里面加「兜底逻辑」时,只改判据是无效的——因为那段代码的上游有 return 会先跳出去,根本轮不到你改的那行。
- 第一次:兜底段写在一个
if (!x) return的后面,于是「x 为空」这个最该由它兜底的场景,反而永远到不了它。 - 第二次:把写入的门槛放宽了,但那个判据在「全都查不到 → 提示 + return」的下游。用户重测仍然失败。
难受的地方在于:失败表现是「某个场景就是不生效」,这和网络问题、时序问题长得一模一样,光读代码很难一眼看出来。
最快的定位手法不是读代码:
打开调试日志,跑一次真实操作,看日志在哪一条之后断掉。断点就是
return的位置。
第二次就是靠这个一眼锁定的——「查询耗时 412ms → 无结果」之后,再没有任何后续日志。
还有一个很好用的辅助:找对照组。另一条路径用 finally 保证「这一步一定会执行」,主链路没有这层结构保证。两条路对同一个输入结果不同时,差异往往在控制流,而不在判据。
顺带一提:这个函数当时有 859 行。连修四轮,每一轮都因为「函数太长」误判过执行路径。后来做了一次纯结构拆分——零行为差异,只是把解析阶段提出来——之后就没再误判过。
五、Console 会骗你:你可能根本不在那个执行上下文里
浏览器扩展的内容脚本跑在隔离世界,而 DevTools 的 Console 默认只显示主世界。
这会制造两个方向相反的误判:
- 功能明明生效了,Console 里一条日志都没有 → 你以为代码没生效;
- 在 Console 里验证成功的想法,写进代码就失败 → 因为 Console 在主世界,那里能读到页面 JS 对象。
我们实测过:在主世界打两个 marker,把一次扩展调用夹在中间,两个 marker 之间空无一物。第一反应是「改动没生效」,绕了两轮。(细节在内容脚本的三个世界。)
由此引出一条写给别人看的规矩:
给别人写验证步骤时,别写「看日志里有没有 X」。 对方换个执行上下文就什么都看不到,然后会非常合理地推断成「功能没生效」。
要写就写「跑这条命令,看返回里有没有 X」——日志是过程的旁证,产物才是结果本身。
六、未经验证的探测手段 = 坏仪器
这一条最贵,因为它让你带着错误的读数排查很多轮,而且完全没有自我怀疑的契机。
有一轮排查,我连用了三件坏仪器:
| 仪器 | 为什么它是坏的 |
|---|---|
单文件跑 tsc --noEmit <file> | 脱离了项目配置 → 没有路径别名、没有 lib,报出来的错全是假的 |
| 在错误的执行上下文里读扩展存储 | 那里根本取不到,而我写的 ?? {} 直接 resolve 了一个空对象——看起来像「读到了空」,而不是「读不到」 |
| 派发合成事件来测键盘处理 | 连我自己挂的探针都没收到——却据此推断了两轮 |
第二条尤其阴险:一个本该报错的路径,被我自己写的默认值兜成了「一个看起来正常的空结果」。
真正定位到问题的,是一个只记录、不判断的探针:在每个 frame 上挂捕获阶段的监听,只记「事件落在哪个 frame / isTrusted / defaultPrevented / target 是谁」,然后请用户按真实的键。
合成事件永远做不到这件事——isTrusted 就是假的。
用一件仪器下结论之前,先证明它在已知正确的情况下能给出正确读数。
探针要只记录、不判断。加了判断的探针,会把你的假设一起编码进读数里。
七、「手测能成功,实际跑失败」本身就是一条线索
有个功能报「打不上标签」。我诊断成「写进去了但界面没渲染」,还据此加了一层回读校验。
证据看起来很像:调用返回 ok: true, added: ['…'],而字段是空的。
但那是我在顶层帧里手动测出来的。 真跑一遍看日志才发现:标签 id 压根没查到,根本没走到写值那一步。真因是另一件事——某个 iframe 里的相对路径被 <base> 改写到了别的域,请求被 CORS 拒了。
「手测能成功 ↔ 实际跑失败」,这个差异本身就是信号——它往往意味着两者的执行环境不同(不同的 frame、不同的世界、不同的时序)。
以及:接口「返回成功但结果不对」时,先确认它到底跑没跑到那一步,再去推断下游。
(那层回读校验我留下了。它是个好东西,只是治的是另一个病。)
八、去读最终产物,别沿着链路往回推
有个 bug:数据提交正常,但关页后再打开草稿,其中一段没了。
前几轮全在查「谁把它盖掉了」——是不是某个轮询?是不是重新挂载时被覆盖?是不是有残留实例在抢写?
后来换了个做法:直接去读宿主存草稿的那个地方(一个 localStorage 键,值是个 JSON)。
读出来一看,丢失的那几条,草稿里对应字段本来就是空的。
→ 不是「被盖掉了」,是关页那一刻就没存进去。方向从第一轮起就是错的。
沿着链路往回推,你验证的是「我以为的链路」。去读最终产物,你验证的是事实。
收尾:复现不可跳过
有一轮我做了四次修改,事后复盘,其中三次是在修用户根本没遇到的问题。
共同毛病是同一个:没复现就改。
复现 → 定位 → 最小修复 → 验证四步里最容易被跳过的是第一步,因为它最”不产出”——你花半小时构造一个复现场景,代码一行没动。但跳过它的代价是:你后面所有的定位和修复,都建立在一个你没亲眼见过的现象上。
回到开头那句话:这些 bug 没有一个是因为不懂某个 API。
它们的共同结构是——我手上有一条信息,它看起来是事实,但它其实是某段代码对事实的转述。错误信息是转述,日志是转述,探测脚本的输出是转述,我对这个函数控制流的记忆也是转述。
排查的技术含量,很大程度上就在于分清哪些是事实、哪些是转述,以及在什么时候停下来去核对一次原件。