一文读懂事件冒泡与事件捕获
💡 从例子入手
这是一个简单的 Demo,点击的 Display video
按钮后,将视频展示出来。
其中的视频 <video>
标签被 <div>
包裹,<div>
与 <video>
上都绑定了自己的 click
事件。
我们的预期是:点击 <video>
时播放视频,点击 <div>
时隐藏视频,然而实际上你会发现,点击视频后,不仅视频虽然正常播放,但同时也被隐藏了。
点击子元素,父元素的事件也被触发,导致这种现象的原因正是:浏览器的事件冒泡机制。
🤔 什么是事件冒泡机制?事件捕获又是什么?
现代浏览器提供了两种事件处理阶段:捕获阶段与冒泡阶段,
在捕获阶段:
- 浏览器检查元素的最外层祖先
<html>
,是否在捕获阶段中注册了一个onclick
事件处理程序,如果是,则运行它。- 然后,它移动到
<html>
中单击元素的下一个祖先元素,执行相同的操作,然后是单击元素再下一个祖先元素,依此类推,直到到达实际点击的元素。
在冒泡阶段,与上述顺序相反:
- 浏览器检查实际点击的元素是否在冒泡阶段中注册了一个
onclick
事件处理程序,如果是,则运行它- 然后它移动到下一个直接的祖先元素,并做同样的事情,然后是下一个,等等,直到它到达
<html>
元素。
当一个事件被触发时,浏览器先运行捕获阶段,后运行冒泡阶段,并且在默认情况下,所有事件处理程序都在冒泡阶段进行注册。
针对上面提到的问题,我们可以知道:当 <video>
点击事件触发后,虽然我们没有主动触发 <div>
上绑定的点击事件,但由于冒泡机制,点击事件冒泡到了 <div>
上,并触发了绑定在其上的监听回调函数,将 <video>
标签隐藏。
📌 用例子验证结论
下面是一个用于验证上述结论的Demo:
页面中包括由外向内的三个类名不同的div标签: div1
div2
div3
,并为他们在捕获阶段/冒泡阶段分别绑定了不同的事件函数 click
和 dblclick
。
当点击最内部的 div3
后,浏览器控制台输出:
> 捕获 click div1
> 捕获 click div2
> 捕获 click div3
> 冒泡 click div3
> 冒泡 click div2
> 冒泡 click div1
捕获阶段先执行,由外向内,冒泡阶段后执行,由内向外。 dblclick
事件并未被触发。
由此可知:
- 事件触发 => 捕获阶段 => 冒泡阶段
- 默认情况下,所有事件都在冒泡阶段被注册
- 捕获阶段,浏览器由外层向内层逐个元素检查事件函数,如有则执行它。
- 冒泡阶段,浏览器由内层向外层逐个元素检查事件函数,如有则执行它。
- 子元素一个事件触发后,只有相同的事件会被捕获/冒泡检查
在本例中,通过为 addEventListener
函数指定第三个参数,从而在捕获阶段监听事件
target.addEventListener(type, listener, useCapture);
当 useCapture
为 true
时,事件监听回调函数将在捕获阶段被触发。
🧐 为什么有两个阶段?它们有什么用?
📌 历史渊源
在过去,Netscape(网景)只使用事件捕获,而Internet Explorer只使用事件冒泡。当W3C决定尝试规范这些行为并达成共识时,他们最终得到了包括这两种情况(捕捉和冒泡)的系统,最终被应用在现代浏览器中。
📌 事件代理 (Event delegation)
利用捕获/冒泡机制,我们可以实现事件代理,什么是事件代理?
试想一下,此时有一个包含大量列表项的无序列表,我们希望每一个 <li>
的点击事件都能被监听并且添加特定的处理函数,然而我们不可能为每一个 <li>
都添加一次事件监听函数,这样效率太低了。
<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>
。
document.querySelector("ul").addEventListener("click", (e) => {
console.log(e.target); // > li (实际点击的元素)
console.log(e.currentTarget); // > ul (事件绑定的元素)
});
📌 事件对象中的target
与currentTarget
在实际的使用中,你会发现事件对象中存在两个不同的属性:target
currentTarget
。
它们有什么区别?和回调函数中的 this
的关系是怎样的?
复用上面验证捕获与冒泡顺序结论的例子,下面的代码片段验证了 target
和 currentTarget
的关系。
当点击最内部的 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()
阻止事件向上冒泡 触发其他回调
document.querySelector(".div1")((e) => {
let e = e || window.event;
// some code ...
e.stopPropagation();
});
📌 e.target == e.currentTarget
当使用事件代理,给目标元素的父元素添加监听回调函数时添加判断
只有当实际触发元素与回调绑定的元素相同时,才触发相关逻辑
document.querySelector(".div1")((e) => {
if (e.target == e.currentTarget) {
// some code ...
}
});
📌 return false
当回调内逻辑执行完毕后,直接 return false
可以中止事件向上冒泡
document.querySelector(".div1")((e) => {
// some code ...
return false;
});
需要注意的是,return false
的方法不仅阻止了事件冒泡,而且阻止了默认事件。
默认事件:DOM元素的默认行为,选中复选框是点击复选框的默认行为。下面这个例子说明了怎样阻止默认行为的发生
另一种阻止默认事件的方法是 .preventDefault()
document.querySelector(".div1")((e) => {
// some code ...
e.preventDefault()
});