前言
作为一名前端开发工作者,肯定绕不开页面的事件流。刚入门的程序员,可能会因为在页面中点击事件触发了父元素或者子元素事件而苦恼,却往往不了解其中的机制。这一篇来简单讲一讲事件捕获和事件冒泡。
解决什么?
追究历史,事件冒泡
和事件捕获
分别由微软
和网景
公司提出,目的在于解决页面中的事件流问题,即元素间事件触发的时序。当然,分久必合,在微软和网景之间火热争论之后,最后采用了 W3C 的折中方案——先捕获后冒泡。当然这个会在下文中详细的讲。
思考问题
首先从一个小例子进行引入,假设有一个页面,结构上从外到内分别是:
-
div #container
-
div #btnContainer
-
button #btn
<div id="container">
<div id="btnContainer">
<button id="btn">点击按钮</button>
</div>
</div>
这三个元素呈嵌套关系,我们分别为三个元素赋予点击事件:
const container = document.getElementById('container');
const btnContainer = document.getElementById('btnContainer');
const btn = document.getElementById('btn');
// window 绑定事件
window.addEventListener('click', () => {
console.log('win')
})
document.addEventListener('click', () => {
console.log('doc');
});
container.addEventListener('click', () => {
console.log('container');
});
btnContainer.addEventListener('click', () => {
console.log('btnContainer');
});
btn.addEventListener('click', () => {
console.log('btn');
});
那么,点击按钮button会打印什么样的信息呢?
通过浏览器打开页面,我们可以看到,控制台上打印的 btn -> btnContainer -> container -> doc -> win 。
这说明,浏览器的事件触发会有一个传递过程,并且父子相对之间是有顺序的。上面的例子所呈现的现象,正是事件冒泡。
事件冒泡
事件冒泡很好理解,简单理解正如水里冒泡一般,从内往外冒。在我们的程序中,事件冒泡会从我们的 target
对象,即当前操作的对象,开始一层一层往外走,直到 document
,最后是 window
对象。根据上面的例子画成流程图如下:
@flowstart
stage1=>operation: btn
stage2=>operation: btnContainer
stage3=>operation: container
stage4=>operation: document
stage5=>operation: window
stage1->stage2->stage3->stage4->stage5
@flowend
事件捕获
事件捕获正好和事件冒泡是相反的,事件冒泡在于往外走,而事件捕获则是从 window
对象开始,一层层往内走,直到当前 target
对象上。
相应的,我们的流程图是这样:
@flowstart
stage5=>operation: btn
stage4=>operation: btnContainer
stage3=>operation: container
stage2=>operation: document
stage1=>operation: window
stage1->stage2->stage3->stage4->stage5
@flowend
EventTarget.addEventListener
了解了事件捕获和事件冒泡之后,我们来讲一讲如何处理事件。
了解 API
由于在页面中,事件呈现流一样的形式层层传递,因此我们需要在某一阶段中进行事件的处理。首先看看 EventTarget.addEventListener
方法:
target.addEventListener(type, listener [, useCapture]);
可以看见,该方法接受三个参数,分别是事件类型、侦听器和是否使用事件捕获。
其中, useCapture
默认是 false,即事件句柄在冒泡阶段执行,因此在上文的例子中,我们可以清楚看到,控制台打印的顺序是从 btn
开始,直到 window
结束。
假如我们把上文例子中的 useCapture
都设置为 true, 那我们会看到的是这样的结果:
预料之中,这正好和冒泡是相反的。
先捕获后冒泡
事实上事件流是先进行捕获再进行冒泡的,我们常常会把事件处理分为三个阶段,即 事件捕获->目标阶段->事件冒泡。
简单举个例子:
const container = document.getElementById('container');
const btnContainer = document.getElementById('btnContainer');
const btn = document.getElementById('btn');
window.addEventListener('click', () => {
console.log('win')
})
document.addEventListener('click', () => {
console.log('doc')
})
container.addEventListener('click', () => {
console.log('container');
}, true);
btnContainer.addEventListener('click', () => {
console.log('btnContainer');
});
btn.addEventListener('click', () => {
console.log('btn');
});
如上,我们给 container
绑定事件设置了使用捕获事件,打印结果变成了:
很明显看到,container
被打印在了第一个位置,这正好也验证了先捕获后冒泡的规则。
阻止传播
有时候,我们只想要触发当前元素即可,并不希望把事件传播到上层或者下层元素,那么这个时候我们就需要设置阻止传播了,而通用的做法是添加 event.stopPropagation();
。
还是用上文的例子,这一次我们在 btn
元素上添加阻止的代码。
const container = document.getElementById('container');
const btnContainer = document.getElementById('btnContainer');
const btn = document.getElementById('btn');
window.addEventListener('click', (event) => {
console.log('win')
})
document.addEventListener('click', () => {
console.log('doc')
})
container.addEventListener('click', () => {
console.log('container');
});
btnContainer.addEventListener('click', () => {
console.log('btnContainer');
});
btn.addEventListener('click', (event) => {
event.stopPropagation();
console.log('btn');
});
查看控制台,效果如下:
可以看到,event.stopPropagation();
阻止了冒泡,事件在 btn
的时候,就不会继续传播下去了。
而相应的,在事件捕获阶段也可以使用 event.stopPropagation();
, 只不过情况有所不同, 捕获阶段阻止的是从 window
到 target
的传播路径的某一环节。
事件代理
接下来来思考这样一个场景,假设我们操纵一个列表,要求点击某一项的时候,会把该项的内容在指定的元素上显示出来。那么,我们可以捏造一个内容结构:
<div id="container">
<div class="show"></div>
<ul id="list">
<li>这是第一个li</li>
<li>这是第二个li</li>
<li>这是第三个li</li>
<li>这是第四个li</li>
<li>这是第五个li</li>
</ul>
</div>
假设我们点击 li
的时候,需要将该元素的内容显示到 div.show
上。
那么我们要怎么做呢?我们当然可以给每一项都添加事件。
const lis = Array.from(document.getElementsByTagName('li'));
const show = document.getElementsByClassName('show')[0];
lis.forEach(element => {
element.addEventListener('click', (e) => {
const target = e.target
show.innerHTML = target.innerHTML
})
});
查看效果:
可以发现功能是可以实现的。
但是,这样的做法也存在着弊端,比如:
-
每一个
li
都要去绑定一个事件,消耗了内存。 -
动态添加或者删除
li
的时候,需要重新添加或者清除事件。
因此,解决这个问题,我们常常会用到一个做法,就是事件委托。事件委托本质上就是把子元素的事件委托给父元素来处理,由于页面的事件流特性,我们可以利用冒泡的特性来编码:
const show = document.getElementsByClassName('show')[0];
list.addEventListener('click', (e) => {
const target = e.target
if (target.nodeName.toLowerCase()==="li") {
show.innerHTML = target.innerHTML
}
})
打开浏览器看看效果:
可以看到,通过事件委托的方式来代理批量子元素的做法是完全可取的。
总结
-
事件捕获和事件冒泡主要解决了页面事件流的问题。统一后,页面的事件流经过了三个阶段,分别是事件捕获、目标阶段和事件冒泡阶段。
-
事件捕获是从
window
对象开始,一层层往内走,直到当前target
;而事件冒泡则是相反。 -
event.stopPropagation()
可以阻止事件流的进一步传播。 -
采用事件代理的方式,能够节省内存消耗,对于动态改变子元素的时候,也非常有利,避免了很多麻烦的步骤,比如重新绑定事件。