从事件传播冒泡阶段到事件委托

2,485 阅读8分钟
原文链接: www.shuaihua.cc

DOM事件传播包括三个阶段:
1、捕获阶段
2、目标对象调用事件处理程序
3、冒泡阶段

希望注册在DOM元素上的事件处理程序在捕获阶段还是在冒泡阶段触发,取决于 addEventListener() 方法的第三个参数为 true 还是 false

<div id="outer_div" style="width:100px; height:100px; background-color:#999;">
  <div id="inner_div" style="width:50px; height:50px; background-color:#333;"></div>
</div>
document.getElementById('outer_div').addEventListener('click', function(){
  console.log('bubble: outer_div');
}, false);

document.getElementById('outer_div').addEventListener('click', function(){
  console.log('capture: outer_div 02');
}, true);

document.getElementById('outer_div').addEventListener('click', function(){
  console.log('capture: outer_div 01');
}, true);

上方html代码片段内,我创建了一个id为outer_div的div标签元素,在该元素内创建了一个id为inner_div的div元素。在上方JavaScript代码片段内,我为id为outer_div的元素添加了三个事件类型为click的事件监听器。当我们在#inner_div上触发点击事件时,根据注册顺序和第三个参数推断,控制台依次输出:

capture: outer_div 02
capture: outer_div 01
bubble: outer_div

当我们在#outer_div上触发点击事件时,根据注册顺序,控制台依次输出:

bubble: outer_div
capture: outer_div 02
capture: outer_div 01

你可能会感到疑惑,为什么在#outer_div上触发点击事件,却先调用了本应在冒泡阶段触发的事件处理程序?这不得不顺带解释一下事件处理程序的调用顺序。根据JavaScript权威指南中的解释:

文档元素或其他对象可以为指定事件类型注册多个事件处理程序。当适当的事件发生时,浏览器必须按照如下规则调用所有事件处理程序:
1、通过设置对象属性或HTML属性注册的处理程序一支优先调用。
2、使用addEventListener()注册的事件处理程序按照他们的注册顺序调用。
3、使用attachEvent()注册的处理程序可能按照任何顺序调用,所以代码不应该依赖于调用顺序。

先从上面的解释中解答一下先前关于调用顺序的疑惑,根据第二条规则,使用addEventListener()注册的事件处理程序按照他们注册顺序调用,也就是说在事件目标上触发事件后执行的事件处理程序顺序是按照事件监听器的注册顺序来确定的。当我们在#outer_div上触发点击事件,此时事件目标就是#outer_div元素,而正好#outer_div元素上注册了三个事件监听器,所以仅根据事件注册的顺序而并不受捕获或冒泡(第三个参数)的约束就有了控制台中令人疑惑的输出结果。

当我们在#inner_div上触发点击事件,此时事件目标是#inner_div元素,DOM事件流在捕获阶段流向事件目标元素即#inner_div元素的时候,经过了#outer_div元素,因此触发了注册在#outer_div上的使用于捕获阶段的事件处理程序,又由于#outer_div元素上有两个使用于捕获阶段的事件处理程序,因此处理程序调用顺序由事件的注册顺序来决定(所以此时控制台先输出 capture: outer_div 02 再输出 capture: outer_div 01),此时DOM事件流流向#inner_div元素,而我们并没有为#inner_div元素注册用于捕获阶段的事件监听器,所以DOM事件流掉头进入冒泡阶段,我们也没有为#inner_div注册用于冒泡阶段的事件监听器,所以DOM事件流一路向上又遇见了#outer_div,而#outer_div注册了用于冒泡阶段的事件监听器,所以此时控制台输出 bubble: outer_div

如果你还觉得匪夷所思,不妨亲自动手写一些测试例子,你可以将上方为#outer_div元素添加三个事件监听器的JavaScript代码复制一份,给#inner_div添加事件监听器(记得修改输出内容避免混淆),如果不出意外,当你此时点击#inner_div时,控制台会输出如下内容:

capture: outer_div 02
capture: outer_div 01
bubble: inner_div
capture: inner_div 02
capture: inner_div 01
bubble: outer_div

我花了一些篇幅解答上面的疑惑。但是我们只用到了 addEventListener() 这一种用来为DOM元素或其他对象注册事件监听器的方式。我们不妨用实践测试一下另一种设置DOM元素或其他对象属性方式:

<div id="outer_div" onclick="console.log('handler: outer_div inline')" style="width:300px; height:300px; background-color:#999;">
	<div id="inner_div" onclick="console.log('handler: inner_div inline')" style="width:100px; height:100px; background-color:#333;"></div>
</div>
document.getElementById('outer_div').addEventListener('click', function(){
  console.log('bubble: outer_div');
}, false);
document.getElementById('outer_div').addEventListener('click', function(){
  console.log('capture: outer_div 02');
}, true);
document.getElementById('outer_div').addEventListener('click', function(){
  console.log('capture: outer_div 01');
}, true);
document.getElementById('outer_div').onclick = function(){
  console.log('handler: outer_div outline');
}

document.getElementById('inner_div').addEventListener('click', function(){
  console.log('bubble: inner_div');
}, false);
document.getElementById('inner_div').onclick = function(){
  console.log('handler: inner_div outline');
}
document.getElementById('inner_div').addEventListener('click', function(){
  console.log('capture: inner_div 02');
}, true);
document.getElementById('inner_div').addEventListener('click', function(){
  console.log('capture: inner_div 01');
}, true);

我们可以在HTML文档中为DOM元素添加onclick属性,也可以在JavaScript中获取到DOM元素并为其设置起onclick属性,后者会覆盖前者。

当我们点击#outer_div元素时,控制台输出:

handler: outer_div outline
bubble: outer_div
capture: outer_div 02
capture: outer_div 01

在解释控制台输出结果的原因之前,我先回忆一下早期addEventListener()方法未规范化时,采用为DOM元素或其他对象的属性设置事件处理函数,这些事件处理函数总是在DOM事件流的冒泡阶段触发。

当我们点击#outer_div元素时,事件的目标元素就是#outer_div本身,根据事件处理程序调用顺序的规则,为DOM元素或其他对象的属性设置事件处理程序总是率先执行,然后就按注册顺序执行,所以不管我们将为DOM元素的onclick属性设置事件处理程序放置在任何位置,在控制台中总是onclick方式的事件处理程序率先执行。

当我们点击#inner_div时,控制台输出:

capture: outer_div 02
capture: outer_div 01
handler: inner_div outline
bubble: inner_div
capture: inner_div 02
capture: inner_div 01
handler: outuer_div outline
bubble: outer_div

在大部分事件监听器使用的场景中,冒泡阶段更加通用,只有在实现特殊功能的情况下才需要使用事件捕获阶段。由于,早期onclick等on开头的事件监听属性只存在于冒泡阶段,所以虽然说使用onclick添加事件处理函数的方式总是优先执行,但也是仅限于在冒泡阶段。因此会有控制台的输出结果也不足为奇。

本篇文章不介绍 attachEvent() 的用法,是因为它终将成为时代的弃儿 :),强烈推荐使用 addEventListener()removeEventListener() 为DOM元素和其他对象添加事件监听器,特殊情况下可以配合使用更便捷的on*方式为DOM元素设置事件处理程序。


事件冒泡为在大量单独文档元素上注册处理程序提供了替代方案,即在共同的祖先元素上注册一个某一事件类型的事件监听器来处理所有子元素的同类型事件。例如,可以在<form>元素上注册“change”事件处理程序来取代在表单中每个元素上注册的“change”事件处理程序,大大提升性能,减少内存占用和代码冗余。

发生在文档元素上的大部分事件都会冒泡,但是有一些例外情况,比如 focus blur scroll 事件不会冒泡,文档元素上的load事件会冒泡,但是他会在Document对象上停止冒泡而不会传播到Window对象,只有当整个文档都家在完毕时才会触发Window对象的load事件。

事件委托,也可以叫做事件代理。下面我以click事件为例,如何为祖先元素注册某一种事件类型,从而代理所有其子元素的该事件类型。事件代理机制的使用需要确保在DOM祖先元素上添加的事件监听器监听的时冒泡阶段的事件,这样才能通过事件对象中的 target 属性获取到事件目标对象,从而一层层的向上冒泡到该祖先元素为止。

点击“添加”按钮添加一个按钮,点击添加的按钮移除这个按钮

<div class="wrap" id="wrap">
	<div class="btn" data-type="btn" data-feat="add">添加</div>
	<div class="btn" data-type="btn" data-feat="delete">绘画</div>
	<div class="btn" data-type="btn" data-feat="delete">散步</div>
	<div class="btn" data-type="btn" data-feat="delete">静坐</div>
</div>
document.getElementById('wrap').addEventListener('click', function(e){
	var target = e.target;
	while(target !== this){
		var type = target.dataset.type;
		if(type == 'btn'){
			var feat = target.dataset.feat;
			switch(feat){
				case 'add':
					this.innerHTML += '<div class="btn" data-type="btn" data-feat="delete">静坐</div>'
					return;
				case 'delete':
					target.parentNode.removeChild(target);
					return;
			}
		}
		target = target.parentNode;
	}
}, false);

往常为每一个新增加的按钮还需要注册一个点击事件,移除元素之前需要移除事件监听器。而使用事件委托机制可以仅需注册为共有的祖先元素添加一个事件监听器便可以代理所有子元素的事件。