原文链接:https://javascript.info/bubbling-and-capturing,translate with ❤️ by zhangbao.

让我们从一个例子开始吧。

我们为 <div> 绑定了一个事件处理器,但是当点击内嵌标签 <em><code> 的时候,同样会触发 <div> 上的处理器。

  1. <div onclick="alert('处理器!')">
  2. <em>如果你点击 <code>EM</code>,会触发 <code>DIV</code> 上的事件处理器。</em>
  3. </div>

这不是有点奇怪吗?为什么我点击 <em>,会执行 <div> 上的事件处理器?

冒泡

冒泡原理很简单。

当一个事件发生在一个元素上时,它首先运行该元素上的处理程序,然后再运行它父元素上的,最后运行其他祖先上的。

我们举一个包含 3 层嵌套元素的例子 FROM > DIV > P,每个元素上面都绑定了 click 事件处理器。

  1. <style>
  2. body * {
  3. margin: 10px;
  4. border: 1px solid blue;
  5. }
  6. </style>
  7. <form onclick="alert('form')">FORM
  8. <div onclick="alert('div')">DIV
  9. <p onclick="alert('p')">P</p>
  10. </div>
  11. </form>

冒泡和捕获 - 图1

点击内部的 <p> 会执行 onclick

  1. 首先是 <p> 上的。

  2. 然后是外部的 <div>

  3. 然后是 <form>

  4. 一直到 document 对象。

冒泡和捕获 - 图2

就是说,当我们点击 <p> 的时候,会相继看到 3 个弹框:p → div → form

这个过程称为“冒泡”,因为事件从内部元素一直传播到最外部 document,这个过程像是像水里的泡泡往上冒。

冒泡和捕获 - 图3几乎所有事件都会冒泡

这里的关键词是“几乎”。

例如,focus 事件就不冒泡,当然还有其他的,之后会遇到。但这算是例外,而不是规则,多数事件都是会冒泡的。

event.target

父元素上的处理程序,始终可以获得内部实际发生事件位置的详细信息。

最里层直接触发事件的元素称为目标元素,可以通过 event.target 获得。

需要注意,它与 this (即 event.currentTarget)的不同:

  • event.target:表示产生事件的“目标”元素,在整个冒泡阶段都不会改变。

  • this:是指“当前”元素,即指绑定了事件处理程序的元素。

例如,如果我们有一个处理器 form.onclick,它就会“捕获”所有发生在其内部的点击事件。不管点击发生在哪里,最终都会冒泡到 <form> 上,并执行 form.onclick 中的代码逻辑。

form.onclick 处理程序中:

  • this(即 event.currentTarget) 是指 <form> 元素,因为是它绑定的事件处理器。

  • event.target 指表单中实际的被点击元素。

当然,event.target 也可能等于 this——当点击直接发生在 <form> 上的时候。

阻止冒泡

冒泡从目标元素开始向上传播。正常会一直传播到到 <html>,然后是 document 对象。有些事件甚至会到达 window 对象。在向上冒泡这一过程中,会触发绑定了此类事件元素上的所有处理器。

但在每一个处理器中,都可以阻止事件的进一步冒泡。

阻止事件冒泡,我们使用 event.stopPropagation()

下面例子中,如果我们在 <button> 上点击,就不会导致 body.onclick 处理器的调用:

  1. <body onclick="alert(`冒泡没有到达这里`)">
  2. <button onclick="event.stopPropagation()">点击我</button>
  3. </body>

冒泡和捕获 - 图4event.stopImmediatePropagation()

如果在一个元素上,为同一个事件类型绑定了多个事件处理器。如果其中一个中写了 event.stopPropagation(),这也不会阻止本元素上其他处理器的执行。

也就是说,event.stopPropagation() 阻止的是向上冒泡,不会阻止其他处理器的执行。

为了阻止事件冒泡和避免其他同事件类型的处理器执行,可以使用 event.stopImmediatePropagation()。调用之后,当前元素上的其他处理器就不会执行了。

冒泡和捕获 - 图5除非必须,请不要阻止事件冒泡

事件冒泡是有好处的。除非必须,请不要阻止事件冒泡。

有时候,使用 event.stopPropagation()`` 可能会制造隐藏的陷阱,在后期引起问题。

例如:

  1. 我们创建了一个嵌套菜单。每个子菜单都绑定了 click事件,并且在处理程序内部调用了 stopPropagation 避免触发外部菜单的 click`` 事件。

  2. 之后,如果我们想在 window上捕捉页面上发生的点击事件、跟踪用户行为(当用户点击时),一些分析系统就是通过这个原理做的,通常会写如 docuemnt.addEventListener('click'...) 这种形式。

  3. 如果我们使用 stopPropagation`` 阻止了点击事件的默认冒泡行为,势必导致分析系统存在“分析死区”。

一般情况下,并不需要真正阻止事件冒泡,貌似需要的场景或许也可以通过其他变通的方式解决。方式之一就是使用自定义事件,之后会讲到。而且,我们可以将自定义数据写入其中一个事件处理器的 event`` 对象里,然后在另一个里读取它,所以我们可以向父级元素传递之前事件的相关信息。

捕获

事件处理还有一个阶段叫“捕获”。在实际开发中,较少使用。

DOM 事件规范中描述了事件传播的 3 个阶段:

  1. 捕获阶段:事件从外向内传播到目标元素的过程。

  2. 目标阶段:事件到达目标元素。

  3. 冒泡阶段:事件从目标元素向外传播的过程。

下图展示了在点击表格中的 <td> 后,事件的传播过程(取自规范):

冒泡和捕获 - 图6

也就是:<td> 上发生点击(click)后,事件从祖先链一路向内传播到目标元素(即为捕获),然后到达目标元素,再向外传播(即为冒泡),在传播过程中调用绑定了 click 事件处理器的元素们。

因为捕获阶段很少使用,正常来说这个阶段对我们是不可见的。

通过 DOM on<事件名> 属性、HTML 特性或者 addEventListener(event, handler) 绑定的事件处理器,都是在第二、第三阶段执行的,对第一阶段(也就是捕获阶段)一无所知。

为了在捕获阶段触发事件处理器,需要将 addEventListener 的第三个参数显式声明为 true

第三个参数的意思是 useCapture,是一个布尔值,表示是否在捕获阶段触发事件处理器

  • 默认是 false,表示在冒泡阶段触发事件处理器。

  • true,表示在捕获阶段触发事件处理器。

请注意,虽然形式上有三个阶段,但第二阶段(即“目标阶段”)并没有单独处理的地方:它包含在捕获和冒泡阶段之中。

如果我们在同一个元素上,为同一类型事件,同时绑定了捕获和冒泡阶段的处理器。那么,在触发该事件时,目标阶段发生在捕获阶段之后、冒泡阶段之前。

我们来实际操作看下:

  1. <style>
  2. body * {
  3. margin: 10px;
  4. border: 1px solid blue;
  5. }
  6. </style>
  7. <form>FORM
  8. <div>DIV
  9. <p>P</p>
  10. </div>
  11. </form>
  12. <script>
  13. for(let elem of document.querySelectorAll('*')) {
  14. elem.addEventListener("click", e => alert(`捕获: ${elem.tagName}`), true);
  15. elem.addEventListener("click", e => alert(`冒泡: ${elem.tagName}`));
  16. }
  17. </script>

代码在文档中的每个元素上都绑定了 click 处理程序,以便观察事件传播的过程。

如果我们点击了 <p>,弹框顺序是这样的:

  1. HTML → BODY → FORM → DIV → P(捕获阶段,第一个监听器),然后是:

  2. P → DIV → FORM → BODY → HTML(冒泡阶段,第二个监听器)。

注意下,P 显示了两次:在捕获阶段的末尾和在冒泡阶段的开头。

还有一个属性 event.eventPhase 表示事件传播到当前的第几个阶段了。但它很少使用,因为我们通常在处理程序中就知道它。

总结

事件处理过程:

  • 当一个事件发生时:直接触发事件的最里层嵌套元素称为“目标元素”(event.target)。

  • 事件首先从根文档节点向下传播到 event.target,在整个过程中触发使用 addEventListener(...., true) 绑定的事件处理器。

  • 事件从 event.target 向上传播到根文档节点过程中,触发用 on<事件名>addEventListener 绑定的事件处理器(为提供第三个参数或第三个参数值为 false)。

每个事件处理器都接收一个事件对象,包含下列属性:

  • event.target:触发事件的目标元素。

  • event.currentTarget(即 this):绑定事件处理器的元素。

  • event.eventPhase:当前事件传播阶段(捕获是 1,冒泡时 3)。

任何事件处理程序都可以通过调用 event.stopPropagation() 来阻止冒泡,但这是不推荐的,因为我们不能确定祖先元素需不需要利用这个冒泡特性。

捕获阶段很少使用,通常我们都是处理冒泡阶段的事件。这背后有一个逻辑。

在现实世界中,当一场事故发生时,地方当局会首先做出反应。他们最了解当地情况。如果需要,则需要通知更高级别的当局。

事件处理程序也是如此。在特定元素上设置处理程序的代码知道有关元素及其功能的最大详细信息。特定 上的处理程序可能完全适合 ,它知道它的一切,所以它应该先获得机会(即先被调用)。然后它的直接父元素也了解上下文信息,但稍微少一点……直到最顶层的元素处理一般概念并运行最后一个处理器。

冒泡和捕捉是“事件委托”的基础——这是一种非常强大的事件处理模式,我们将在下一章学习。

(完)