先记住这个答案
DOM 事件先捕获:从 Window 经 Document、html 一路向下到目标父元素,期间触发 capture 监听器;然后到达目标,进入目标阶段,目标上注册的捕获与冒泡监听器都会触发,顺序按注册先后;最后若 bubbles 为 true 则冒泡,从目标父级向上回到 Window,触发冒泡监听器。用 addEventListener 第三个参数控制:true 为捕获,false 或不传为冒泡。event.eventPhase 可读出当前处于 1、2、3 哪个阶段。
- 顺序:捕获向下、目标、冒泡向上
- capture:true 在阶段1触发,默认在阶段3
- 目标上两类监听器都触发,按注册顺序
三阶段的传播路径与监听器归属
浏览器派发事件时构造一条从 Window 到目标的传播路径。捕获阶段沿路径向下,依次经过 Document、html、body 直到目标的父元素,此阶段只触发用 addEventListener(type, fn, true) 注册的监听器。eventPhase 此时为 1,可通过 Event.CAPTURING_PHASE 常量比对。
到达目标后进入目标阶段,eventPhase 为 2。关键细节是:在目标元素自身上,捕获监听器和冒泡监听器都会触发,不再区分阶段方向,而是按照各自注册的先后顺序依次执行。之后若事件 bubbles 为 true,进入冒泡阶段(eventPhase 为 3),默认注册的监听器(第三个参数 false 或省略)在从目标父级回到 Window 的过程中逐个触发。
嵌套点击时确认父级拦截先于子级处理
一个弹层组件中,结构为 overlay > panel > button。需求是点击 button 前先让 overlay 检查登录态,未登录则不再向下分发。输入是一次真实 click,约束是 overlay 的检查必须先于 button 的业务逻辑执行。决策:overlay 用 addEventListener('click', check, true) 注册在捕获阶段。
结果:事件沿 overlay→panel→button 捕获时,check 最先运行,eventPhase 为 1 且 currentTarget 是 overlay;通过校验后 button 的默认冒泡监听器在其目标阶段执行。若误把 overlay 监听器注册成冒泡,执行顺序会反过来,button 先触发,拦截失效。这验证了监听器触发阶段由注册时的 capture 标志决定,而非元素层级。
该模型失效或需注意的条件
并非所有事件都有冒泡阶段:focus、blur、mouseenter 等 bubbles 为 false,事件在目标阶段结束后即终止,冒泡监听器永远不会触发。若依赖冒泡在祖先上统一处理这类事件,代码静默失效,须改用捕获注册或换成 focusin 等可冒泡变体。
另一个边界是监听器调用 stopPropagation 后传播路径被截断,后续路径上的节点(如尚未经过的捕获或冒泡节点)监听器不会触发,但同一元素上尚未执行的监听器仍可能运行。目标阶段的细节也仅对同一元素成立。排查时应先读 event.eventPhase 与 event.bubbles 确认事件实际走到了哪一步,再推断监听器为何未触发,而不是假设三阶段一定完整走完。
容易答错的地方
- 认为捕获监听器在目标上不触发
- 捕获与冒泡的区分只在祖先路径上;事件到达目标进入 AT_TARGET 阶段后,目标上注册的捕获和冒泡监听器都会执行,顺序由注册先后决定,这是面试最易答错的点。
- 认为所有事件都完整走完三阶段
- 只有 bubbles 为 true 的事件才有冒泡阶段。focus、blur、load 等事件到目标阶段就结束,祖先上的冒泡监听器收不到。判断时应查事件类型的 bubbles 属性而非默认假设。
面试官还会怎么问?
如何知道监听器当前处于哪个阶段?
读回调参数 event.eventPhase:1 表示捕获,2 表示目标,3 表示冒泡,0 表示未在派发中。可与 Event.CAPTURING_PHASE 等常量比较,常用于调试传播顺序问题。
目标元素上两个监听器谁先执行?
目标阶段不区分捕获或冒泡,两者都触发,执行顺序按 addEventListener 调用先后。只有离开目标元素后,capture 标志才重新决定监听器属于哪个传播阶段。
window 上的监听器什么时候触发?
window 作为顶层祖先,在捕获阶段最先触发监听器(若注册为捕获),在冒泡阶段最后触发(若注册为冒泡)。因此 window 上注册的捕获监听器总是先于页面内其它元素执行,但若要阻止事件继续传播,仍需在回调内调用 stopPropagation()。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。