事件传播机制:捕获与冒泡
触摸事件在 DOM 树里怎么跑?这个问题我当年也困惑过。你点了一个按钮,浏览器到底先通知谁?是按钮自己,还是它的父容器,甚至是 document?
其实标准里早就定好了规则——事件传播分三个阶段。我刚开始做移动端时,就因为没搞懂这个,排查一个点击穿透问题花了整整两天。嗯,咱们今天就把这事彻底说清楚。
三个阶段:捕获、目标、冒泡
一个触摸事件从触发到结束,会经历三个步骤:
- 捕获阶段:事件从 document 一路往下传,直到目标元素的父节点。
- 目标阶段:事件到达真正触发的元素。
- 冒泡阶段:事件从目标元素一路往上冒,回到 document。
说白了,就像石头扔进水里——先沉到底,再浮上来。捕获是下沉,冒泡是上浮。
关键点:默认情况下,我们绑定的事件监听器只在冒泡阶段触发。如果你想在捕获阶段拦截,需要给 addEventListener 的第三个参数传 true。
// 冒泡阶段触发(默认)
element.addEventListener('touchstart', handler);
// 捕获阶段触发
element.addEventListener('touchstart', handler, true);
我个人习惯在冒泡阶段处理大部分逻辑,因为更符合直觉。但有些场景——比如全局拦截——捕获阶段反而更干净。
触摸事件的传播路径
触摸事件的传播路径和鼠标事件完全一致。假设你有一个结构:
<div id="parent">
<button id="child">点我</button>
</div>
当你触摸 button 时,事件传播顺序是这样的:
| 阶段 | 节点顺序 | 说明 |
|---|---|---|
| 捕获 | document → html → body → div#parent | 从上往下,逐级经过每个祖先节点 |
| 目标 | button#child | 真正触发事件的元素 |
| 冒泡 | div#parent → body → html → document | 从下往上,原路返回 |
我在项目中遇到过一个问题:一个全屏遮罩层,点击遮罩本身应该关闭,但点击遮罩里的弹窗也会触发关闭。原因就是事件冒泡到了遮罩层。你想想看,如果不理解传播路径,这种 bug 能查到你怀疑人生。
小技巧:调试传播路径时,可以在每个节点打印 event.target 和 event.currentTarget。前者是实际触发的元素,后者是当前正在处理事件的元素。这两个值不一样,说明事件正在传播。
如何阻止事件传播
阻止传播有两个方法,很多人混用,其实区别很大。
| 方法 | 作用 | 影响 |
|---|---|---|
| event.stopPropagation() | 阻止事件继续传播 | 当前元素的其他监听器仍会执行 |
| event.stopImmediatePropagation() | 阻止事件传播 + 阻止当前元素后续监听器 | 当前元素上还没执行的监听器也会被跳过 |
// 场景:按钮点击后,不希望父容器收到事件
child.addEventListener('touchstart', (e) => {
e.stopPropagation();
// 父容器的 touchstart 不会被触发
});
// 场景:紧急拦截,连当前元素的其他监听器也别执行
child.addEventListener('touchstart', (e) => {
e.stopImmediatePropagation();
// 当前元素上绑定的其他 touchstart 监听器都不会执行
});
注意:不要滥用 stopPropagation。我曾经在一个项目里看到全局都在调 stopPropagation,结果导致页面上的滚动、手势全部失灵。阻止传播应该是最后的手段,而不是默认行为。
一张图看懂事件传播
下面这张 SVG 图把整个传播路径画清楚了。建议你保存下来,排查延迟问题时随时对照。
实战中的传播问题
讲个真实案例。我之前排查过一个触摸延迟问题:页面上的一个滑动组件,每次触摸后要等 300ms 才有反应。查了半天,发现是父容器在捕获阶段拦截了 touchstart,然后调了 stopPropagation。子组件的监听器根本收不到事件。
修复方法很简单——去掉那个 stopPropagation,或者把监听器改到捕获阶段。但根源在于:当初写代码的人不理解传播路径,随手加了个阻止传播,觉得「反正不影响」。结果影响大了去了。
避坑指南:如果你不确定要不要阻止传播,先别阻止。用 event 对象里的 path 属性(或 composedPath() 方法)打印出完整传播链,看清楚再动手。
// 打印事件传播路径
element.addEventListener('touchstart', (e) => {
console.log('传播路径:', e.composedPath());
// 返回一个数组,从目标元素一直到 document
});
嗯,事件传播机制就这些。理解了这个,你排查触摸延迟时就能快速定位——是事件根本没传到?还是传到了但被拦截了?还是冒泡阶段触发了不该触发的逻辑?