JavaScript 事件冒泡与捕获
上一篇文章提到事件会传导,但没有展开讲。事件从发生到结束,会在文档里走一条完整的路径,这条路径包括捕获阶段、目标阶段和冒泡阶段。理解这条路径,是理解事件机制的关键。
事件冒泡
先看冒泡,它是默认行为。事件从触发它的目标元素开始,一层一层向外触发到祖先元素,直到 document。就像水底的泡泡,从底部慢慢升到水面。
三层嵌套的 div,每一层都绑定 click,点击最内层看会发生什么。
<div id="outer" style="padding: 20px; background: #f99;">
外层
<div id="middle" style="padding: 20px; background: #9f9;">
中层
<div id="inner" style="padding: 20px; background: #99f;">
内层
</div>
</div>
</div>
<script>
const outer = document.getElementById('outer')
const middle = document.getElementById('middle')
const inner = document.getElementById('inner')
outer.addEventListener('click', () => console.log('外层被触发'))
middle.addEventListener('click', () => console.log('中层被触发'))
inner.addEventListener('click', () => console.log('内层被触发'))
</script>
点击最内层,控制台依次输出内层被触发、中层被触发、外层被触发。内层先触发,接着向上传导给中层和外层,这就是冒泡。
事件捕获
捕获与冒泡方向相反,从 document 开始,一层一层向内到达目标元素。默认的监听不参与捕获阶段,需要在 addEventListener 的第三个参数传 true 才开启捕获。
<div id="outer" style="padding: 20px; background: #f99;">
外层
<div id="inner" style="padding: 20px; background: #99f;">
内层
</div>
</div>
<script>
const outer = document.getElementById('outer')
const inner = document.getElementById('inner')
outer.addEventListener('click', () => console.log('外层捕获'), true)
outer.addEventListener('click', () => console.log('外层冒泡'))
inner.addEventListener('click', () => console.log('内层触发'))
</script>
点击内层,控制台输出顺序是外层捕获、内层触发、外层冒泡。外层传了 true,所以先在捕获阶段执行,然后事件到达目标,最后回到冒泡阶段。
addEventListener 第三个参数默认为 false,表示只在冒泡阶段触发。传 true 表示在捕获阶段触发。
完整路径验证
把捕获和冒泡同时打开,就能看到事件走完整条路径。三层嵌套的 div 各绑两个监听,一个捕获一个冒泡。
<div id="outer" style="padding: 20px; background: #f99;">
外层
<div id="inner" style="padding: 20px; background: #99f;">
内层
</div>
</div>
<script>
const outer = document.getElementById('outer')
const inner = document.getElementById('inner')
const log = (name) => console.log(name)
outer.addEventListener('click', () => log('外层 捕获'), true)
outer.addEventListener('click', () => log('外层 冒泡'))
inner.addEventListener('click', () => log('内层 捕获'), true)
inner.addEventListener('click', () => log('内层 冒泡'))
</script>
点击内层,控制台输出顺序如下。
外层 捕获
内层 捕获
内层 冒泡
外层 冒泡
捕获阶段从外向内经过外层和内层,目标阶段触发内层的监听,冒泡阶段再依次经过内层和外层。注意同一个元素上的捕获监听先执行,冒泡监听后执行。
事件传播的三个阶段
一次完整的点击事件经历三个阶段。
- 捕获阶段,从 document 向内走,到达目标元素的父元素。
- 目标阶段,事件到达目标元素本身。
- 冒泡阶段,从目标元素向外走,回到 document。
整个顺序是先捕获阶段(外到内),再目标阶段,再冒泡阶段(内到外)。用一张图表示更直观。
捕获阶段(外到内) 目标阶段 冒泡阶段(内到外)
document → 外层 → 内层 内层 内层 → 外层 → document
目标阶段只经过目标元素本身。如果监听绑定在祖先元素上,事件到达祖先时既经过了捕获阶段也经过了冒泡阶段,绑定在哪个阶段决定它何时执行。同一元素上如果同时有捕获监听和冒泡监听,捕获监听先执行,冒泡监听后执行。大多数开发只关心冒泡阶段,捕获阶段常用于需要抢先处理事件的场景。
阻止传播 stopPropagation
冒泡会把事件传给所有祖先元素,有时这不是想要的。点击某个子元素时只希望它自己的监听生效,可以调用 e.stopPropagation 阻止事件继续向上传播。
<div id="box" style="padding: 20px; background: #eee;">
父容器
<button id="btn">点我</button>
</div>
<script>
const box = document.getElementById('box')
const btn = document.getElementById('btn')
box.addEventListener('click', () => console.log('父容器被触发'))
btn.addEventListener('click', (e) => {
e.stopPropagation()
console.log('按钮被触发')
})
</script>
点击按钮,控制台只输出按钮被触发,父容器的监听没有执行。如果没有 stopPropagation,按钮和父容器都会触发。
需要留意的是,stopPropagation 只阻止传播路径,不阻止其他监听。同一个元素上绑定的其他事件仍会执行。要连默认行为一起阻止,需要配合 preventDefault,两者职责不同。
小结
事件传播包括三个阶段,捕获从外到内,目标在中间,冒泡从内到外。默认监听在冒泡阶段触发,addEventListener 第三个参数传 true 可以改为捕获阶段触发。stopPropagation 可以阻止事件继续传播,让子元素的处理不被父元素干扰。