事件冒泡

我们已经知道网页是由元素组成的——标题、文本段落、图片、按钮等等——并且你可以监听这些元素上发生的事件。例如,你可以给一个按钮添加一个监听器,当用户点击这个按钮时它就会运行。

我们也看到这些元素可以相互嵌套:例如,一个<button>可以放在一个<div>元素里面。在这种情况下,我们称<div>元素为元素,<button>元素。

在本章中,我们将学习事件冒泡——当你给父元素添加事件监听器,而用户点击子元素时会发生什么。

预备知识 了解 HTMLCSS 基础,熟悉前面课程中介绍的 JavaScript 基础。
学习成果
  • 通过事件冒泡或事件捕获实现的事件委托。
  • 使用stopPropagation()阻止事件委托。
  • 从事件对象访问事件目标。

事件冒泡简介

让我们通过一个例子来介绍和定义事件冒泡。

在父元素上设置监听器

考虑这样一个网页

html
<div id="container">
  <button>Click me!</button>
</div>
<pre id="output"></pre>

这里按钮位于另一个元素,一个<div>元素内部。我们称这里的<div>元素是它所包含元素的父级。如果我们给父级添加一个点击事件处理程序,然后点击按钮,会发生什么?

js
const output = document.querySelector("#output");
function handleClick(e) {
  output.textContent += `You clicked on a ${e.currentTarget.tagName} element\n`;
}

const container = document.querySelector("#container");
container.addEventListener("click", handleClick);

你会看到当用户点击按钮时,父级会触发一个点击事件

You clicked on a DIV element

这是有道理的:按钮在<div>里面,所以当你点击按钮时,你也隐式地点击了它所在的元素。

冒泡示例

如果我们同时给按钮父元素添加事件监听器,会发生什么?

html
<body>
  <div id="container">
    <button>Click me!</button>
  </div>
  <pre id="output"></pre>
</body>

让我们尝试给按钮、它的父元素(<div>)以及包含它们的<body>元素添加点击事件处理程序

js
const output = document.querySelector("#output");
function handleClick(e) {
  output.textContent += `You clicked on a ${e.currentTarget.tagName} element\n`;
}

const container = document.querySelector("#container");
const button = document.querySelector("button");

document.body.addEventListener("click", handleClick);
container.addEventListener("click", handleClick);
button.addEventListener("click", handleClick);

你会看到当用户点击按钮时,所有三个元素都会触发一个点击事件

You clicked on a BUTTON element
You clicked on a DIV element
You clicked on a BODY element

在这种情况下

  • 首先触发按钮上的点击事件。
  • 接着触发其父级(<div>元素)上的点击事件。
  • 接着触发<div>元素的父级(<body>元素)上的点击事件。

我们把这种现象描述为事件从被点击的“最里层”元素冒泡到其父元素。

这种行为可能很有用,也可能导致意想不到的问题。在接下来的章节中,我们将看到它导致的一个问题,并找到解决方案。

视频播放器示例

在这个例子中,我们的页面包含一个视频,它最初是隐藏的,还有一个名为“显示视频”的按钮。我们希望实现以下交互:

  • 当用户点击“显示视频”按钮时,显示包含视频的框,但不要立即开始播放视频。
  • 当用户点击视频时,开始播放视频。
  • 当用户点击视频外部的框中任何位置时,隐藏该框。

HTML代码如下所示

html
<button>Display video</button>

<div class="hidden">
  <video>
    <source src="/shared-assets/videos/flower.webm" type="video/webm" />
    <p>
      Your browser doesn't support HTML video. Here is a
      <a href="rabbit320.mp4">link to the video</a> instead.
    </p>
  </video>
</div>

它包含

  • 一个<button>元素。
  • 一个<div>元素,它最初有一个class="hidden"属性。
  • 一个嵌套在<div>元素内的<video>元素。

我们正在使用 CSS 来隐藏设置了"hidden"类的元素。

JavaScript 代码如下所示

js
const btn = document.querySelector("button");
const box = document.querySelector("div");
const video = document.querySelector("video");

btn.addEventListener("click", () => box.classList.remove("hidden"));
video.addEventListener("click", () => video.play());
box.addEventListener("click", () => box.classList.add("hidden"));

这添加了三个'click'事件监听器

  • 一个在<button>上,它显示包含<video><div>
  • 一个在<video>上,它开始播放视频。
  • 一个在<div>上,它隐藏视频。

我们来看看它是如何工作的

你应该会看到,当你点击按钮时,视频框和其中包含的视频会显示出来。但是当你点击视频时,视频开始播放,但视频框又被隐藏了!

视频位于<div>内部——它是<div>的一部分——所以点击视频会同时运行两个事件处理程序,导致这种行为。

使用stopPropagation()解决问题

正如我们在上一节中看到的,事件冒泡有时会产生问题,但有一种方法可以阻止它。 Event 对象上有一个名为 stopPropagation() 的函数,当在事件处理程序内部调用时,它会阻止事件冒泡到任何其他元素。

我们可以通过将 JavaScript 更改为以下内容来解决当前问题

js
const btn = document.querySelector("button");
const box = document.querySelector("div");
const video = document.querySelector("video");

btn.addEventListener("click", () => box.classList.remove("hidden"));

video.addEventListener("click", (event) => {
  event.stopPropagation();
  video.play();
});

box.addEventListener("click", () => box.classList.add("hidden"));

我们在这里所做的只是在<video>元素的'click'事件的处理程序中调用事件对象上的stopPropagation()。这将阻止该事件冒泡到框。现在尝试点击按钮,然后点击视频

事件捕获

事件传播的另一种形式是事件捕获。这类似于事件冒泡,但顺序相反:事件不是首先在最内部的目标元素上触发,然后依次在嵌套较少的元素上触发,而是首先在最不嵌套的元素上触发,然后依次在嵌套更多的元素上触发,直到到达目标元素。

事件捕获默认是禁用的。要启用它,您必须在addEventListener()中传递capture选项。

这个例子与我们前面看到的冒泡例子非常相似,只是我们使用了capture选项

html
<body>
  <div id="container">
    <button>Click me!</button>
  </div>
  <pre id="output"></pre>
</body>
js
const output = document.querySelector("#output");
function handleClick(e) {
  output.textContent += `You clicked on a ${e.currentTarget.tagName} element\n`;
}

const container = document.querySelector("#container");
const button = document.querySelector("button");

document.body.addEventListener("click", handleClick, { capture: true });
container.addEventListener("click", handleClick, { capture: true });
button.addEventListener("click", handleClick);

在这种情况下,消息的顺序是相反的:<body>事件处理程序首先触发,接着是<div>事件处理程序,最后是<button>事件处理程序

You clicked on a BODY element
You clicked on a DIV element
You clicked on a BUTTON element

为什么要同时使用捕获和冒泡呢?在过去,当浏览器远不如现在兼容时,Netscape 只使用事件捕获,而 Internet Explorer 只使用事件冒泡。当 W3C 决定尝试标准化行为并达成共识时,他们最终采用了包含两者的系统,这也是现代浏览器所实现的。

默认情况下,几乎所有事件处理程序都注册在冒泡阶段,这在大多数情况下更有意义。

事件委托

在上一节中,我们探讨了事件冒泡导致的一个问题以及如何解决它。然而,事件冒泡不仅仅是烦人:它也可以非常有用。特别是,它实现了事件委托。在这种做法中,当我们希望在用户与大量子元素中的任何一个交互时运行一些代码时,我们将事件监听器设置在其父元素上,并让发生在子元素上的事件冒泡到它们的父元素,而不是不得不为每个子元素单独设置事件监听器。

让我们回到我们的第一个例子,在那里当用户点击一个按钮时,我们设置了整个页面的背景颜色。假设页面被分成16个瓷砖,并且我们希望当用户点击那个瓷砖时,将每个瓷砖设置为一个随机颜色。

这是HTML代码

html
<div id="container">
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
  <div class="tile"></div>
</div>

我们有一些CSS,用来设置瓷砖的大小和位置

css
#container {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-auto-rows: 100px;
}

现在在 JavaScript 中,我们可以为每个瓷砖添加一个点击事件处理程序。但是一个更简单、更高效的选择是将点击事件处理程序设置在父元素上,并依靠事件冒泡来确保当用户点击瓷砖时,该处理程序会被执行

js
function random(number) {
  return Math.floor(Math.random() * number);
}

function bgChange() {
  const rndCol = `rgb(${random(255)} ${random(255)} ${random(255)})`;
  return rndCol;
}

const container = document.querySelector("#container");

container.addEventListener("click", (event) => {
  event.target.style.backgroundColor = bgChange();
});

输出结果如下(尝试点击它)

注意:在这个例子中,我们使用event.target来获取事件的目标元素(即最内部的元素)。如果我们要访问处理此事件的元素(在此情况下是容器),我们可以使用event.currentTarget

注意:完整源代码请参见useful-eventtarget.html;也可在此处实时运行

targetcurrentTarget

如果您仔细观察本页中介绍的示例,您会发现我们正在使用事件对象的两个不同属性来访问被点击的元素。在在父元素上设置监听器中,我们使用event.currentTarget。然而,在事件委托中,我们使用event.target

区别在于,target指的是事件最初触发的元素,而currentTarget指的是附加此事件处理程序的元素。

当事件冒泡时,target保持不变,而对于层次结构中附加到不同元素的事件处理程序,currentTarget将不同。

如果我们将上面的冒泡示例稍作修改,就可以看到这一点。我们使用与之前相同的 HTML

html
<body>
  <div id="container">
    <button>Click me!</button>
  </div>
  <pre id="output"></pre>
</body>

JavaScript 代码几乎相同,只是我们同时记录了targetcurrentTarget

js
const output = document.querySelector("#output");
function handleClick(e) {
  const logTarget = `Target: ${e.target.tagName}`;
  const logCurrentTarget = `Current target: ${e.currentTarget.tagName}`;
  output.textContent += `${logTarget}, ${logCurrentTarget}\n`;
}

const container = document.querySelector("#container");
const button = document.querySelector("button");

document.body.addEventListener("click", handleClick);
container.addEventListener("click", handleClick);
button.addEventListener("click", handleClick);

请注意,当我们点击按钮时,每次target都是按钮元素,无论事件处理程序是附加到按钮本身、<div>还是<body>。然而,currentTarget标识了我们当前正在运行其事件处理程序的元素

target属性通常用于事件委托,如我们上面事件委托示例所示。

总结

您现在应该已经掌握了这个早期阶段关于 Web 事件所需的所有知识。如前所述,事件并非真正属于核心 JavaScript 语言的一部分——它们是在浏览器 Web API 中定义的。

在下一篇文章中,我们将为您提供一些测试,您可以用来检查您对我们提供的所有事件信息的理解和记忆程度。

另见

domevents.dev

一个有用的交互式游乐场应用程序,可以通过探索学习 DOM 事件系统的行为。

DOM 事件

一份关于理解和处理事件的全面指南。

事件顺序

Peter-Paul Koch 对捕获和冒泡的精彩详细讨论。