事件冒泡

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

我们还了解到这些元素可以彼此嵌套:例如,一个 <button> 可以放在一个 <div> 元素内部。在这种情况下,我们将 <div> 元素称为元素,将 <button> 称为元素。

在本节中,我们将了解当你向父元素添加事件监听器,而用户点击子元素时会发生什么。

介绍事件冒泡

在父元素上设置监听器

考虑这样一个网页

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="https://interactive-examples.mdn.mozilla.net/media/cc0-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
.tile {
  height: 100px;
  width: 25%;
  float: left;
}

现在在 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);

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

target 属性通常在事件委托中使用,就像我们上面 事件委托 示例中一样。

测试您的技能!

你已经读到这篇文章的末尾,但你能记住最重要的信息吗?在你继续学习之前,请验证你是否已记住这些信息 - 请查看 测试你的技能:事件.

结论

现在,你应该已经了解了在这个早期阶段你需要知道的关于 Web 事件的所有知识。正如所述,事件实际上并不属于核心 JavaScript - 它们是在浏览器 Web API 中定义的。

此外,重要的是要理解 JavaScript 使用的不同上下文具有不同的事件模型 - 从 Web API 到其他领域,例如浏览器 WebExtensions 和 Node.js(服务器端 JavaScript)。我们不希望你现在了解所有这些领域,但了解事件的基础知识将有助于你在学习 Web 开发的道路上不断前进。

注意:如果你遇到困难,可以在我们的 沟通渠道 中联系我们。

另请参阅

  • domevents.dev - 一个非常有用的交互式游乐场应用程序,它允许你通过探索来了解 DOM 事件系统的行为。
  • 事件参考
  • 事件顺序(有关捕获和冒泡的讨论) - 彼得·保罗·科赫写的一篇非常详细的文章。