3. 事件捕获与冒泡:W3C事件流模型

事件传播,说白了就是浏览器怎么决定「谁先收到这个点击」。我刚入行那会儿,以为点击一个按钮,事件就只触发这个按钮的监听器。后来发现完全不是这么回事——事件会从根节点一路「钻」到目标,再一路「浮」回去。整个过程分三个阶段,这就是W3C标准定义的事件流模型。

3.1 三个阶段的完整流程

W3C把事件传播拆成了三个阶段:

  • 捕获阶段(Capture Phase):事件从 window 向下传播,直到目标元素的父节点
  • 目标阶段(Target Phase):事件到达目标元素本身
  • 冒泡阶段(Bubble Phase):事件从目标元素向上冒泡,直到 window

我习惯用「扔石头进水」来理解:石头先穿过水面(捕获),砸中水底的目标(目标),然后气泡浮上来(冒泡)。

核心要点:捕获阶段和冒泡阶段是镜像对称的。捕获是从外到内,冒泡是从内到外。目标阶段只出现一次。

W3C事件流三阶段示意图 事件传播三阶段 捕获阶段 目标阶段 冒泡阶段 window document <html> <body> 父元素 目标元素 捕获阶段 事件从 window 向下传播 到目标元素的父节点 目标阶段 事件到达目标元素 触发目标上的监听器 冒泡阶段 事件从目标元素向上冒泡 直到 window 关键行为 • 捕获阶段:默认不触发监听器 除非设置 capture: true • 目标阶段:触发目标元素上的 所有监听器(捕获+冒泡) • 冒泡阶段:最常用的阶段 事件委托依赖冒泡 • stopPropagation() 可以 中断后续传播 • 部分事件不冒泡 (如 focus、blur、mouseleave)

3.2 addEventListener 的第三个参数

很多人以为 addEventListener 只有两个参数。其实第三个参数决定了监听器在哪个阶段触发。我见过不少同事踩过这个坑。

// 默认行为:只在冒泡阶段触发
element.addEventListener('click', handler);

// 显式指定:在捕获阶段触发
element.addEventListener('click', handler, true);

// 对象形式(推荐,语义更清晰)
element.addEventListener('click', handler, { capture: true });

// 只触发一次,自动移除
element.addEventListener('click', handler, { once: true });

我的习惯:除非有特殊需求,否则我永远用 { capture: false }(默认值)。捕获阶段监听器容易让代码逻辑变得难以追踪。你想想看,一个点击事件在到达目标之前就被拦截了,调试的时候很容易懵。

3.3 捕获与冒泡的对比

特性 捕获阶段 冒泡阶段
传播方向 从外到内(window → 目标) 从内到外(目标 → window)
默认触发 否(需设置 capture: true) 是(默认行为)
使用场景 全局拦截、事件预处理 事件委托、UI 组件通信
调试难度 较高(容易意外拦截) 较低(符合直觉)
性能影响 较少使用,影响范围大 频繁使用,需注意委托优化

3.4 事件委托:冒泡阶段的最佳实践

事件委托,说白了就是利用冒泡机制,在父元素上统一处理子元素的事件。我做过一个项目,列表里有上千个可点击项。如果每个项都绑一个监听器,性能直接崩了。

// ❌ 不推荐:每个子元素单独绑定
document.querySelectorAll('.list-item').forEach(item => {
  item.addEventListener('click', handleItemClick);
});

// ✅ 推荐:事件委托,只绑一个监听器
document.querySelector('.list').addEventListener('click', (event) => {
  const target = event.target.closest('.list-item');
  if (target) {
    handleItemClick(target);
  }
});

事件委托的优势:

  • 减少内存占用(一个监听器 vs N 个监听器)
  • 动态元素自动生效(新增的子元素无需额外绑定)
  • 便于统一管理和解绑

3.5 阻止传播:stopPropagation 与 stopImmediatePropagation

这两个方法都能阻止事件继续传播,但区别很大。我曾经在调试一个复杂的弹窗组件时,被 stopPropagation 坑了整整一个下午。

// 阻止事件继续传播(捕获或冒泡)
event.stopPropagation();
// 当前元素上的其他监听器仍然会执行

// 阻止事件传播 + 阻止当前元素上后续监听器执行
event.stopImmediatePropagation();
// 当前元素上尚未执行的监听器都不会再触发

我曾经踩过的坑:在父元素的捕获阶段监听器里调用了 stopPropagation(),结果子元素的所有监听器都不触发了。因为事件在捕获阶段就被截停了,根本没到达目标阶段。所以用 stopPropagation 之前,一定要想清楚你当前在哪个阶段。

3.6 不冒泡的事件

不是所有事件都会冒泡。我记得刚学的时候以为所有事件都冒泡,结果 focus 事件死活不触发父元素的监听器。查了文档才发现——有些事件天生就不冒泡。

事件类型 是否冒泡 替代方案
click / mousedown / mouseup 是 —
mouseover / mouseout 是 —
focus / blur 否 focusin / focusout(冒泡)
mouseenter / mouseleave 否 mouseover / mouseout(冒泡)
load / unload 否 —
scroll 否(部分浏览器冒泡) 使用捕获阶段监听

小技巧:如果你不确定某个事件是否冒泡,直接在子元素上触发事件,在父元素上监听看看能不能收到。或者查 MDN 文档,事件页面都会标注「Bubbles: Yes/No」。

3.7 实际项目中的选择策略

说了这么多,到底什么时候用捕获,什么时候用冒泡?我个人的经验是这样的:

  1. 默认用冒泡:95% 的场景都够了。事件委托、UI 交互、表单处理,冒泡阶段都能搞定。
  2. 捕获用于全局拦截:比如你想在页面任何地方点击时都先执行一段逻辑(统计、权限校验、全局快捷键),捕获阶段是最早的拦截点。
  3. 目标阶段别纠结:目标阶段同时属于捕获和冒泡,你设置 capture: true 或 false 在目标元素上效果一样——都会触发。
  4. 避免混用:同一个事件,不要在捕获和冒泡阶段都绑监听器。逻辑容易乱,调试起来想砸电脑。

一句话总结:捕获阶段是「先下手为强」,冒泡阶段是「秋后算账」。大多数时候,秋后算账就够了。