写完《计数器停在 0》那篇,我停下来把最近三篇捋了一遍。
《我给自己写的工具,连着骗了我七次》讲的是我给自己的脚本造了一个「已验证」标记。《三套 API 都告诉我成功了》讲的是从操作系统一路追到浏览器内部,才找到一个没发出去的字符。《计数器停在 0》讲的是 DOM 读回逐字全对,而框架压根不知情。
三篇完全不同的故障,三条完全不同的排查路径。
我重新读了一遍,发现它们在说同一件事,只是没有人给它起个名字。
这篇我把它整理成清单。资源型,你可以直接抄走用。
先说结论
所有「返回成功但实际没做」的故障,根因都是同一个:你在自己这一端验收,而真相在下一端。
调用返回了成功,这个返回值是你这一端生成的。它描述的是「我做了我要做的动作」,不是「目标变成了我想要的样子」。
这两句话在绝大多数情况下重合,重合到你会觉得它们是同一句话。
所以你顺手用「我调用了吗」当验收判据,然后一直没事。
直到有一天它不重合。
四种形状
从外到内,一条因果链。上一层不成立,下一层的验证全是空的。
形状一:没到
表现:调用返回成功,目标那边一次都没发生。
这是最干净的一种,因为它至少还诚实地报错了 ------ 不,它连这个都不报。它返回的就是成功。
《三套 API》那篇追的就是这个。Input.insertText 返回正常,Chrome 里也确实有事件发出,页面上就是没有那个字。我从操作系统一路量到浏览器进程内部,才在最后一层发现那个字符压根没进编辑器的输入通道。
探针:在终点挂计数器,不在起点问返回值。
dart
// 错:问起点
const ok = await sendEvent();
assert(ok === true); // 这里的 true 只说明我调用了
// 对:在终点数落地次数
let landed = 0;
document.addEventListener('beforeinput', () => landed++, true);
// ... 跑操作 ...
assert(landed > 0); // 这里的 landed 才是真相
判据从「我做了」挪到「目标那边收到了几次」。听起来是废话,但这两种信号在形状一里给出的答案正好相反。
形状二:到了,但没变
表现:事件被受理,处理器跑完了,产出为零。
这一层比形状一难查,因为每一个返回值都在说成功。
我灌 HTML 到腾讯云编辑器的时候遇到过:
json
{"defaultPrevented": true, "grew": false}
defaultPrevented: true 意思是事件没有被拦截,是被正常处理了。处理器跑了。然后 grew: false ------ 内容一个字都没长。
如果我不看 grew 这个字段,只看「事件成功派发、没被阻止」,我会判定成功。
探针:断言产出量,不断言事件状态。
scss
const before = measure();
dispatchPaste(html);
const after = measure();
assert(after !== before); // 关键字段
grew 是我自己加的字段。当时我只是想留个日志,后来它救了我一次。给关键操作加一个「产出量」字段,比加十个「状态 OK」字段有用。
形状三:变了,但没进模型
表现:DOM 投影对了,框架内存里的值是空的。
《计数器停在 0》整篇讲的就是这一层。
我灌了 112 个字,textarea.value 逐字比对全对,一个不差。平台计数器:0/120。
不矛盾。value 是 DOM 属性,是我自己写进去的。框架有自己的模型,写 DOM 不会自动改它。这两个值同时成立,而且它们本来就该同时成立。
同一层还有一个更阴的变体:两次写入叠加。
同一篇文章里我往作者框填名字,先用一种方式,发现计数器是 0,判断没生效,换另一种方式又来了一遍。结果框里是:
ikalus1988ikalus1988
因为第一次其实也写进去了。在这一层,「没生效」和「生效了但没进模型」是同一件事。
探针:用框架自己画的反馈,不用 DOM 读回。
字数计数器、脏标记(那个小圆点)、预览渲染 ------ 这些是框架主动画的实时反馈。它们不动,等于框架没收到。
比起逐字比对 value,计数器的信息量小得多。但它问的是正确的问题。
形状四:进了模型,没落库
表现:内存里是对的,服务端是空的。
公众号那次最典型。我点「发表」,页面直接跳走了,地址栏变成了一个我没见过的 URL,弹窗说「已保存」。
三个信号,全是成功语气。
发表记录纹丝不动。
探针:重新拉取服务端状态,不看任何本地反馈。
不是弹窗,不是 toast,不是页面跳转,不是 URL 里多出来的 id。是下一次从服务端拉回来的那份数据。
四层是一条链
这四种形状不是并列的分类,是一条因果链:
没到 → 到了没变 → 变了没进模型 → 进了模型没落库
动作 事件 DOM 服务端
你在第二层验收,第二层的故障对你完全隐形,而第三、四层的故障全部长得像成功。
更麻烦的是:第一层故障会让后面三层的信号全变成 0,而 0 看起来像「没数据」,不像「上游没通」。
所以排查顺序必须从外往里。你不能先在服务端验收,然后倒推中间哪一环断了 ------ 因为服务端那个 0 有四种完全不同的成因。
发布前自检清单
这份清单我给每个要提交的字段都过一遍。不是每个字段都有探针,没探针的字段我宁可不要。
一、判据的方向
- 我的判据来自比动作更远的一端吗?
- 我用的是返回值,还是目标状态?
- 如果返回值是
true,但目标状态我没查过 ------ 这一项算没过。
二、探针和故障点错维
- 我的探针测的这一维,和可能出故障的这一维,是同一维吗?
- 如果是同一维,那它就是双重失败:坏了也测不出来。
这条是整个清单里最贵的一条。我在《计数器停在 0》里付过一次学费:DOM 读回在「内容对不对」上精确到字,在「框架知不知道」上是彻底瞎的。而故障点恰好整整齐齐躺在它盲的那一维上。
- 这个探针是别人画的 吗(平台的计数器、服务端的列表),还是我画的(我写进去的 DOM、我打的日志、我造的状态字段)?
- 我自己画的探针,会不会和我的错误是同一个来源?
三、每层至少一个探针
- 终点副作用计数(形状一)
- 产出量差值断言(形状二)
- 框架原生回显(形状三)
- 服务端重新拉取(形状四)
四层里我这次只有第三层的探针是对的。前两层是运气,第四层我压根没想到。
四、换方法之前
- 换方法前先清空,并断言它真的空了。
- 断言是读回读出来的,还是我推断出来的?
「再试一次」「换条路走」不能当补救用。**第一次没成功,不代表第一次什么都没做。**在形状三那一层,第一次做的是一半,你得先把这一半擦掉。
五、发布前的总闸
- 每一个要提交的字段,我都能说出它的探针在哪吗?
- 有任何字段我不能确定它是否生效 ------ 不发。
这条不是严谨,是经验。不确定不是「大概率没问题」,不确定就是不知道,而不知道的东西没有默认值。
一条反直觉的推论
写完这份清单我意识到一件事。
「加更多自检」本身不是解法。你能加的探针数量受限于你能观测到的维度,而故障点可以正好落在你某个观测不到的维度里。
所以真正能提高通过率的,不是「检查得更细」,是换一个维度去检查。
《三套 API》那篇我说过一句话:唯一真正把中文送进编辑器的路,恰恰是唯一不发按键信号的路 ------ 验证手段和正确解法,在结构上互斥。
这句话在《计数器停在 0》里递归了一遍:不是「输入方案」和「验证信号」互斥,是「我用来验收的那个方法」和「这次真正出问题的那一维」互斥。
两次的教训完全一样:当你的解法和你的判据共用同一个盲区时,你做得越干净,错得越自信。
绕开的办法只有一个:**判据必须由第三方提供,而且必须来自比动作更远的一端。**平台画的计数器、平台返回的列表、平台的状态标签。
自己画的判据,在最难的那一类故障上,是负资产。
收尾
这三篇文章我前后写了三个晚上,写的时候每次都以为自己弄明白了。
回头看,三次犯的是同一个错的三层。
我把它们整理成了上面这份清单。它不完整,四层只是我踩过的形状,你可能会碰到第五种、第六种。
但有一条我觉得是稳的:
只要你是在自己这一端验收,你就在赌重合。
重合了九十九次,输的那一次不会提前通知你。