排查问题时,不要太早相信第一假设
从一次 Vue 组件事件失效的排查经历出发,讨论为什么排查问题时不要太早相信第一假设,以及如何用控制变量、断点、DOM 身份和版本控制把问题一步步缩小。
工程师解决问题时,最危险的东西之一,是一个看起来很合理的第一假设。
它来得很快,解释力很强,还常常带着一点经验的光芒。人一旦相信它,就会开始围着它找证据。找不到,也不一定怀疑假设,反而怀疑自己查得还不够深。
这时问题就麻烦了。
排查问题最怕的不是没有方向,而是方向错了还走得很坚决。
那次事件失效
刚工作不久时,遇到过一个 Vue 组件的问题。
前一天封好的一个移动端二级菜单组件,在一个页面上已经正常使用。第二天放到另一个页面,突然失效。这个菜单要求能左右滑动,也能点击。
当时为了统一 iOS 和 Android 的滚动体验,没有直接用原生滚动,而是用了 bscroll 这类滚动库。问题页面本身又有纵向滚动,也用了类似的滚动能力。
于是第一假设非常自然地冒了出来:
是不是外层滚动库拦截了事件?
这个假设听起来很像那么回事。滚动库、移动端、事件冒泡、阻止默认行为、点击穿透,几个词往一起一摆,像极了前端问题。
于是我在事件上倒腾了一下午。
查事件冒泡,查捕获,查 preventDefault,查 stopPropagation,查外层容器,查滚动库。越查越像一场泥地行军,脚下全是细节,方向却越来越模糊。
最后发现,问题根本不在事件流。
真正的原因很低级:组件里用了 id 选择器。
上一个页面只有一个组件,所以没出事;新页面里有两个同样的组件。id 本来应该唯一,重复以后选择器拿到的是第一个。偏偏第一个组件因为样式问题处于隐藏状态。
也就是说,我一直在一个看不见的组件上绑定事件,然后在另一个没有绑定事件的组件上疯狂调试。
这事说出来有点可笑。
但排查问题时,很多时间就是这样丢掉的。不是丢在难题上,而是丢在一个过早相信的假设上。
第一假设为什么危险
第一假设通常不是胡说。
它往往来自经验。滚动库确实可能处理事件,移动端事件确实复杂,嵌套滚动确实容易出问题。正因为它合理,才更危险。
完全荒谬的猜测,人反而不会信。最容易误导人的,是那种“八成就是它”的判断。
一旦心里有了这个判断,后面的动作就会变形。
你会优先看和它有关的代码,会把新现象解释成它的旁证,会忽略那些不符合它的细节。调试从寻找真相,变成替假设辩护。
这和写 bug 没什么区别。
写 bug 是代码相信了错误前提;查 bug 是人相信了错误前提。
先确认事实,不急着解释
后来再排查问题,我会尽量先做一件事:把问题描述成事实,而不是解释。
不说:
bscroll 把点击事件拦截了。
而说:
在 A 页面点击菜单有效,在 B 页面点击菜单无效。B 页面里目标 DOM 上是否真的绑定了点击事件,还没有确认。
这两句话差别很大。
前一句已经下结论,后一句只是记录现象。只要还停留在现象层,就更容易继续问问题:
- 事件有没有绑定到预期元素?
- 绑定的是不是当前看到的那个组件?
- 点击时事件有没有触发?
- 如果触发了,执行到哪一步停了?
- 如果没触发,是 DOM 不对,时机不对,还是被阻止了?
这些问题比“是不是滚动库搞鬼”更可靠。
排查问题不是写侦探小说,不需要一开始就有凶手。先把现场勘清楚,凶手有时会自己站出来。
DOM 身份很重要
那次问题给我最大的教训,是组件里不要随便用全局 id 选择器。
id 在 HTML 里本来就应该唯一。可组件的意义,恰恰是可以被多次使用。一旦组件里写死 id,复用时就埋了雷。浏览器和 JavaScript 对重复 id 又很宽容,不会立刻炸给你看,只会在某个页面里悄悄选错元素。
Vue 里可以用 ref。它能在组件实例里定位元素或子组件,不会像全局 id 那样互相打架。
更重要的是,要始终确认“我操作的是不是我以为的那个东西”。
这个问题不只存在于 DOM。
React 里的 key、Vue 里的组件实例、表单里的字段名、后端里的对象 id、数据库里的唯一键,背后都是同一个问题:身份。如果身份认错了,后面的逻辑再复杂也没用。
一个请求打到了错误环境,一个事件绑到了隐藏元素,一个状态更新了旧实例,一个缓存命中了错误 key,表现出来都可能像玄学。
其实不是玄学,是认错人。
调试事件,不要只盯代码
事件问题很适合用浏览器开发者工具查。
Chrome DevTools 里可以看元素上绑定的事件,也可以在 Sources 面板里的 Event Listener Breakpoints 对事件打断点。比如勾选 click,再去页面上点击,就能看到到底是哪段代码被执行。
这比盯着代码猜要快。
很多时候,人看代码会自动脑补执行路径。浏览器不会。它只告诉你事实:有没有绑定,触发了谁,调用栈是什么,在哪一步停下。
preventDefault 和 stopPropagation 也要分清楚。
preventDefault 是取消默认行为,比如阻止链接跳转、阻止表单提交、阻止复选框默认勾选。它不是用来阻止事件继续传播的。
stopPropagation 才是阻止事件继续冒泡或捕获。
这两个方法当然都可能影响问题,但不要一上来就乱加。乱加这些方法,就像屋里漏水时先把所有门窗都封死,看起来在处理,实际可能把新问题也埋了进去。
控制变量不是口号
排查问题时常说控制变量。
这句话说起来很容易,真正难的是:怎么知道哪些变量已经被控制住了?
我的经验是,尽量把问题缩小到能被验证的程度。
比如那次事件问题,可以按顺序做这些检查:
- 页面里到底有几个目标组件?
- 目标 DOM 是否唯一?
- 事件是否绑定到当前可见元素?
- 点击时断点是否进入处理函数?
- 去掉外层滚动库后,问题是否还存在?
- 换成
ref后,问题是否消失?
每一步都只回答一个问题。
如果一口气改三处,问题好了也不知道是哪处好的;问题没好,也不知道哪处判断错了。调试时最忌讳把实验做成一锅粥。
版本控制也很重要。
当时我其实有 git,却没有立刻想到可以大胆删改验证。怕把代码改坏,是很多新人都会有的心理。可如果版本控制在,分支在,工作区能恢复,就应该用它换取验证速度。
大胆实验,小心提交。
这是 git 给调试带来的底气。
有人可问也很重要
那次最后能定位到问题,也离不开同事提醒。
刚入行时,身边有没有靠谱的人能问,差别非常大。很多问题自己闷头查一天,别人看一眼就能指出方向。不是别人比你聪明多少,而是他的经验里已经踩过类似的坑。
这也是为什么新人选择环境时,不能只看业务酷不酷、技术栈新不新。
有没有人做 code review?有没有成熟的调试习惯?有没有工程规范?遇到问题时,是有人一起拆,还是所有人都在救火?这些东西比“我们用最新框架”重要得多。
技术成长不是闭门修仙。
很多时候,是在一次次问题排查里,学会别人怎么想。
最后
这个问题本身并不高级。
重复 id、隐藏组件、事件绑错对象。讲出来甚至有点像低级错误合集。
但它留下的教训一直有用:
不要太早相信第一假设。
先确认事实,再解释原因。先看事件有没有绑定,再讨论事件为什么没触发。先确认操作对象是谁,再研究复杂机制。先做小实验,再下大判断。
工程师不是靠猜中答案解决问题,而是靠一步步排除错误答案。
很多 bug 看起来像深山,走进去才发现只是门口的牌子写错了。