"事件冒泡是什么?""捕获和冒泡谁先执行?"这类问题看似基础,但真正拉开差距的,是后面跟着的那句:addEventListener 的三个参数分别是什么,capture 和 once、passive 到底解决了什么问题。
很多人只记得 addEventListener(type, listener, useCapture),却不知道第三个参数早就可以传一个配置对象了。这篇文章从事件流讲起,把捕获/冒泡的全过程和三个参数一次说清。
一、事件流:捕获 → 目标 → 冒泡
DOM 事件流分为三个阶段:
- 捕获阶段(Capture):事件从
window一路向下传递到目标元素的父级。 - 目标阶段(Target):事件到达真正被点击的元素。
- 冒泡阶段(Bubble):事件从目标元素一路向上冒泡回
window。
用一段嵌套结构演示:
<div id="outer">
<div id="inner">点我</div>
</div>
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或省略(默认):注册在冒泡阶段。
对同一个元素注册捕获和冒泡两个监听器,捕获的那个会先执行:
el.addEventListener('click', handlerA, true); // 捕获,先执行
el.addEventListener('click', handlerB, false); // 冒泡,后执行
这里有个容易被忽略的细节:目标元素本身的捕获和冒泡监听,都会在目标阶段触发,执行顺序按注册顺序,而非捕获优先。
三、addEventListener 的三个参数
参数一:type
事件类型字符串,如 'click'、'input'、'keydown'。大小写敏感,必须全小写。
参数二:listener
事件触发时执行的回调函数,接收一个 event 对象。传入 null 或非函数会被忽略。同一个函数引用重复注册同一事件、同一阶段,只会生效一次(去重)。
参数三:options / useCapture
这是信息量最大的部分。它可以是布尔值(历史写法),也可以是配置对象:
element.addEventListener('click', handler, {
capture: false, // 是否在捕获阶段触发
once: false, // 是否只触发一次
passive: false, // 是否承诺不调用 preventDefault
signal: undefined // AbortSignal,用于批量移除监听
});
capture
是否在捕获阶段触发,默认 false。这也是 useCapture 的本意。老代码里第三个参数直接传布尔值就是它。
once
设为 true 时,监听器触发一次后自动移除,不需要手动 removeEventListener。非常适合"只响应第一次"的场景:
button.addEventListener('click', () => {
console.log('只会打印一次');
}, { once: true });
passive
设为 true 表示"我不会调用 preventDefault()",浏览器因此不必等待回调执行完,就能立即滚动页面,从而提升滚动性能。
典型场景是 touchstart / touchmove / wheel:
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() 即可移除该监听器:
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 往往不同,这正是事件委托的原理。
五、事件委托
利用冒泡,把监听器挂在父元素上统一处理,比给每个子元素都绑一个更高效(尤其列表项动态增删时):
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是绑定者,事件委托靠的就是这个差异。