创见博客
事件冒泡与捕获:一次讲透 addEventListener 的三个参数
七崽爱吃小饼干2026/10/21阅读 4

"事件冒泡是什么?""捕获和冒泡谁先执行?"这类问题看似基础,但真正拉开差距的,是后面跟着的那句:addEventListener 的三个参数分别是什么,capture 和 once、passive 到底解决了什么问题。

很多人只记得 addEventListener(type, listener, useCapture),却不知道第三个参数早就可以传一个配置对象了。这篇文章从事件流讲起,把捕获/冒泡的全过程和三个参数一次说清。

一、事件流:捕获 → 目标 → 冒泡

DOM 事件流分为三个阶段:

  1. 捕获阶段(Capture):事件从 window 一路向下传递到目标元素的父级。
  2. 目标阶段(Target):事件到达真正被点击的元素。
  3. 冒泡阶段(Bubble):事件从目标元素一路向上冒泡回 window。

用一段嵌套结构演示:

html
<div id="outer">
  <div id="inner">点我</div>
</div>
js
outer.addEventListener('click', () => console.log('outer 捕获'), true);
inner.addEventListener('click', () => console.log('inner'));
outer.addEventListener('click', () => console.log('outer 冒泡'));

点击 inner,输出顺序是:

outer 捕获
inner
outer 冒泡

因为 outer 的捕获监听先于冒泡监听执行,而 inner 作为目标元素,注册的监听在目标阶段触发。这就是"先捕获,后冒泡"。

注意:focus、blur 等少数事件不冒泡;mouseenter、mouseleave 也不冒泡。它们通常有冒泡版本,如 focusin、focusout、mouseover、mouseout。

二、浏览器为什么要设计捕获和冒泡

事件不是凭空“触发”一下就结束。DOM 是一棵树,浏览器需要给事件定义一条确定的传播路径,让路径上的元素都有机会处理它。捕获和冒泡,分别对应两种不同的需求。

捕获:让祖先先拦截

捕获阶段从 window 向下到目标元素:

text
window → document → body → parent → target

它解决的是“在目标处理之前先做点什么”的需求,例如:

  • 全局快捷键、权限校验
  • 提前调用 preventDefault()
  • 在事件到达目标前决定是否拦截
  • 处理不冒泡的事件

如果只有冒泡,祖先就没有机会在目标之前介入。

冒泡:让目标先处理,再向上通知

冒泡阶段从目标元素向上回到 window:

text
target → parent → body → document → window

它更符合大多数业务直觉:用户点的是目标元素,目标先响应,然后父级再处理。事件委托就是典型例子:父元素通过冒泡统一处理子元素事件,包括后来动态添加的子元素。

如果只有捕获,父元素会先于子元素执行,很多交互逻辑会变得反直觉。

为什么两个阶段都保留

这背后有历史原因。

早期浏览器里,Netscape 主张捕获模型,IE 主张冒泡模型,两边并不一致。后来 DOM Level 2 标准把两种模型都收了进来:

text
捕获 → 目标 → 冒泡

于是同一个事件可以有两种监听方式:

js
el.addEventListener('click', handler, true);   // 捕获
el.addEventListener('click', handler, false);  // 冒泡,默认

这不是纯粹的技术最优解,而是浏览器竞争、兼容性和标准化共同作用的结果。

为什么默认是冒泡

因为绝大多数开发者的预期是“目标先处理,再向上冒泡”。同时,早期大量代码依赖 onclick 和冒泡行为,如果默认改成捕获,会破坏已有的 Web 页面。

捕获因此被设计成显式选项:需要提前拦截时才开启。

代价是什么

两个阶段让事件系统更灵活,但也更复杂:

  • stopPropagation() 会截断传播,可能让预期的监听器收不到事件。
  • 捕获和冒泡混用时,执行顺序容易出错。
  • 不冒泡的事件需要特殊处理。
  • 事件委托依赖冒泡,遇到 stopPropagation() 就可能失效。

所以浏览器不是单纯为了“功能多”而设计两个阶段,而是在解决一类真实问题:既要允许祖先提前拦截,又要保留目标优先和向上委托的能力。捕获负责前者,冒泡负责后者,默认冒泡则是历史兼容和开发直觉共同选择的结果。

三、事件在哪个阶段触发?

addEventListener 的第三个参数决定监听器注册在哪个阶段:

  • true 或 { capture: true }:注册在捕获阶段。
  • false 或省略(默认):注册在冒泡阶段。

对同一个元素注册捕获和冒泡两个监听器,捕获的那个会先执行:

js
el.addEventListener('click', handlerA, true);   // 捕获,先执行
el.addEventListener('click', handlerB, false);  // 冒泡,后执行

这里有个容易被忽略的细节:目标元素本身的捕获和冒泡监听,都会在目标阶段触发,执行顺序按注册顺序,而非捕获优先。

四、addEventListener 的三个参数

参数一:type

事件类型字符串,如 'click'、'input'、'keydown'。大小写敏感,必须全小写。

参数二:listener

事件触发时执行的回调函数,接收一个 event 对象。传入 null 或非函数会被忽略。同一个函数引用重复注册同一事件、同一阶段,只会生效一次(去重)。

参数三:options / useCapture

这是信息量最大的部分。它可以是布尔值(历史写法),也可以是配置对象:

js
element.addEventListener('click', handler, {
  capture: false,   // 是否在捕获阶段触发
  once: false,      // 是否只触发一次
  passive: false,   // 是否承诺不调用 preventDefault
  signal: undefined // AbortSignal,用于批量移除监听
});

capture

是否在捕获阶段触发,默认 false。这也是 useCapture 的本意。老代码里第三个参数直接传布尔值就是它。

once

设为 true 时,监听器触发一次后自动移除,不需要手动 removeEventListener。非常适合"只响应第一次"的场景:

js
button.addEventListener('click', () => {
  console.log('只会打印一次');
}, { once: true });

passive

设为 true 表示"我不会调用 preventDefault()",浏览器因此不必等待回调执行完,就能立即滚动页面,从而提升滚动性能。

典型场景是 touchstart / touchmove / wheel:

js
document.addEventListener('touchmove', onMove, { passive: true });

如果在一个 passive: true 的监听器里调用 preventDefault(),浏览器会忽略并打印警告:

Unable to preventDefault inside passive event listener invocation.

顺带一提:Chrome 从 56 起,对 document / body 上的 touchstart / touchmove 默认为 passive,这也是很多"下拉刷新/自定义滚动失效"的根因。

signal

传入一个 AbortSignal,调用 controller.abort() 即可移除该监听器:

js
const controller = new AbortController();
el.addEventListener('click', handler, { signal: controller.signal });

controller.abort(); // 移除监听

这解决了"无法批量移除匿名回调"的老大难问题。

五、事件对象里的几个关键点

  • event.target:真正触发事件的元素(可能是子元素)。
  • event.currentTarget:当前绑定监听器的元素,等同于回调里的 this。
  • event.stopPropagation():阻止事件继续传播(捕获/冒泡都停)。
  • event.stopImmediatePropagation():不仅阻止传播,还阻止同一元素上后续监听器执行。
  • event.preventDefault():阻止默认行为(如链接跳转),与冒泡无关。

因为事件冒泡,子元素上的点击会触发父元素的监听,target 和 currentTarget 往往不同,这正是事件委托的原理。

六、事件委托

利用冒泡,把监听器挂在父元素上统一处理,比给每个子元素都绑一个更高效(尤其列表项动态增删时):

js
list.addEventListener('click', (e) => {
  const item = e.target.closest('.item');
  if (!item) return;
  console.log('点击了', item.dataset.id);
});

注意用 closest 从 target 往上找目标元素,因为 target 可能是图标、文本节点等子元素。

七、小结

  • 事件流顺序:捕获 → 目标 → 冒泡,注册捕获的监听器先执行。
  • 捕获和冒泡同时存在,是为了兼顾祖先提前拦截和目标优先/事件委托;默认冒泡则来自历史兼容和开发直觉。
  • 第三个参数既可以是 useCapture 布尔值,也可以是 { capture, once, passive, signal } 配置对象。
  • once 省去手动解绑,passive 提升滚动性能,signal 支持批量移除。
  • target 是触发者,currentTarget 是绑定者,事件委托靠的就是这个差异。
评论
0/100