"事件冒泡是什么?""捕获和冒泡谁先执行?"这类问题看似基础,但真正拉开差距的,是后面跟着的那句: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。
二、浏览器为什么要设计捕获和冒泡
事件不是凭空“触发”一下就结束。DOM 是一棵树,浏览器需要给事件定义一条确定的传播路径,让路径上的元素都有机会处理它。捕获和冒泡,分别对应两种不同的需求。
捕获:让祖先先拦截
捕获阶段从 window 向下到目标元素:
window → document → body → parent → target
它解决的是“在目标处理之前先做点什么”的需求,例如:
- 全局快捷键、权限校验
- 提前调用
preventDefault() - 在事件到达目标前决定是否拦截
- 处理不冒泡的事件
如果只有冒泡,祖先就没有机会在目标之前介入。
冒泡:让目标先处理,再向上通知
冒泡阶段从目标元素向上回到 window:
target → parent → body → document → window
它更符合大多数业务直觉:用户点的是目标元素,目标先响应,然后父级再处理。事件委托就是典型例子:父元素通过冒泡统一处理子元素事件,包括后来动态添加的子元素。
如果只有捕获,父元素会先于子元素执行,很多交互逻辑会变得反直觉。
为什么两个阶段都保留
这背后有历史原因。
早期浏览器里,Netscape 主张捕获模型,IE 主张冒泡模型,两边并不一致。后来 DOM Level 2 标准把两种模型都收了进来:
捕获 → 目标 → 冒泡
于是同一个事件可以有两种监听方式:
el.addEventListener('click', handler, true); // 捕获
el.addEventListener('click', handler, false); // 冒泡,默认
这不是纯粹的技术最优解,而是浏览器竞争、兼容性和标准化共同作用的结果。
为什么默认是冒泡
因为绝大多数开发者的预期是“目标先处理,再向上冒泡”。同时,早期大量代码依赖 onclick 和冒泡行为,如果默认改成捕获,会破坏已有的 Web 页面。
捕获因此被设计成显式选项:需要提前拦截时才开启。
代价是什么
两个阶段让事件系统更灵活,但也更复杂:
stopPropagation()会截断传播,可能让预期的监听器收不到事件。- 捕获和冒泡混用时,执行顺序容易出错。
- 不冒泡的事件需要特殊处理。
- 事件委托依赖冒泡,遇到
stopPropagation()就可能失效。
所以浏览器不是单纯为了“功能多”而设计两个阶段,而是在解决一类真实问题:既要允许祖先提前拦截,又要保留目标优先和向上委托的能力。捕获负责前者,冒泡负责后者,默认冒泡则是历史兼容和开发直觉共同选择的结果。
三、事件在哪个阶段触发?
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是绑定者,事件委托靠的就是这个差异。