4. 事件传播机制:捕获阶段、目标阶段、冒泡阶段
事件传播,说白了就是浏览器决定「谁先收到通知」的一套规则。
我记得刚入行那会儿,写了个点击弹窗关闭的功能。点击弹窗外部关闭,点击弹窗内部不关闭。结果怎么测都不对——点击弹窗内部,弹窗也关了。后来一查,原来是事件冒泡在捣鬼。从那以后,我彻底搞懂了捕获、目标和冒泡这三个阶段。
4.1 三个阶段,一个流程
一个事件从触发到结束,会经历三个顺序固定的阶段:
- 捕获阶段:事件从
window一路向下,经过每一层父节点,直到目标元素。 - 目标阶段:事件到达真正触发它的元素。
- 冒泡阶段:事件从目标元素原路返回,一层层向上冒到
window。
你想想看,这就像公司里发通知:先从上到下传达(捕获),到具体执行人手里(目标),再一层层汇报结果(冒泡)。
核心要点:addEventListener 的第三个参数控制监听器在哪个阶段触发。
false(默认)→ 冒泡阶段触发true→ 捕获阶段触发
4.2 代码验证一下
光说理论不够,咱们写段代码跑跑看。
<div id="parent">
<button id="child">点我</button>
</div>
<script>
const parent = document.getElementById('parent');
const child = document.getElementById('child');
// 捕获阶段监听
parent.addEventListener('click', () => {
console.log('父元素 - 捕获');
}, true);
child.addEventListener('click', () => {
console.log('目标元素');
}, true); // 目标阶段,第三个参数不影响
parent.addEventListener('click', () => {
console.log('父元素 - 冒泡');
}, false);
</script>
点击按钮后,控制台输出顺序是:
父元素 - 捕获目标元素父元素 - 冒泡
看到了吗?捕获先跑,目标接着跑,冒泡最后跑。顺序是死的,谁也改不了。
4.3 实际项目中的坑
我曾经在一个后台管理系统里,遇到一个诡异的 bug:点击表格里的删除按钮,结果触发了整行的点击事件,弹出了详情页。排查了半天,发现是事件冒泡把按钮的点击传到了行元素上。
避坑指南:如果你不想让事件继续传播,有两个方法:
event.stopPropagation()— 阻止后续阶段的传播event.stopImmediatePropagation()— 连当前元素上其他监听器也阻止
举个例子:
child.addEventListener('click', (e) => {
e.stopPropagation(); // 事件到此为止,不再冒泡
console.log('只触发我');
});
加了这行,点击按钮就不会触发父元素的冒泡监听了。但要注意,捕获阶段已经跑完了,stopPropagation 只能阻止后续阶段。
4.4 捕获阶段用得少,但很重要
说实话,日常开发中 90% 的情况只用冒泡阶段。但捕获阶段有几个特殊场景:
| 场景 | 说明 |
|---|---|
| 全局事件拦截 | 在捕获阶段提前处理,比如阻止所有点击 |
| 事件代理的变体 | 某些框架在捕获阶段做事件委托 |
| 特殊 UI 组件 | 比如拖拽、手势识别,需要捕获阶段提前感知 |
我的习惯:除非有明确需求,否则一律用冒泡阶段(第三个参数传 false 或不传)。捕获阶段容易打乱事件顺序,调试起来很头疼。
4.5 事件传播的完整路径
一个点击事件,到底经过了多少节点?我写了个小工具来验证:
document.addEventListener('click', (e) => {
let path = e.composedPath(); // 返回事件传播路径
console.log(path);
}, true);
composedPath() 返回一个数组,从 window 开始,到目标元素结束。你可以清晰地看到事件经过了哪些节点。
嗯,这里要注意:composedPath 在 Shadow DOM 场景下也能正确返回路径,这是 event.target 做不到的。
4.6 总结一下
事件传播机制,说白了就是浏览器帮你搭好的一条「事件传递路线」。捕获阶段从上到下,目标阶段精准命中,冒泡阶段从下到上。理解了这个顺序,你就能精准控制事件在哪个环节被处理。
我个人建议:能用冒泡解决的问题,别碰捕获。保持简单,代码才不容易出幺蛾子。