六维教程

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>

点击内层,控制台输出顺序如下。

外层 捕获
内层 捕获
内层 冒泡
外层 冒泡

捕获阶段从外向内经过外层和内层,目标阶段触发内层的监听,冒泡阶段再依次经过内层和外层。注意同一个元素上的捕获监听先执行,冒泡监听后执行。

事件传播的三个阶段

一次完整的点击事件经历三个阶段。

  1. 捕获阶段,从 document 向内走,到达目标元素的父元素。
  2. 目标阶段,事件到达目标元素本身。
  3. 冒泡阶段,从目标元素向外走,回到 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 可以阻止事件继续传播,让子元素的处理不被父元素干扰。

上一篇
JavaScript 事件对象
下一篇
JavaScript 事件委托