Skip to content

一文读懂事件冒泡与事件捕获

💡 从例子入手

这是一个简单的 Demo,点击的 Display video 按钮后,将视频展示出来。

其中的视频 <video> 标签被 <div> 包裹,<div><video> 上都绑定了自己的 click 事件。

代码片段

我们的预期是:点击 <video> 时播放视频,点击 <div> 时隐藏视频,然而实际上你会发现,点击视频后,不仅视频虽然正常播放,但同时也被隐藏了。

点击子元素,父元素的事件也被触发,导致这种现象的原因正是:浏览器的事件冒泡机制

🤔 什么是事件冒泡机制?事件捕获又是什么?

现代浏览器提供了两种事件处理阶段:捕获阶段与冒泡阶段

bubbling-capturing.png

在捕获阶段:

  • 浏览器检查元素的最外层祖先 <html> ,是否在捕获阶段中注册了一个 onclick 事件处理程序,如果是,则运行它。
  • 然后,它移动到 <html> 中单击元素的下一个祖先元素,执行相同的操作,然后是单击元素再下一个祖先元素,依此类推,直到到达实际点击的元素。

在冒泡阶段,与上述顺序相反:

  • 浏览器检查实际点击的元素是否在冒泡阶段中注册了一个 onclick 事件处理程序,如果是,则运行它
  • 然后它移动到下一个直接的祖先元素,并做同样的事情,然后是下一个,等等,直到它到达 <html> 元素。

当一个事件被触发时,浏览器先运行捕获阶段,后运行冒泡阶段,并且在默认情况下,所有事件处理程序都在冒泡阶段进行注册

针对上面提到的问题,我们可以知道:当 <video> 点击事件触发后,虽然我们没有主动触发 <div> 上绑定的点击事件,但由于冒泡机制,点击事件冒泡到了 <div> 上,并触发了绑定在其上的监听回调函数,将 <video> 标签隐藏。

📌 用例子验证结论

下面是一个用于验证上述结论的Demo:

页面中包括由外向内的三个类名不同的div标签: div1 div2 div3,并为他们在捕获阶段/冒泡阶段分别绑定了不同的事件函数 clickdblclick

代码片段

当点击最内部的 div3 后,浏览器控制台输出:

> 捕获 click div1
> 捕获 click div2
> 捕获 click div3
> 冒泡 click div3
> 冒泡 click div2
> 冒泡 click div1

捕获阶段先执行,由外向内,冒泡阶段后执行,由内向外。 dblclick 事件并未被触发。

由此可知:

  • 事件触发 => 捕获阶段 => 冒泡阶段
  • 默认情况下,所有事件都在冒泡阶段被注册
  • 捕获阶段,浏览器由外层向内层逐个元素检查事件函数,如有则执行它。
  • 冒泡阶段,浏览器由内层向外层逐个元素检查事件函数,如有则执行它。
  • 子元素一个事件触发后,只有相同的事件会被捕获/冒泡检查

在本例中,通过为 addEventListener 函数指定第三个参数,从而在捕获阶段监听事件

js
target.addEventListener(type, listener, useCapture);

useCapturetrue 时,事件监听回调函数将在捕获阶段被触发。

🧐 为什么有两个阶段?它们有什么用?

📌 历史渊源

在过去,Netscape(网景)只使用事件捕获,而Internet Explorer只使用事件冒泡。当W3C决定尝试规范这些行为并达成共识时,他们最终得到了包括这两种情况(捕捉和冒泡)的系统,最终被应用在现代浏览器中。

📌 事件代理 (Event delegation)

利用捕获/冒泡机制,我们可以实现事件代理,什么是事件代理?

试想一下,此时有一个包含大量列表项的无序列表,我们希望每一个 <li> 的点击事件都能被监听并且添加特定的处理函数,然而我们不可能为每一个 <li> 都添加一次事件监听函数,这样效率太低了。

js
<ul>
  <li>Li.</li>
  <li>Li.</li>
  <li>Li.</li>
  <li>Li.</li>
  <li>Li.</li>
  <li>Li.</li>
  <li>Li.</li>
</ul>

这时,我们可以为最外层的 <ul> 绑定一个 click 事件的监听函数,利用捕获/冒泡机制,就可以在事件对象的 target 属性中拿到对应的 <li>

js
document.querySelector("ul").addEventListener("click", (e) => {
  console.log(e.target); // > li (实际点击的元素)
  console.log(e.currentTarget); // > ul (事件绑定的元素)
});

📌 事件对象中的targetcurrentTarget

在实际的使用中,你会发现事件对象中存在两个不同的属性:target currentTarget

它们有什么区别?和回调函数中的 this 的关系是怎样的?

复用上面验证捕获与冒泡顺序结论的例子,下面的代码片段验证了 targetcurrentTarget 的关系。

代码片段

当点击最内部的 div3 后,浏览器控制台输出:

> 捕获 target: div3 currentTarget: div1 this: div1
> 捕获 target: div3 currentTarget: div2 this: div2
> 捕获 target: div3 currentTarget: div3 this: div3
> 冒泡 target: div3 currentTarget: div3 this: div3
> 冒泡 target: div3 currentTarget: div2 this: div2
> 冒泡 target: div3 currentTarget: div1 this: div1

由此可知,事件对象中的 target 属性为实际触发事件的DOM元素,currentTarget 指向注册事件监听时绑定的DOM元素。

需要注意的是,为了验证 this 指向,此处使用了 function 声明函数替代前例中的 () => {},如果仍以箭头函数形式声明,则 this 始终指向 Window 对象。

🥳 如何阻止事件冒泡?

如你所见,大多数情况事件冒泡机制可以为我们带来便利,但是少数情况(如本文开头的例子)下,会影响预期的代码效果,我们应该如何阻止事件冒泡呢?

📌 .stopPropagation()

直接调用 e.stopPropagation() 阻止事件向上冒泡 触发其他回调

js
document.querySelector(".div1")((e) => {
  let e = e || window.event;
  // some code ...
  e.stopPropagation();
});

📌 e.target == e.currentTarget

当使用事件代理,给目标元素的父元素添加监听回调函数时添加判断

只有当实际触发元素与回调绑定的元素相同时,才触发相关逻辑

js
document.querySelector(".div1")((e) => {
  if (e.target == e.currentTarget) {
    // some code ...
  }
});

📌 return false

当回调内逻辑执行完毕后,直接 return false 可以中止事件向上冒泡

js
document.querySelector(".div1")((e) => {
  // some code ...
  return false;
});

需要注意的是,return false 的方法不仅阻止了事件冒泡,而且阻止了默认事件。

默认事件:DOM元素的默认行为,选中复选框是点击复选框的默认行为。下面这个例子说明了怎样阻止默认行为的发生

另一种阻止默认事件的方法是 .preventDefault()

js
document.querySelector(".div1")((e) => {
  // some code ...
  e.preventDefault()
});

相关链接

事件冒泡及捕获

Released under the MIT License.