0%

事件流与事件委托

事件流

当我们在一个网页中点击一个div时,我们都知道我们触发了这个div上的点击事件。但是你有没有想过,你其实不仅仅点击了这个div,你同时也点击了包裹这个div的所有元素,像是body,html乃至document,你同时触发了所有这些元素的点击事件。但是点击事件的触发是有一个先后顺序的,事件流这个概念就是用来描述从页面中接收事件的顺序的。

IE和Netscape提出的事件流是截然相反的,IE提出的事件流是 事件冒泡,而Netscape提出的事件流是 事件捕获

事件冒泡:从字面意思理解,冒泡是从下往上发生的,同样的,事件冒泡也是从下往上发生的,或者说由内向外。用上面提到的例子就是,点击div后,先触发div上的点击事件,然后先后分别触发body,html,document上的点击事件。

事件捕获:事件捕获的过程和事件冒泡恰好相反:document > html > body > div。

DOM2级事件规定事件流包括3个阶段,按照触发的先后顺序分别是:捕获阶段 > 处于目标阶段 > 冒泡阶段(见下图)。关于什么是DOM2级事件我们下面再说。

DOM事件流

规范还规定捕获阶段会在到达目标(这里的div)前结束,目标元素上的事件处理程序实际属于冒泡阶段的一部分。但是各大浏览器厂商实际上并没有听老大哥的话(实际上它们经常这么干),在实际开发中,如果你在上面例子中的div上同时绑定了捕获阶段和冒泡阶段的事件处理程序,这两个程序都会执行,但是执行顺序并不总是先捕获再冒泡,而是 谁先注册,谁先执行,这一点要特别注意,后面我会用代码论证这一点,但在那之前我们要先搞清楚什么是DOM2级事件。

DOM0级事件和DOM2级事件

为什么没有DOM1级事件

DOM1级于1998年10月成为W3C的推荐标准,但是在DOM1级标准里并没有定义事件相关的内容,所以没有DOM1级标准。实际上DOM0并不是官方正式发布的标准,而是之前的浏览器厂商就是这么实现的,DOM0只是相对于DOM1的叫法。

DOM0级事件处理程序

写在html行内的事件处理程序<div onclick="doSomething()"></div>,到底算不算DOM0级事件处理程序我也不清楚,这种写法是完全不推荐的,这里就不做过多讨论了。

我们主要讨论一下这种形式element.onclick = function(){}。DOM0级事件处理程序有以下几个特点(以点击事件onclick为例,其它的也一样):

  • onclick是作为元素的属性存在的。在浏览器控制台输入document.onclick或者document.querySelector('div').onclick都会返回null(假设你还没有绑定过onclick)
  • 事件处理程序的this指向绑定的那个元素。当然,如果你绑定了一个箭头函数,this指向就不是绑定的那个元素了,比如在全局作用域注册了onclick为一个箭头函数,箭头函数内的this会指向window。、
  • 在同一个元素上注册多个onclick,后面的会覆盖前面的,因为这本质上和对对象的属性操作是一样的。
  • 利用上一条,我们可以使用element.onclick = null这句代码来移除某个元素上的事件处理程序。
  • onclick注册的事件处理程序会被添加到冒泡阶段。

现在我们来验证下“onclick注册的事件处理程序会在冒泡阶段执行”这句话。假设页面里有一个div,我们在document,html,body,div上分别注册事件处理程序,冒泡阶段的执行顺序应该是div > body > html > document。

1
2
3
4
5
6
7
8
9
10
11
12
13
document.onclick = function(){
console.log('document')
}
document.querySelector('html').onclick = function(){
console.log('html')
}
document.querySelector('body').onclick = function(){
console.log('body')
}
document.querySelector('div').onclick = function(){
console.log('div')
}
// 输出:div body html document

DOM2级事件处理程序

DOM2级事件有两个方法,分别用于注册和移除事件处理程序:addEventListenerremoveEventListener。它们都接受3个参数:事件类型(string)、函数(function)、是否在捕获阶段调用(boolean,默认为false,即在冒泡阶段调用)。

我们来比较下DOM2级事件处理程序和DOM0级事件处理程序的异同:

  • addEventListener是作为元素的方法存在的。这一点和DOM0级有一定区别,可以在浏览器控制台输入document.onclickdocument.addEventListener细细体会其中的区别。
  • this也指向绑定的元素。
  • 在同一个元素上注册多个addEventListener,事件处理程序会同时存在,并且按照注册的顺序来执行。这一点和DOM0级有很大区别。
  • 和DOM0级不同,DOM2级移除事件处理程序有专门的方法removeEventListener
  • DOM2级可以指定把事件处理程序添加到捕获还是冒泡阶段,而DOM0级事件处理程序只能被添加到冒泡阶段。

removeEventListener可以用来移除某个元素上某个指定的事件处理程序,这意味着,如果使用addEventListener添加的是一个匿名函数,那么就无法移除了。同时容易忽略的是,在捕获阶段和冒泡阶段添加的即使是同一个函数,那这也是两个不同的事件处理程序,移除时需要分别移除。我们来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
var handle = function(){
console.log(1)
}
document.addEventListener('click', handle, false)
document.addEventListener('click', handle, true)

// 此时点击页面,应该会输出两次1

// 现在我们移除冒泡阶段的事件处理程序
document.removeEventListener('click, handle, false)
// 此时再点击页面,还是会输出一次1,因为只移除了冒泡阶段的,捕获阶段的还在

还记得上面我们提到过:当在目标元素上同时注册了捕获阶段和冒泡阶段的事件处理程序时,执行顺序并不总是先捕获再冒泡,而是 谁先注册,谁先执行吗?现在我们了解了DOM2级事件,是时候来验证一下了,我们把讲DOM0级时使用的代码修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
var html = document.querySelector('html')
var body = document.querySelector('body')
var div = document.querySelector('div')

document.addEventListener('click', function(){
console.log('冒泡:document')
}, false)
html.addEventListener('click', function(){
console.log('冒泡:html')
}, false)
body.addEventListener('click', function(){
console.log('冒泡:body')
}, false)
// (1)
div.addEventListener('click', function(){
console.log('冒泡:div')
}, false)

document.addEventListener('click', function(){
console.log('捕获:document')
}, true)
html.addEventListener('click', function(){
console.log('捕获:html')
}, true)
body.addEventListener('click', function(){
console.log('捕获:body')
}, true)
// (2)
div.addEventListener('click', function(){
console.log('捕获:div')
}, true)

// 输出顺序:
// 捕获:document 捕获:html 捕获:body 冒泡:div 捕获:div 冒泡:body 冒泡:html 冒泡:document

可以看到,在target元素(div)上先后注册了冒泡和捕获的事件处理程序,target上的事件处理程序是按注册顺序执行的。把上面代码的第(1)处和第(2)处呼唤位置,输出顺序也会相应改变。同时,通过上面的代码也能很清晰的了解整个事件流的传递过程:捕获阶段 > 处于目标阶段 > 冒泡阶段。

IE事件处理程序

IE8及其之前的版本(后面都简称IE)实现了与DOM2级类似的两个方法:attachEventdetachEvent。着两个方法接收两个参数:(1)事件名称,这里与DOM2级事件不同,addEventListener的第一个参数是click,在这里是onclick,所有的事件都在前面加一个on。(2)第二个参数是回调函数。没有第3个参数,因为IE的事件处理程序会被添加到冒泡阶段。

同样对比一下特点:

  • attachEvent也作为元素的方法存在,这一点与addEventListener类似。
  • this指向window,这一点与DOM0级和DOM2级不同。
  • 在同一个元素上注册多个attachEvent,事件处理程序会同时存在,这一点与DOM2级一致。但是执行顺序是后添加的先执行,这一点与DOM2级相反。
  • IE有专门用来移除事件处理程序的方法detachEvent,这一点和DOM2级类似。
  • IE事件处理程序会被添加到冒泡阶段,这一点与DOM0级表现一致。

事件委托

从业务场景出发

  1. 考虑一下这样一个常见的业务场景:有一个列表ul,里面有很多列表项li,每一个列表项都要能点击并执行某个函数。最容易想到的是给每一个li都注册一个事件处理程序。
1
2
3
document.querySelecorAll('li').forEach(item => {
item.addEventListener('click', function(){}, false)
})

这样的实现是有问题的,当li的个数很多的时候,这会消耗很多的内存。我们知道,声明一个函数其实等价于new Function(),每个函数都是一个实例对象,没个li生成一个对象,只自然是一笔不小的内存开销。那么把匿名函数换成具名函数呢?这样多个事件处理程序的回调函数会指向同一个函数。这样确实要好一点,但内存问题仍然存在。而且每个li绑定一个事件处理程序,需要多次访问DOM,我们应该尽量少的访问DOM

  1. html里一个空的ul列表,我们用Ajax请求列表数据并渲染到ul里。或者有一个添加按钮,用户每点击一下,添加一个列表项。向这种动态的列表,我们给每个列表项都添加事件处理程序也很麻烦。

利用事件冒泡

我们上面也分析了事件冒泡的原理,利用事件冒泡,我们可以在实际要添加事件处理程序的元素的祖先元素上添加一次事件处理程序,由这一个事件处理程序来指派多个其它的任务,这种方法就是 事件委托

我们用上面列表的例子来写个实例代码:

1
2
3
4
5
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
1
2
3
4
5
6
7
8
9
10
document.querySelector('ul').addEventListener('click', function (e) {
var event = e || window.event
var target = event.target || event.srcElement

// 打印每个li的内容
if (target.nodeName.toLocaleLowerCase === 'li') {
console.log(target.innerHTML);
}
}, false);
// 点击列表项分别输出: 1 2 3

jQuery的事件委托

jQuery的事件委托可以使用.on.delegate()方法,.live()方法于1.9版本已废弃。具体的内容可以参考jQuery文档。

局限

事件委托是利用了事件冒泡的特性,所以对于不能冒泡的事件比如focus和blur则无法使用事件委托。mousemove虽然也能冒泡,但是鼠标移动过程中的位置计算比较麻烦,也比较消耗性能,所以也不适合使用事件委托。

总结

  • 事件流的阶段顺序是事件捕获阶段 > 处于目标阶段 > 事件冒泡阶段。目标元素上的捕获阶段事件处理程序和冒泡阶段事件处理程序按照注册的顺序执行的。
  • DOM0级、DOM2级以及IE的事件处理程序异同比较如下:
    • 属性还是方法?DOM0级:onclick作为元素的属性存在。DOM2级:addEventListener作为元素的方法存在。IE:attachEvent作为元素的方法存在。
    • this指向问题?DOM0级和DOM2级:回调函数的this指向目标元素。IE:回调函数的this指向window。
    • 一个元素注册多个处理程序?DOM0级:一个元素只能注册一个事件处理程序,后面的onclick会覆盖前面的。DOM2级:可以同时添加多个事件处理程序,执行顺序就是注册顺序。IE:可以同时添加多个事件处理程序,执行顺序与注册顺序相反。
    • 移除事件处理程序?DOM0级:通过element.onclick = null移除事件处理程序。DOM2级:通过removeEventListener来移除事件处理程序,注意参数和注册时保持一致。IE:通过detachEvent移除事件处理程序,注意参数和注册时保持一致。
    • 添加到哪个阶段?DOM0级和IE:事件处理程序被添加到冒泡阶段。DOM2级:可以通过参数指定把事件处理程序添加到捕获还是冒泡阶段。
  • 利用事件冒泡可以实现事件委托,事件委托有更高的性能,应该善用事件委托。
  • 还有一点反复提到的顺序问题不能弄混淆了:
    • 目标元素也就是被点击的某个div上,同时注册 捕获阶段冒泡阶段的事件处理程序(毫无疑问,只有DOM2级事件拥有这种能力),那么事件触发时处理程序执行的顺序遵循 谁先注册,谁先执行
    • 在同一个元素上注册 多个事件处理程序(DOM2级或IE),那么事件触发时处理程序的执行顺序是:DOM2级:谁先注册,谁先执行,IE:谁先注册,谁后执行