事件传播机制:捕获与冒泡

触摸事件在 DOM 树里怎么跑?这个问题我当年也困惑过。你点了一个按钮,浏览器到底先通知谁?是按钮自己,还是它的父容器,甚至是 document?

其实标准里早就定好了规则——事件传播分三个阶段。我刚开始做移动端时,就因为没搞懂这个,排查一个点击穿透问题花了整整两天。嗯,咱们今天就把这事彻底说清楚。

三个阶段:捕获、目标、冒泡

一个触摸事件从触发到结束,会经历三个步骤:

  1. 捕获阶段:事件从 document 一路往下传,直到目标元素的父节点。
  2. 目标阶段:事件到达真正触发的元素。
  3. 冒泡阶段:事件从目标元素一路往上冒,回到 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 图把整个传播路径画清楚了。建议你保存下来,排查延迟问题时随时对照。

触摸事件传播路径 捕获阶段 document html body div#parent button#child 冒泡阶段 捕获:document → html → body → div#parent → button#child 冒泡:button#child → div#parent → body → html → document

实战中的传播问题

讲个真实案例。我之前排查过一个触摸延迟问题:页面上的一个滑动组件,每次触摸后要等 300ms 才有反应。查了半天,发现是父容器在捕获阶段拦截了 touchstart,然后调了 stopPropagation。子组件的监听器根本收不到事件。

修复方法很简单——去掉那个 stopPropagation,或者把监听器改到捕获阶段。但根源在于:当初写代码的人不理解传播路径,随手加了个阻止传播,觉得「反正不影响」。结果影响大了去了。

避坑指南:如果你不确定要不要阻止传播,先别阻止。用 event 对象里的 path 属性(或 composedPath() 方法)打印出完整传播链,看清楚再动手。

// 打印事件传播路径
element.addEventListener('touchstart', (e) => {
  console.log('传播路径:', e.composedPath());
  // 返回一个数组,从目标元素一直到 document
});

嗯,事件传播机制就这些。理解了这个,你排查触摸延迟时就能快速定位——是事件根本没传到?还是传到了但被拦截了?还是冒泡阶段触发了不该触发的逻辑?