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

"事件冒泡是什么?""捕获和冒泡谁先执行?"这类问题看似基础,但真正拉开差距的,是后面跟着的那句: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。

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

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