skip to content
月与羽

DOM 事件流

/ 11 min read

1. 捕获阶段 (Capturing Phase)

这个阶段是事件从 DOM 树的顶端向目标元素传播的过程。

  • 行为:
    • 事件从 window 对象开始,依次经过 document<html><body>,然后逐级向下穿过目标元素的每一个祖先元素,直到到达目标元素的父元素为止。
    • 此阶段的主要任务是“探路”,为事件的传递确定路径。
    • 默认情况下,在此阶段注册的事件监听器不会被触发。浏览器只是默默地完成这个传播过程。
  • 如何利用这个阶段:
    • 如果你希望在捕获阶段就拦截并处理事件,你需要在调用 addEventListener 时,将第三个参数设置为 true
    • element.addEventListener('click', myFunction, true);
    • 这在某些高级应用场景中很有用,例如,你想在子元素处理事件之前,由父元素先进行某些统一的操作或阻止。 总结: 默认静默的向下传播过程。除非明确要求,否则此阶段不执行任何事件处理代码。

2. 目标阶段 (Target Phase)

这是事件传播的核心,事件到达了它最初被触发的源头。

  • 行为:
    • 事件已经到达并“停留”在目标元素上(例如,用户直接点击的那个按钮)。
    • 浏览器会检查该目标元素自身是否注册了对应类型的事件监听器(无论是通过捕获还是冒泡模式注册的)。
    • 如果目标元素上有监听器,它们就会被触发执行。这是事件处理的主要发生地。
    • 在事件对象中,event.target 始终指向这个阶段的元素。
  • 特殊说明:
    • 虽然概念上分为三个阶段,但在实际执行中,目标阶段没有明确的“开始”和“结束”界限。可以认为,当事件到达目标元素时,捕获阶段结束,冒泡阶段即将开始。所有在目标元素上注册的监听器都会在这个时间点被调用。 总结: 事件到达目的地,执行直接绑定在目标元素上的处理函数。

3. 冒泡阶段 (Bubbling Phase)

这是最常见、也是默认的事件处理阶段,事件从目标元素向 DOM 树的顶端回溯。

  • 行为:
    • 事件从 event.target(目标元素)开始,逐级向其父元素、祖父元素等上传播,一直回到 window 对象。
    • 在向上传播的每一步中,浏览器都会检查当前元素是否注册了对应类型的事件监听器(在默认的冒泡模式下)。
    • 如果存在,该监听器就会被触发。
    • addEventListener 的第三个参数默认为 false 或不写,就意味着在冒泡阶段处理事件。
    • element.addEventListener('click', myFunction, false);element.addEventListener('click', myFunction);
  • 如何利用这个阶段:
    • 这是事件委托 (Event Delegation) 的实现基础。通过在父元素上设置一个监听器,可以统一管理所有子元素的事件,因为子元素的事件最终会“冒泡”到父元素上。 总结: 默认的、从内向外的传播过程,逐级触发沿途的事件处理函数。

综合示例:观察三个阶段的行为

让我们用代码来直观地感受一下这三个阶段的行为和执行顺序。

<div id="parent">
父元素
<p id="child">子元素(点击这里)</p>
</div>
const parent = document.getElementById('parent');
const child = document.getElementById('child');
// 1. 在父元素上注册捕获阶段的监听器 (true)
parent.addEventListener('click', () => {
console.log('父元素 - 捕获阶段');
}, true);
// 2. 在父元素上注册冒泡阶段的监听器 (false)
parent.addEventListener('click', () => {
console.log('父元素 - 冒泡阶段');
}, false);
// 3. 在子元素(目标)上注册监听器
child.addEventListener('click', () => {
console.log('子元素 - 目标阶段');
}, false); // 这里的 true/false 对目标元素影响不大
// 4. 在 document 上也注册监听器,观察完整的流程
document.addEventListener('click', () => {
console.log('Document - 捕获阶段');
}, true);
document.addEventListener('click', () => {
console.log('Document - 冒泡阶段');
}, false);

当您点击 ” 子元素 ” 时,控制台的输出顺序将是: Document - 捕获阶段 父元素 - 捕获阶段 子元素 - 目标阶段 父元素 - 冒泡阶段 Document - 冒泡阶段 这个输出结果完美地展示了整个事件流:

  1. 向下捕获:事件从 document 走到 parent
  2. 到达目标:事件到达 child 并执行其处理函数。
  3. 向上冒泡:事件从 child(虽然它自己没有冒泡监听器)冒泡到 parent,再到 document

event.stopPropagation() 的作用

1. 防止“事件冒泡”导致的误触发(最常见)

这是最经典的使用场景:当你有一个父元素和一个子元素,两者都绑定了点击事件时,点击子元素会触发父元素的事件。

场景:点击列表项中的“删除”按钮

  • 点击整行(父元素)进入详情页。
  • 点击行内的“删除”按钮(子元素)弹出删除确认框。
  • 如果不使用 stopPropagation:点击删除按钮后,删除框弹出的同时,页面也会跳转到详情页(因为事件冒泡到了父行)。
deleteBtn.addEventListener('click', (e) => {
console.log('执行删除逻辑');
e.stopPropagation(); // 阻止事件向上冒泡,这样父级的跳转逻辑就不会触发
});
row.addEventListener('click', () => {
console.log('跳转到详情页');
});

2. 实现“点击空白处关闭”功能

在开发模态框(Modal)、下拉菜单(Dropdown)或气泡卡片(Popover)时,通常需要:点击组件内部不关闭,点击组件外部(页面其他地方)则关闭。

逻辑实现:

  1. windowdocument 绑定一个关闭弹窗的事件。
  2. 给弹窗容器绑定一个点击事件,里面调用 e.stopPropagation()
  3. 结果:点击弹窗内部时,事件被拦截,不会冒泡到 window,弹窗保持打开;点击弹窗外时,事件传给 window,执行关闭。
// 点击页面任何地方都尝试关闭
window.addEventListener('click', () => {
menu.hide();
});
// 点击菜单内部时,阻止事件传给 window
menuElement.addEventListener('click', (e) => {
e.stopPropagation();
console.log('在菜单内操作,不关闭菜单');
});

3. 嵌套交互组件的独立化

当你在一个复杂的 UI 组件内部嵌套了另一个具有交互能力的组件时。

场景:在轮播图(Swiper)上放置一个视频播放按钮

  • 轮播图本身有滑动切换的逻辑。
  • 视频按钮有点击播放的逻辑。
  • 如果不阻止冒泡,点击播放按钮可能会意外触发轮播图的切换或暂停逻辑。

4. 配合“事件委托”进行精细化控制

事件委托是指把子元素的事件监听器统一绑定在父元素上。但有时你希望某个特殊的子元素不参与这个委托逻辑。

场景:表格行委托

  • 父级 table 监听所有 td 的点击来高亮显示。
  • 其中一列是 input 输入框。
  • 你不希望点击 input 导致整行高亮,可以在 input 上阻止冒泡。

5. 性能优化(极少数情况)

如果你的 DOM 树非常深,且父级节点链上绑定了大量的、计算开销极大的事件监听器。通过在底层阻止冒泡,可以避免浏览器在每次点击时都去遍历和触发那一长串的父级处理器。


⚠️ 注意:不要滥用

虽然 stopPropagation 很好用,但不要在不必要的地方使用它

  • 破坏全局监控:如果你在很多地方都用了 stopPropagation,那么像 Google Analytics 或百度统计这类依赖事件冒泡来统计点击量的工具就会失效。
  • 难以调试:如果一个项目里到处是 stopPropagation,当你想在顶层做一些全局控制(比如全局快捷键、全局点击特效)时,会发现很多地方“点不动”,排查起来非常痛苦。

💡 提示: 如果你只想阻止浏览器默认行为(比如点击链接跳转、提交表单刷新),请使用 event.preventDefault(),这和阻止传播是两码事。