事件冒泡和捕获怎样理解?阻止冒泡和阻止默认行为有什么区别?
点击一个按钮,事件先从 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 是冒泡阶段):
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,而不是直接切断传播。