前端知库

事件冒泡和捕获怎样理解?阻止冒泡和阻止默认行为有什么区别?

点击一个按钮,事件先从 window 往下传到按钮(捕获),再从按钮往上传回 window(冒泡),监听器默认在冒泡阶段执行。stopPropagation() 让事件不再传到其他节点,stopImmediatePropagation() 连同一节点上后面的监听器也拦住,preventDefault() 只取消浏览器的默认动作,三者互不替代。

参考答案

简答

页面上有一个 div 包着一个按钮,两者都监听了 click。点击按钮时,事件先从 window 一层层往下传,经过 div 到达按钮,这段叫捕获;然后再从按钮一层层往上传回 window,这段叫冒泡。addEventListener 默认在冒泡阶段执行,所以通常是按钮的监听器先执行、div 的后执行;第三个参数传 true 或 { capture: true },就改在捕获阶段执行,外层会先拿到事件。

阻止冒泡和阻止默认行为管的是两件事。stopPropagation() 让事件不再传给后面的节点,但当前节点上的其他监听器照常执行;stopImmediatePropagation() 连当前节点上后面的监听器也不执行。preventDefault() 不管传播,只取消浏览器自带的动作,比如链接跳转、表单提交、复选框勾选。

所以想让点击链接不跳转,要用 preventDefault(),只调 stopPropagation() 链接照样跳;反过来,调了 preventDefault(),外层的监听器仍然收得到事件。

要注意两个边界。不是所有事件都冒泡,focus、blur、mouseenter、load 都不冒泡,自己 new Event() 创建的事件默认也不冒泡。在 passive: true 的监听器里调 preventDefault() 不起作用。

先看事件按什么顺序传

下面在 #outer 和里面的按钮上各挂两个监听器,一个在捕获阶段,一个在冒泡阶段,并打印 eventPhase(1 是捕获阶段,2 是到达目标,3 是冒泡阶段):

JavaScript
outer.addEventListener('click', (e) => log('outer 捕获', e.eventPhase), true);
btn.addEventListener('click', (e) => log('btn 冒泡监听器', e.eventPhase));
btn.addEventListener('click', (e) => log('btn 捕获监听器', e.eventPhase), true);
outer.addEventListener('click', (e) => log('outer 冒泡', e.eventPhase));

btn.click();
// outer 捕获 1
// btn 捕获监听器 2
// btn 冒泡监听器 2
// outer 冒泡 3

这段在 Chrome 里实际运行过。outer 的捕获监听器最先执行,冒泡监听器最后执行,这符合“先往下、再往上”。

可是按钮上的两个监听器有点意外:冒泡监听器是先注册的,却后执行。原因是按钮本身就是目标,它的两个监听器都在“到达目标”阶段执行,eventPhase 都是 2。按现在的 DOM 规范,目标节点上也是先执行捕获监听器,再执行非捕获监听器,同一类里才按注册顺序。一些老文章说“目标节点上按注册顺序执行”,那是规范调整之前的旧说法,以现在的规范和浏览器行为为准。

三个方法分别拦住了什么

stopPropagation() 拦的是“传到下一个节点”。MDN 说它阻止当前事件在捕获和冒泡阶段继续传播,但不阻止默认行为,也不阻止当前元素上的其他监听器。在 Chrome 里实测:中间那层 div 上有两个冒泡监听器,第一个调了 stopPropagation(),第二个照样执行,再外层的监听器就收不到了。

stopImmediatePropagation() 拦得更彻底。MDN 说,同一个元素上的多个监听器按添加顺序调用,其中一个调用了它,剩下的监听器都不会执行,这个元素上的和其他元素上的都不会。

preventDefault() 和传播没有关系。它告诉浏览器“这个事件的默认动作不要做”。在 Chrome 里实测:给 href="#jump" 的链接调用 preventDefault(),地址的 hash 没有变;给复选框调用,点击后 checked 仍然是 false;而外层 div 的监听器两次都收到了点击。可以用 event.defaultPrevented 判断某个事件是否已被取消。

哪些情况下 preventDefault() 不起作用

第一种是事件本身不能取消。MDN 说,对 cancelable 为 false 的事件调用 preventDefault() 没有效果,比如 load 和媒体的 play 事件。自己用 new Event('x') 创建事件时,cancelable 和 bubbles 默认都是 false,要显式传 { cancelable: true }。

第二种是监听器声明了 passive: true。DOM 规范规定,执行 passive 监听器时会标记“正在 passive 监听器中”,这期间调 preventDefault() 不会设置取消标记。在 Chrome 里实测,在 passive 的 wheel 监听器里调用后,defaultPrevented 仍然是 false。MDN 提到浏览器可能会在控制台给出警告。passive 的作用和默认值见《addEventListener 的 capture、once、passive 和 signal 有什么作用?》。

哪些事件不冒泡

按 MDN 各事件页面的说明,focus 和 blur 不冒泡,紧跟着触发的 focusin 和 focusout 会冒泡;mouseenter、mouseleave 不冒泡,mouseover、mouseout 会冒泡;load 和媒体元素的 play 不冒泡也不能取消。

不冒泡不等于外层完全拿不到。捕获阶段仍然会经过外层,所以在 document 上用捕获方式监听 focus 能收到。在 Chrome 里实测,给一个输入框派发 focus,document 上的捕获监听器执行了,冒泡监听器没有执行。这一点在做事件委托时很有用,委托的写法见《事件委托是什么?target 和 currentTarget 有何区别?》。

常见追问

return false 能同时阻止冒泡和默认行为吗?

在原生 addEventListener 的监听器里,返回值会被忽略。写在 onclick 这种事件处理属性里时,return false 会取消默认行为,但不会阻止冒泡。在 Chrome 里实测,onclick="return false" 的链接没有跳转,外层 div 仍然收到了点击。某些库会让 return false 同时阻止冒泡,那是库自己的约定。

什么时候需要在捕获阶段监听?

想比内层元素更早拿到事件时。比如点击页面任意位置关闭弹层,内层组件可能调了 stopPropagation(),在冒泡阶段就收不到了,改在捕获阶段监听就能先拿到。另一个场景是监听不冒泡的事件,比如在外层统一处理 focus。

随便调用 stopPropagation() 有什么问题?

它会让外层的监听器全部收不到这个事件,包括事件委托、埋点统计、点击外部关闭弹层这类逻辑,而且这类问题很难排查。如果只是想让外层不处理,可以在外层判断 event.target 或检查 defaultPrevented,而不是直接切断传播。