JavaScript事件三部曲之事件机制的原理

JavaScript 是一个事件驱动(Event-driven) 的语言,当浏览器载入网页开始读取后,虽然马上会读取JavaScript 事件相关的代码,但是必须要等到「事件」被触发(如使用者点击、按下键盘等)后,才会再进行对应代码段的执行。

啥意思呢?

就好比放了一部电话在家里,但是电话要是没响,我们不会主动去「接电话」 (没人打来当然也无法主动接) ,这里电话响了就好比事件被触发,接电话就好比去做对应的事情。

电话响了(事件被触发) -> 接电话(去做对应的事)

换以我们很常见的网页对话框UI 来说,当使用者「按下了按钮」之后,才会启动对话框的显示。如果使用者没有按下按钮,就狂跳对话框,那使用者一定觉得这网站瓦特了吧。

以Bootstrap Modal 为例:

在上面的例子中,当使用者点击了按钮,才会启动对话框的显示,那么「点击按钮」这件事,就被称作「事件」(Event),而负责处理事件的代码段通常被称为「事件处理程序」(Event Handler),也就是「启动对话框的显示」这个动作。

看完上面的例子,想必大家对事件有了一定的理解了吧,接下来就深入来探讨DOM事件。

DOM事件级别

DOM有4次版本更新,与DOM版本变更,产生了3种不同的DOM事件:DOM 0级事件处理,DOM 2级事件处理和DOM 3级事件处理。由于DOM 1级中没有事件的相关内容,所以没有DOM 1级事件。

DOM 0级事件

1.on-event (HTML 属性):

1
<input onclick="alert('xxx')"/>

需要注意的是,基于代码的使用性与维护性考量,现在已经不建议用此方式来绑定事件。

on-event (非HTML 属性):

像是windowdocument此类没有实体元素的情况:

1
2
3
window.onload = function(){
document.write("Hello world!");
};

若是实体元素:

1
2
3
4
5
6
7
8
// HTML
<button id="btn">Click</button>

// JavaScript
var btn = document.getElementById('btn');
btn.onclick = function(){
alert('xxx');
}

若想解除事件的话,则重新指定on-eventnull即可:

1
btn.onclick = null

2.同一个元素的同一种事件只能绑定一个函数,否则后面的函数会覆盖之前的函数

3.不存在兼容性问题

DOM 2级事件

1.Dom 2级事件是通过 addEventListener 绑定的事件

2.同一个元素的同种事件可以绑定多个函数,按照绑定顺序执行

3.解绑Dom 2级事件时,使用 removeEventListener

1
btn.removeEventListener( "click" ,a)

Dom 2级事件有三个参数:第一个参数是事件名(如click);第二个参数是事件处理程序函数;第三个参数如果是true的话表示在捕获阶段调用,为false的话表示在冒泡阶段调用。捕获阶段和冒泡阶段在下一节具体介绍。

还有注意removeEventListener():不能移除匿名添加的函数。

DOM 3级事件

DOM3级事件在DOM2级事件的基础上添加了更多的事件类型,增加的类型如下:

  • UI事件,当用户与页面上的元素交互时触发,如:load、scroll
  • 焦点事件,当元素获得或失去焦点时触发,如:blur、focus
  • 鼠标事件,当用户通过鼠标在页面执行操作时触发如:dblclick、mouseup
  • 滚轮事件,当使用鼠标滚轮或类似设备时触发,如:mousewheel
  • 文本事件,当在文档中输入文本时触发,如:textInput
  • 键盘事件,当用户通过键盘在页面上执行操作时触发,如:keydown、keypress
  • 合成事件,当为IME(输入法编辑器)输入字符时触发,如:compositionstart
  • 变动事件,当底层DOM结构发生变化时触发,如:DOMsubtreeModified
  • 同时DOM3级事件也允许使用者自定义一些事件。

DOM事件流

事件流(Event Flow)指的就是「网页元素接收事件的顺序」。事件流可以分成两种机制:

  • 事件捕获(Event Capturing)
  • 事件冒泡(Event Bubbling)

当一个事件发生后,会在子元素和父元素之间传播(propagation)。这种传播分成三个阶段:

  1. 捕获阶段:事件从window对象自上而下向目标节点传播的阶段;
  2. 目标阶段:真正的目标节点正在处理事件的阶段;
  3. 冒泡阶段:事件从目标节点自下而上向window对象传播的阶段。

接着就来分别介绍事件捕获和事件冒泡这两种机制。

事件捕获(Event Capturing)

事件捕获指的是「从启动事件的元素节点开始,逐层往下传递」,直到最下层节点,也就是div

假设HTML 如下:

1
2
3
4
5
6
7
8
9
10
<html>
<head>
<title>米淇淋是个大帅哥</title>
</head>
<body>

<div>点我</div>

</body>
</html>

假设我们点击(click)了<div>点我</div>元素,那么在「事件捕获」的机制下,触发事件的顺序会是:

  1. document
  2. <html>
  3. <body>
  4. <div>点我</div>

像这样click事件由上往下依序被触发,就是「事件捕获」机制。

事件冒泡(Event Bubbling)

刚刚说过「事件捕获」机制是由上往下来传递,那么「事件冒泡」(Event Bubbling) 机制则正好相反。

假设HTML 同样如下:

1
2
3
4
5
6
7
8
9
10
<html>
<head>
<title>米淇淋是个大帅哥</title>
</head>
<body>

<div>点我</div>

</body>
</html>

假设我们点击(click)了<div>点我</div>元素,那么在「事件冒泡」的机制下,触发事件的顺序会是:

  1. <div>点我</div>
  2. <body>
  3. <html>
  4. document

像这样click事件逐层向上依序被触发,就是「事件冒泡」机制。

既然事件传递顺序有这两种机制,那我怎么知道事件是依据哪种机制执行的呢?

答案是:两种都会执行。

假设现在的事件是点击上图中蓝色的<td>

那么当td的click事件发生时,会先走红色的「capture phase」:

  1. Document
  2. <html>
  3. <body>
  4. <table>
  5. <tbody>
  6. <tr>
  7. <td> (实际被点击的元素)

由上而下依序触发它们的click事件。

然后到达「Target phase」后再继续执行绿色的「bubble phase」,反方向由<td>一路往上传至Document,整个事件流到此结束。

要检验事件流,我们可以通过addEventListener()方法来绑定click事件:

假设HTML 如下:

1
2
3
4
5
6
<div>
<div id="parent">
父元素
<div id="child">子元素</div>
</div>
</div>

JavaScript 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var parent = document.getElementById('parent');
var child = document.getElementById('child');

// 通过 addEventListener 指定事件的绑定
// 第三个参数 true / false 分別代表 捕获/ 冒泡 机制

parent.addEventListener('click', function () {
console.log('Parent Capturing');
}, true);

parent.addEventListener('click', function () {
console.log('Parent Bubbling');
}, false);

child.addEventListener('click', function () {
console.log('Child Capturing');
}, true);

child.addEventListener('click', function () {
console.log('Child Bubbling');
}, false);

当我点击的是「子元素」的时候,通过console.log可以观察到事件触发的顺序为:

1
2
3
4
"Parent Capturing"
"Child Capturing"
"Child Bubbling"
"Parent Bubbling"

而如果直接点击「父元素」,则出现:

1
2
"Parent Capturing"
"Parent Bubbling"

由此可知,点击子元素的时候,父层的Capturing会先被触发,然后再到子层内部的CapturingBubbling事件。最后才又回到父层的Bubbling结束。点击父元素的时候,不会经过子元素,子层的CapturingBubbling都不会触发。

那么,子层中的CapturingBubbling谁先谁后呢?要看你代码的顺序而定:

若是CapturingBubbling前面:

1
2
3
4
5
6
7
child.addEventListener('click', function () {
console.log('Child Capturing');
}, true);

child.addEventListener('click', function () {
console.log('Child Bubbling');
}, false);

则会得到:

1
2
"Child Capturing"
"Child Bubbling"

若是将两段代码段顺序反过来的话,就会是这样了:

1
2
3
4
5
6
7
child.addEventListener('click', function () {
console.log('Child Bubbling');
}, false);

child.addEventListener('click', function () {
console.log('Child Capturing');
}, true);

则会得到:

1
2
"Child Bubbling"
"Child Capturing"

事件监听 EventTarget.addEventListener()

addEventListener()基本上有三个参数,分别是「事件名称」、「事件的处理程序」(事件触发时执行的function),以及一个「Boolean」值,由这个Boolean决定事件是以「捕获」还是「冒泡」机制执行,若不指定则预设为「冒泡」。

1
2
3
4
5
6
7
8
9
// HTML
<button id="btn">Click</button>

// JavaScript
var btn = document.getElementById('btn');

btn.addEventListener('click', function(){
console.log('HI');
}, false);

使用这种方式来注册事件的好处是:同一个元素的同种事件可以绑定多个函数,按照绑定顺序执行。

1
2
3
4
5
6
7
8
9
var btn = document.getElementById('btn');

btn.addEventListener('click', function(){
console.log('HI');
}, false);

btn.addEventListener('click', function(){
console.log('HELLO');
}, false);

点击后console出现:

1
2
"HI"
"HELLO"

若要解除事件的监听,则是通过removeEventListener()来取消。

removeEventListener()的三个参数与addEventListener()一样,分别是「事件名称」、「事件的处理程序」以及代表「捕获」或「冒泡」机制的「Boolean」值。

但是需要注意的是,由于addEventListener()可以同时针对某个事件绑定多个函数,所以通过removeEventListener()解除事件的时候,第二个参数的函数必须要与先前在addEventListener()绑定的函数是同一个「实体」。

比如:

1
2
3
4
5
6
7
8
9
10
var btn = document.getElementById('btn');

btn.addEventListener('click', function(){
console.log('HI');
}, false);

// 移除事件,但是没用
btn.removeEventListener('click', function(){
console.log('HI');
}, false);

像上面这样,即使执行了removeEventListener来移除事件,但click时仍会出现’HI’。因为addEventListenerremoveEventListener所移除的函数实际上是两个不同实体的function对象。

不知道为什么这两个function是两个不同实体的朋友请参考:《JavaScript系列之内存空间》。简单理解就是两个function指向不同的内存地址,代表来自于不同实体。

稍加改进后就能如愿移除了:

1
2
3
4
5
6
7
8
9
10
11
var btn = document.getElementById('btn');

// 把 event 函数程序拉出來
var clickHandler = function(){
console.log('HI');
};

btn.addEventListener('click', clickHandler, false);

// 移除 clickHandler, ok!
btn.removeEventListener('click', clickHandler, false);

那么以上就是今天为各位介绍JavaScript事件机制原理的部分。

接下来的文章我会继续来介绍事件的种类,以及更多实际上处理「事件」时需要注意的事项。

如果觉得文章对你有些许帮助,欢迎在我的GitHub博客点赞和关注,感激不尽!