浅谈JavaScript事件顺序

如果一个元素和它的其中一个祖先元素对同一个事件都有事件侦听器,哪一个会先触发呢?这个问题其实是取决于浏览器的。

假设在一个元素里包含一个元素:

1
2
3
4
5
6
7
-----------------------------------
| element1 |
| ------------------------- |
| |element2 | |
| ------------------------- |
| |
-----------------------------------

它们都有点击事件的处理器,如果用户点击element2,就会触发element1和element2的点击事件,但是哪一个先触发呢?换句话说,事件执行的顺序是怎么样的?

两种模型

在过去网景浏览器和IE浏览器为主流时,这个问题会有两种结论:

网景浏览器中element1的事件侦听器先被触发,element2的后触发,这叫事件捕获(Event CAPTURING)。

1
2
3
4
5
6
7
8
               | |
---------------| |-----------------
| element1 | | |
| -----------| |----------- |
| |element2 \ / | |
| ------------------------- |
| Event CAPTURING |
-----------------------------------

IE浏览器中element2的事件侦听器先被触发,element1的后触发,这个叫做事件冒泡(Event BUBBLING)。

1
2
3
4
5
6
7
8
               / \
---------------| |-----------------
| element1 | | |
| -----------| |----------- |
| |element2 | | | |
| ------------------------- |
| Event BUBBLING |
-----------------------------------

IE仅支持事件冒泡,网景浏览器只支持事件捕获,火狐、欧朋浏览器两种都支持,再老一点的浏览器是都不支持的。

W3C模型

如果开发者要对多种浏览器在事件模型上做适配,想必是非常麻烦的。因此出现了W3C模型。

W3C很明智地融合了两者,任何事件在W3C模型中是先被捕获,直到事件到达了目标元素,最后再向上冒泡。

上面的过程被分别被称为:捕获阶段、目标阶段、冒泡阶段。

  • 捕获阶段:事件对象从Window​开始,通过目标元素的祖先元素传播,直到目标元素的父元素。
  • 目标阶段:事件对象到达事件的目标对象。如果事件类型表明该事件不会冒泡,那么在这个阶段完成后,事件对象将停止传播。
  • 冒泡阶段:事件对象按照相反的顺序通过目标的祖先元素传播,从目标的父元素开始,直到Window​。
1
2
3
4
5
6
7
8
                 | |  / \
-----------------| |--| |-----------------
| element1 | | | | |
| -------------| |--| |----------- |
| |element2 \ / | | | |
| -------------------------------- |
| W3C event model |
------------------------------------------

开发者可以通过addEventListener​选择把事件侦听器注册在捕获阶段还是冒泡阶段,最后一个参数为true​就是在捕获阶段触发,false​就是冒泡阶段触发。

还是和上面的一样的页面构造,假设有下面的代码:

1
2
element1.addEventListener('click',doSomething2,true)
element2.addEventListener('click',doSomething,false)

然后点击element2,会发生下面的事情:

  1. 点击事件开始它的捕获阶段,在这个阶段事件会检查element2的任意一个祖先有没有捕获阶段的点击事件侦听器。
  2. 事件找到了element1,doSomething2被执行了。
  3. 事件继续向下传递进行捕获阶段,直到传递到了element2,在期间没有别的捕获阶段点击事件侦听器被找到了。这时开始了冒泡阶段,执行doSomething。
  4. 事件开始向上冒泡,检查它有没有祖先有冒泡阶段的事件侦听器。

如果两个反过来,再点击element2

1
2
element1.addEventListener('click',doSomething2,false)
element2.addEventListener('click',doSomething,false)
  1. 点击事件开始捕获阶段,事件检查element2的祖先有没有捕获阶段的点击事件侦听器,但是没有找到。
  2. 事件传递到element2,并且开始冒泡,并执行doSomething。
  3. 事件向上冒泡,检查是否有祖先元素有冒泡阶段的事件侦听器。
  4. 找到了element1,运行doSomething2。

onXYZ​属性

在支持W3C事件模型的浏览器中,传统的事件侦听器注册方式(也可以是内联方式):

1
2
element1.onclick = doSomething2;
<div onclick="doSomething2"></div>

会被注册为冒泡阶段的事件侦听器。但是执行的优先顺序高于其他侦听器(如通过addEventListener​注册的),可以假设它是在目标阶段执行的。

内联的事件侦听器没法使用闭包或者匿名函数,并且可以作用域是有限的。

除了内联的事件侦听器,我们还可以使用element1.onclick = doSomething2;​,自定义元素的onclick属性,这和内联事件侦听器的效果是一样的,只不过这样子可以使用闭包、匿名函数。

这样注册事件侦听器的缺点就是只能给一个事件类型注册一个事件侦听器,因为onXYZ​属性只是一个属性。

总的来说,开发中应该避免使用onXYZ​属性来注册事件侦听器。

addEventListener

前面提到addEventListener​用于将指定的事件侦听器注册到目标对象上,当该对象触发指定事件时,指定的方法会被执行。

现代网页开发中,推荐使用addEventListener​来注册事件侦听器,理由如下:

  1. addEventListener​支持为一个事件添加多个侦听器。
  2. addEventListener​可以精细控制事件触发的阶段,相比于内联侦听器属性或者element.onXYZ​,更加灵活,并且不用担心被覆盖的问题。
  3. 对任何事件有效。

addEventListener​的工作原理是将其接收到的第二个参数listener​函数添加到它的目标对象上的指定事件类型的事件侦听器列表中。如果接收到的listener​函数已经被添加到列表中,他就不会被再次添加。需要注意的是,如果传入的listener函数是一个匿名函数,并且在后面的代码中再添加一个完全相同的匿名函数,这时候会被添加到列表中

这是因为即使使用完全相同的代码创建匿名函数,创建出来的函数不是使用同样的函数引用,它们虽然代码功能都相同,但不是同一个函数。

因此在addEventListener​使用匿名函数是不推荐的,这可能会导致更大的内存开销,并且由于没有保存函数的应用,是无法使用removeEventListener​来移出监听器的,这可能会导致潜在的内存泄漏问题。

停止事件传播

有时我们希望把捕获和冒泡关闭,去防止函数间的互相干扰。此外,如果我们的html结构过于复杂,我们可以通过阻止冒泡来节省系统资源。浏览器会遍历每一个祖先元素去检查是不是有事件侦听器,即使没有找到,这个搜索的过程也是会浪费一定时间的。

在W3C模型中我们可以调用事件对象的的stopPropagation​方法:e.stopPropagation()​,来阻止事件的继续传递。

stopPropagation​只是阻止事件继续向下传播,但是当前事件对象的其他事件侦听器还是会继续运行,如果不想它们继续运行,可以使用stopImmediatePropagation​方法,可以停止冒泡,也阻止当前事件对象其他侦听器的运行。

currentTarget

事件对象有一个target​和srcElement​属性,指向的是触发这个事件的元素,如果我们给两个元素注册相同的事件侦听器,

1
2
element1.addEventListener('click', doSomething);
element2.addEventListener('click', doSomething);

用户点击element2时,doSomething会被执行两次,我们如何知道哪一个元素执行了这个函数呢?

target​和srcElement​它们总是指向element2,因为事件是由element2触发的。解决这个问题,我们可以使currentTarget​,它包含的是当前正在处理事件的元素的引用。

事件委托

因为事件总是会在冒泡到元素的祖先元素,因此类似下面的场景,点击每一个element都要触发类似的事件,比如修改样式、路由跳转等,我们可以给wrapper添加点击事件侦听器,在wrapper的事件侦听器中可以通过target​属性获取到点击的元素,再做相应的处理。

1
2
3
4
5
6
7
8
9
--------------------------------------------------
| wrapper |
| --------------- ------------ ------------ |
| | element1 | | element2 | | element3 | |
| --------------- ------------ ------------ |
| |
--------------------------------------------------

wrapper.onclick = defaultFunction;

这种做法被称为事件委托,其核心思想是利用事件冒泡的原理,将子元素的事件监听器设置在其共同的父元素上。这样,当子元素上的事件被触发并冒泡到父元素时,可以在父元素上捕获并处理这些事件。

事件委托的优点包括:

  1. 减少内存使用:不需要给每个子元素单独绑定事件监听器,只需在父元素上绑定一次。
  2. 动态元素管理:对于动态添加到页面的元素,无需单独绑定事件监听器,事件委托自然适用于后来添加的元素。
  3. 简化事件管理:可以在一个地方管理和维护所有的事件处理逻辑。

参考资料

EventTarget.addEventListener() - Web API 接口参考 | MDN

UI Event - W3C TR

JavaScript Event Order - quirksmode