浏览器的 DOM 事件

前言

好像有一个月没有写博客,最近沉迷了一款手游,工作之外的休息时间几乎被游戏占据了。夜深了,深感惭愧,于是静下心来写写东西,努力提升自己的专业能力。其实,在写这篇文章之前,为了使自己的理解更加透彻,表达更加清晰,自己花了好多时间去收集资料,才动笔的。
这次要讲的是 javascript 的“事件”,主要从事件事件流以及事件处理程序这几个点去阐述。

概念

  • 事件:就是文档或浏览器窗口中发生的一些特定的交互瞬间,我们可以通过事件侦听器(或处理程序)来预定可能发生的事件,以便事件发生时执行相应的代码。
  • 事件流:用来描述从页面中接收事件的顺序,比如用户点击了某个 DOM 节点,那么这个事件在浏览器上是怎么样被接收到的,关于这个问题,下文会做详细的讲解。
  • 事件处理程序:通俗点来说,就是用来处理或响应事件的函数,而注册事件处理程序又有很多种方式。

进入主题

事件流

作为一名初级前端开发程序员,我曾经也是对这个概念相当的模糊,不是因为概念本身很难明白,而是这个概念要追溯到上个世纪 90 年代,属于错综复杂的那种,当时浏览器开发商对浏览器事件流的理解没有达成一致,浏览器开发团队微软和 Netscape(网景)对于事件流提出了几乎完全相反的概念。

  • IE:它认为事件流应该是事件冒泡。
  • Netscape:它则认为事件流应该是事件捕获。

事件冒泡

由 IE 开发团队提出的事件冒泡,认为事件开始时由最具体的元素接收,然后,逐级向上传播到较为不具体的节点。 这个很好理解,,冒泡冒泡嘛,就像水中的鱼儿吐出的气泡(事件)一样。如图:

也就是说,在浏览器的 DOM 结构中,假设用户点击了某个 DOM 节点,此时这个事件就会像鱼儿吐了气泡一样,一层又一层的向上冒,一直冒到了水平面(windowdocument对象)。
注意:对于冒泡流的事件流机制,存在如下的兼容问题:

  • ie5.5 或以下 p -> div -> body -> document
  • ie6+ p -> div -> body -> html ->  document
  • ie9+,firefox,chrome,safari p -> div -> body -> html ->  document -> window

事件捕获

这个概念显得没有那么直白,甚至有些反正常的思维。最早由 Netscape 团队提出的事件捕获,它认为事件应该从不太具体的节点开始接收,而最具体的节点应该最后接收到事件,事件捕获流的用意在于在事件到达预定目标之前捕获它,如图

其实在生活中,也有许多例子。例如公司的前台 MM 帮员工代收快递,一件快递在送到你手上之前,一般会经过前台 MM 的代签,还可能同事帮你跑腿,再到你手上,这个快递最终才会落在你手上。
需要注意的是,因为IE9以下的浏览器不支持事件捕获,所以,建议使用事件冒泡。必要的时候,再使用事件捕获。
尽管“DOM2 级事件”的规范是要求事件捕获是从document对象开始传播,但是大多数浏览器都是从 window 开始传播: window -> document -> html -> body -> div

DOM 事件流

DOM 事件流是在“DOM2 级事件”中规范出来的,它规定的事件流应该包括三个阶段:事件捕获阶段、处于目标阶段、事件冒泡阶段。首先发生的是事件捕获,它为截获事件提供了机会。然后是实际的目标收到事件。最后一个阶段是冒泡阶段,可以在这个阶段对事件做出响应。假设在页面上点击了一个 div 元素,这个事件会按照下面的顺序图进行传播。

IE9、Opera、Firefox、Chrome 和 Safari 都支持 DOM 事件流。

其实 javascript 的事件流的内容大概就是这些了。然而你会问,这么多理论,到底有个卵用啊???当然是有用的,它可以让我们在恰当的时机阻止事件的触发,以及帮助理解jQuery的事件委托是怎样实现的(以后的文章会有专门的介绍)。这里我写了一个实例,帮助大家理解 DOM 事件流

1
2
3
<div id="div">
<button id="button">button</button>
</div>
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
var $button = document.getElementById('button');
var $div = document.getElementById('div');
$div.addEventListener(
'click',
function (event) {
console.log('捕获阶段的div');
},
true,
);
$button.addEventListener(
'click',
function (event) {
console.log('捕获阶段的button');
},
true,
);
$button.addEventListener(
'click',
function (event) {
console.log('冒泡阶段的button');
},
false,
);
$div.addEventListener(
'click',
function (event) {
console.log('冒泡阶段的div');
},
false,
);

上面的例子,打印出来的是 log 顺序应该是:捕获阶段的div -> 捕获阶段的button -> 冒泡阶段的button -> 冒泡阶段的div
假设我只想让事件传播到捕获阶段的捕获阶段的div就停止了,这时候只要在事件处理程序里面加上event.stopPropagation()就 ok 了。

1
2
3
4
5
6
7
8
$div.addEventListener(
'click',
function (event) {
console.log('这是捕获阶段的div');
event.stopPropagation();
},
true,
);

事件处理程序

我们知道,事件就是用户或浏览器自行执行的某种动作,而响应某个事件的函数,就叫做事件处理程序事件侦听器)。事件处理程序的名称以on开头,,所以 click 事件的事件处理程序就是 onclick,load 事件的事件处理程序就是 onload 了。而为事件指定处理程序的方式有好几种:

  • HTML 事件处理程序
  • DOM0 级事件处理程序
  • DOM2 级事件处理程序
  • IE 事件处理程序

HTML 事件处理程序

这种事件处理方式非常简单,直接在 HTML 中定义,然后执行函数写在 javascript 脚本上,而且执行函数的作用域必须暴露在window上:

1
<button onclick="hello()"></button>
1
2
3
function hello() {
console.log('hello world');
}

这种方式很明显的缺点是,用户如果在执行函数(hello())没有加载完之前,就点击了按钮,页面就会抛出一个错误,当然这个可以通过 try-catch 来解决,另外,在《javascript 高级程序设计》这本书中说到,这种方式会让 HTML 和 javascript 代码紧密耦合,维护性比较差,这个呢,我不敢苟同啊(毕竟 angularJS 1.x 都是这么做的,没感觉到难维护)。

DOM0 级事件处理程序

这种方式至今仍然为所有现代浏览器所支持。原因是简单、且具有跨浏览器的优势。
每个元素(包括 window 和 document)都有自己的事件处理程序属性,这些属性通常全部小写。例如 onclick。将这种属性的值设置为一个函数,就可以指定事件处理程序了,如:

1
<button id="button"></button>
1
2
3
4
var $btn = document.getElementById('button');
$btn.onclick = function () {
console.log('hello world');
};

在使用 DOM0 级事件处理程序时,执行函数被认为是元素的方法。因此,这时候执行函数里的作用域就是当前元素了,这也是为什么this引用会指向元素而不是window的原因了。以这种方式添加的事件处理程序会在事件流的冒泡阶段被处理。
值得注意的是,用 DOM0 级事件处理程序时,会遇到一种情况,就是一个元素只能绑定一个同类型的事件,即一个元素不能同时绑定多个 click 事件,如果绑定多个的话,最后一个会覆盖前面所有的绑定(执行函数被认为是元素的方法,当然会覆盖了)。
如果要注销元素的事件处理程序的话,很简单,只要将该元素的事件处理程序设置为null即可(同时也会注销用HTML事件处理程序方式注册的事件处理程序),比如:

1
$btn.onclick = null;

DOM2 级事件处理程序

DOM2 级事件”定义两个方法:addEventListenerremoveEventListener。它们都接受 3 个参数:事件名事件处理程序的函数一个布尔值true表示在捕获阶段调用事件处理程序函数、false表示在冒泡阶段调用事件处理程序,默认为flase)。为一个元素绑定事件处理程序,并在冒泡阶段调用,可以这么写:

1
2
3
4
5
6
7
8
var $btn = document.getElementById('btn');
btn.addEventListener(
'click',
function () {
console.log(this);
},
false,
);

那么,为这个元素注销绑定的事件处理程序,应该是:

1
2
3
4
5
6
7
8
var $btn = document.getElementById('btn');
btn.removeEventListener(
'click',
function () {
console.log(this);
},
false,
);

Sorry,这样是注销不了的。实际上,DOM2 事件处理程序的规范要求,addEventListenerremoveEventListener传入的事件处理程序函数必须相同,上面的例子是因为函数的引用地址不同。因此,我们可以这样处理:

1
2
3
4
5
6
var $btn = document.getElementById('btn');
var handlder = function () {
console.log(this);
};
btn.addEventListener('click', handlder, false);
btn.removeEventListener('click', handlder, false);

另外,DOM2 级事件处理程序,是允许一个元素同时注册同个类型的事件的,因此一个 div 绑定多个click,这种情况也是被允许的。我们知道IE9+,FireFox,Safari,Chrome,Opera都是兼容DOM 事件流的,所以同样也兼容DOM2 级事件处理程序

IE 事件处理程序

我们“可(zuo)爱(si)”的 IE 浏览器实现了与 DOM2 级处理程序类似的方法:attachEventdetachEvent。不过这两个方法分别接受两个参数:on+事件名、事件处理程序函数。由于 IE8 及更早版本只支持事件冒泡,所以通过 attachEvent 添加的事件处理程序都会被添加到冒泡阶段。
同样的,我们为一个元素注册或注销事件处理程序时,可以这么写:

1
2
3
4
5
6
var $btn = document.getElementById('btn');
var handlder = function () {
console.log(this);
};
$btn.attachEvent('onclick', handlder);
$btn.detachEvent('onclick', handlder);

这里有几点要注意的:

  • 我们发现 attachEvent 的第一个参数并不是直接传事件名click,而是加了”on”。
  • console.log打印出来this是指向window,而不是元素本身。卧槽,IE 小盆友,你确定这个不是 bug 吗?
  • 用 attachEvent 注册多个同类型的事件处理程序函数时,函数并不是按照他们定义时的顺序执行,而是相反的顺序执行。

干货

扯了这么多,不知道你看晕了没有。没关系,这里留一个经典的干货,跨浏览器的事件处理程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var EventUtil = {
addEvent: function (element, type, handler) {
if (element.addEventListener) {
element.addEventListener(type, handler, false);
} else if (element.attachEvent) {
element.attachEvent('on' + type, handler);
} else {
element['on' + type] = handler;
}
},
removeEvent: function (element, type, handler) {
if (element.removeEventListener) {
element.removeEventListener(type, handler, false);
} else if (element.detachEvent) {
element.detachEvent('on' + type, handler);
} else {
element['on' + type] = null;
}
},
};

参考资料

  • 《javascript 高级程序设计》

[本文谢绝转载,谢谢]

粤ICP备2022084378号