先看如下代码,当点击样式为 small 的div后,控制台会输出什么?猜猜输出顺序:
html
<div class="large">
<div class="medium">
<div class="small">点我</div>
</div>
</div>
javascript
const large = document.querySelector('.large');
const medium = document.querySelector('.medium');
const small = document.querySelector('.small');
large.addEventListener('click', () => console.log('large 冒泡'));
large.addEventListener('click', () => console.log('large 捕获'), true);
medium.addEventListener('click', () => console.log('medium 冒泡'));
medium.addEventListener('click', () => console.log('medium 捕获'), true);
small.addEventListener('click', () => console.log('small 冒泡'));
small.addEventListener('click', () => console.log('small 捕获'), true);
输出结果是:
scss
large 捕获
medium 捕获
small 捕获
small 冒泡
medium 冒泡
large 冒泡
这个顺序揭示了 DOM 事件流的核心机制:先捕获,再冒泡。下面我们来一起看一看:

一、DOM 事件流三阶段
DOM 标准规定,当一个事件发生时,它在 DOM 树中的传播分为三个阶段:
- 捕获阶段(Capture Phase) :事件从
window出发,沿着 DOM 树从上到下,一路传播到目标元素的父节点。 - 目标阶段(Target Phase):事件到达事件源本身。
- 冒泡阶段(Bubble Phase) :事件从事件源开始,沿着 DOM 树从下往上冒泡,回到
window。
画成图就是:

关键点:无论你绑定不绑定事件处理函数,这个传播过程都会发生。它传播的是"事件",不是"处理函数"。你可以在传播路径的任意节点上拦截它。
二、addEventListener 的第三个参数
addEventListener 有三个参数:
javascript
element.addEventListener(event, handler, useCapture);
useCapture = false(默认值):事件处理函数在冒泡阶段触发。useCapture = true:事件处理函数在捕获阶段触发。
也可以写成选项对象的形式:
javascript
element.addEventListener('click', handler, {
capture: true, // 在捕获阶段触发
once: true, // 只触发一次后自动解绑
passive: true // 不会调用 preventDefault(),用于滚动性能优化
});
在 React 和 Vue 中,也有对应的写法:
| 冒泡阶段 | 捕获阶段 | |
|---|---|---|
| 原生 JS | addEventListener('click', fn) |
addEventListener('click', fn, true) |
| React | onClick={fn} |
onClickCapture={fn} |
| Vue | @click="fn" |
@click.capture="fn" |
三、事件冒泡
事件冒泡 是最常用的阶段。当一个元素触发事件后,事件会像水中的气泡一样,不断向上传递到父元素、再到祖父元素,一直到 document 和 window。
1. 冒泡示例
html
<div class="large" onclick="console.log('点击了 large')">
<div class="medium" onclick="console.log('点击了 medium')">
<div class="small" onclick="console.log('点击了 small')">
点我
</div>
</div>
</div>
点击最内层 small 后,控制台输出:
scss
点击了 small
点击了 medium
点击了 large
事件依次从 small → medium → large 向上冒泡,每一层绑定的事件处理函数都触发了。注意这里传递的是事件本身 (如 click 事件),而不是事件处理函数。父元素上有绑定 click 事件的,就会触发对应的处理函数;没绑定的,事件照样经过,只是什么都不发生。
2. 阻止冒泡
如果你希望只在 small 上触发事件,不让父元素也收到,可以用 e.stopPropagation():
javascript
document.querySelector('.small').addEventListener('click', (e) => {
e.stopPropagation(); // 阻止冒泡
console.log('点击了 small');
});
此时点击 small,只会输出 点击了 small,父元素的处理函数不会触发。
在 React 和 Vue 中阻止冒泡:
jsx
// React
<div onClick={(e) => {
e.stopPropagation();
console.log('只在这里触发');
}}>
html
<!-- Vue:使用 .stop 修饰符 -->
<div @click.stop="handleClick">点我</div>
四、事件捕获
事件捕获 和冒泡方向正好相反------事件从最外层 window 开始,沿 DOM 树逐层向下传播,直到事件源。
javascript
// 捕获阶段触发(第三个参数为 true)
large.addEventListener('click', () => console.log('large 捕获'), true);
medium.addEventListener('click', () => console.log('medium 捕获'), true);
small.addEventListener('click', () => console.log('small 捕获'), true);
点击 small,输出顺序:
scss
large 捕获
medium 捕获
small 捕获
捕获阶段适合做什么?
捕获阶段在日常开发中用得不如冒泡多,但它在以下场景中不可或缺:
场景一:全局埋点/数据上报(防止漏记)
假设页面中某些元素调用了 e.stopPropagation() 阻止了冒泡。如果在 document 上使用冒泡阶段做埋点监听,这些元素的点击事件就记录不到了。在捕获阶段监听,可以在事件到达目标之前就记录到,避免被阻止冒泡影响。
javascript
document.addEventListener('click', (e) => {
analytics.track('button_click', { target: e.target });
}, true); // 捕获阶段监听
场景二:监听不冒泡的事件
focus、blur 等事件默认不冒泡。如果你想在父容器上统一监听所有子元素的聚焦事件,只能在捕获阶段做:
javascript
// 在表单容器上统一监听所有输入框的聚焦事件
form.addEventListener('focus', (e) => {
console.log('输入框聚焦:', e.target.name);
}, true); // 必须用捕获,因为 focus 不冒泡
五、事件委托
假如一个列表有 1000 条数据,每条数据都有删除按钮。最直接的做法是给每个按钮都 addEventListener。但这样会创建 1000 个监听器实例,占用内存,而且新增或删除列表项时需要手动管理事件绑定/解绑。
事件委托的原理
事件委托 利用事件冒泡机制,只在父元素 上绑定一个事件处理函数,通过 e.target 判断实际触发事件的子元素是哪一个,再执行对应逻辑。
javascript
// 只在父容器上绑定一个事件
document.querySelector('.list').addEventListener('click', (e) => {
if (e.target.matches('.delete-btn')) {
console.log('删除:', e.target.dataset.id);
}
});
html
<ul class="list">
<li>项目 A <button class="delete-btn" data-id="1">删除</button></li>
<li>项目 B <button class="delete-btn" data-id="2">删除</button></li>
<li>项目 C <button class="delete-btn" data-id="3">删除</button></li>
<!-- 后续动态新增的 li 也一样有效 -->
</ul>
事件委托的优点
- 减少内存消耗:1000 个按钮只需要 1 个监听器,不需要循环绑定。
- 动态元素自动生效:后续新增或删除列表项,不需要手动绑定/解绑事件。
事件委托的缺点
- 依赖事件冒泡 :对
focus、blur等不冒泡的事件不适用。 - 可能被中途阻止 :如果某个子元素调用了
e.stopPropagation(),事件就到不了父元素,委托失效。 - 需要额外判断
e.target:代码中需要写匹配逻辑。 - 建议就近委托 :在
ul上代理li就好,不要在document上代理所有东西,避免不必要的处理函数调用。
事件委托在现代框架中
React 17+ 已经在框架层面做了事件委托------所有合成事件统一绑定在根节点(#root)上,通过内部的事件系统分发到具体组件。所以你在代码中写 onClick,React 并不会直接挂在真实 DOM 节点上。
Vue 的事件绑定虽然默认直接挂在真实 DOM 上,但在 v-for 循环中大量使用 @click 也不会有明显性能问题,因为 Vue 在模板编译阶段做了优化。
六、总结
JS事件流整个过程就像一块石头丢进水里,涟漪先向内收缩(捕获),然后从落点向外扩散(冒泡)。默认情况下,我们通过 addEventListener 或 React 的 onClick 绑定的事件,都是在冒泡阶段触发的。
这三个概念是日常开发中解决弹窗关闭、列表交互、性能优化等问题的基础。希望这篇文章能帮你一次性彻底搞懂它们。