浏览器事件捕获、冒泡、事件委托和默认行为有什么区别?
简化版
DOM 事件通常经历捕获阶段、目标阶段和冒泡阶段。捕获从外到内,冒泡从内到外;事件委托利用冒泡把子元素事件交给父元素统一处理;stopPropagation 阻止传播,preventDefault 阻止默认行为,二者不是一回事。
详细版
事件流可以记成:
window -> document -> html -> body -> target -> body -> html -> document -> window
捕获阶段 目标阶段 冒泡阶段
addEventListener(type, handler, true) 或 { capture: true } 可在捕获阶段监听,默认在冒泡阶段监听。
事件委托常用于列表:
list.addEventListener('click', (e) => {
const item = e.target.closest('[data-id]')
if (!item) return
console.log(item.dataset.id)
})
event.target 是真实触发节点,event.currentTarget 是当前绑定监听器的节点。默认行为如链接跳转、表单提交、复选框切换,用 preventDefault() 阻止。
完整版教学
一、事件流为什么分阶段
DOM 是一棵树,用户点击一个按钮时,浏览器不仅要让按钮知道事件,也要让祖先节点有机会感知这次交互。于是事件流被设计成从外到内的捕获、到达目标、再从内到外冒泡。
<div id="card">
<button id="btn">提交</button>
</div>
点击按钮时,事件路径大致是:
window -> document -> html -> body -> #card -> #btn -> #card -> body -> html -> document -> window
捕获阶段适合提前拦截或做全局监控,冒泡阶段适合业务处理和事件委托。大多数前端业务都用冒泡阶段,因为它更符合“子元素触发、父容器统一处理”的习惯。
二、addEventListener 的 capture 决定监听阶段
addEventListener 第三个参数可以是布尔值,也可以是选项对象。默认 capture 为 false,表示冒泡阶段执行。
card.addEventListener('click', () => {
console.log('bubble card')
})
card.addEventListener('click', () => {
console.log('capture card')
}, { capture: true })
如果点击 card 内部按钮,捕获监听会在事件到达按钮之前执行,冒泡监听会在按钮阶段之后执行。输出顺序取决于具体绑定位置,但“外到内捕获、内到外冒泡”的方向不变。
| 阶段 | 方向 | 常见用途 |
|---|---|---|
| 捕获 | 外层到目标 | 全局拦截、埋点预处理 |
| 目标 | 目标节点 | 目标自身逻辑 |
| 冒泡 | 目标到外层 | 事件委托、业务处理 |
三、target 和 currentTarget 是高频陷阱
event.target 是真正触发事件的节点,event.currentTarget 是当前正在执行监听器的节点。事件委托里经常需要同时理解二者。
list.addEventListener('click', (e) => {
console.log(e.target) // 可能是 span、button、img
console.log(e.currentTarget) // 一定是 list
})
假设列表容器绑定了点击事件,用户点的是内部 <span>,那 target 是 span,currentTarget 是列表容器。要找业务项,通常用 closest() 向上找带 data-id 的节点。
const row = e.target.closest('[data-id]')
if (row && list.contains(row)) {
console.log(row.dataset.id)
}
这里的 list.contains(row) 是为了避免 closest 找到容器外的节点,尤其在复杂嵌套或弹层里更安全。
四、事件委托为什么能提升性能和维护性
事件委托利用冒泡,把很多子节点的监听器合并到父节点。它适合大量列表、动态节点和结构相似的交互。
// 1000 个按钮各自绑定
buttons.forEach(btn => btn.addEventListener('click', onClick))
// 委托给父容器
list.addEventListener('click', onClickList)
如果有 1000 行列表,每行 3 个按钮,逐个绑定可能创建 3000 个监听器;事件委托只需要父容器 1 个监听器。内存、绑定成本和动态增删节点的维护成本都会更低。
但委托不是万能的。focus、blur 等事件的冒泡行为特殊,鼠标移入移出也要小心子元素切换导致的误判。复杂交互中仍需要结合具体事件类型。
记忆钩子:委托的本质不是“少写代码”,而是借助冒泡把动态子节点的事件统一交给稳定父节点处理。
五、stopPropagation 和 preventDefault 不要混用
stopPropagation() 阻止事件继续传播,preventDefault() 阻止浏览器默认行为。它们解决的问题不同。
link.addEventListener('click', (e) => {
e.preventDefault() // 阻止 a 标签跳转
})
button.addEventListener('click', (e) => {
e.stopPropagation() // 阻止外层 card click 被触发
})
例如点击 <a href="/detail">,如果只调用 stopPropagation,链接仍可能跳转;如果只调用 preventDefault,外层监听器仍可能收到冒泡事件。面试里很多人把这两个 API 混为一谈。
还有 stopImmediatePropagation(),它会阻止事件继续传播,并阻止当前节点后续同类型监听器执行。它更强,业务代码里要谨慎使用。
六、默认行为、被动监听和滚动性能
浏览器某些事件有默认行为,例如链接跳转、表单提交、滚轮滚动、触摸滚动。为了提升滚动性能,浏览器对一些触摸或滚动相关监听器会推荐使用 passive。
window.addEventListener('touchmove', onMove, { passive: true })
passive: true 表示监听器不会调用 preventDefault(),浏览器可以更放心地并行处理滚动。如果你在 passive 监听器里调用 preventDefault(),通常不会生效。
数字例子:滚动时每帧只有约 16.7ms,如果浏览器必须等待 JS 判断是否阻止滚动,就可能造成触摸延迟。passive 的价值是给浏览器明确承诺。
七、常见误区与追问
- 误区:事件只有冒泡没有捕获。 DOM 事件通常有捕获、目标、冒泡三个阶段,只是业务默认常用冒泡。
- 误区:target 和 currentTarget 总是一样。 嵌套节点和事件委托中二者经常不同。
- 误区:stopPropagation 会阻止默认行为。 它只阻止传播,默认行为要用
preventDefault()。 - 追问:事件委托为什么适合动态列表? 新增子节点不需要单独绑定监听器,父容器统一处理即可。
- 追问:passive 监听有什么用? 告诉浏览器监听器不会阻止默认滚动,从而改善滚动响应。
- 追问:什么时候不用事件委托? 事件不冒泡、交互强依赖单节点状态、或委托判断过于复杂时不一定合适。
八、加强记忆
事件题按“三阶段、两目标、两阻止”记:三阶段是捕获、目标、冒泡;两目标是 target 和 currentTarget;两阻止是 stopPropagation 阻传播、preventDefault 阻默认行为。再补上事件委托和 passive,浏览器事件机制就讲完整了。