我在看cnblog上的一篇有关前端面试的博文的时候,发现里面有一道很有研究价值的题目,是这样说的:

什么是浏览器事件模型?
请描述js的事件冒泡和捕获(event bubble and capturing)?
如何停止冒泡(bubble)?

这里博主给了我们回答:

  • 浏览器事件模型是涉及到捕获、目标、冒泡三个阶段的事件模型;
  • 至于捕获和冒泡,是指事件从最外围元素一直进入触发元素,然后再返回到最外层元素;
  • 停止冒泡使用事件对象的stopPropagation方法。(IE中有所不同,是cancelBubble属性)。

例如你有两个div元素是嵌套起来的,两者都有onClick事件处理函数,如果用户点击内层元素,那么两个div的onClick事件都会触发。
但是,具体是内层还是外层先触发?对于这一问题,早期Netscape和MSIE各有一套处理办法:

  • Netscape主张外层元素先触发,事件的触发顺序是由外往内,这样的事件模型称之为捕获型
  • MSIE主张内层元素先触发,事件触发顺序是由内往外,这样的事件模型称之为冒泡型

W3C为了避免冲突,于是将事件分为3个阶段:事件先是从捕获阶段开始,由外往内;到达触发事件的元素之后,在节点上触发事件,称之为目标阶段;最后再开始冒泡阶段,由内往外

如果你是Web开发人员,你可以选择在捕获阶段处理事件,或是在冒泡阶段处理事件,具体取决于你在绑定事件时使用的addEventListener函数的第三个参数。
当然,如果你在某个元素上绑定了监听器,那么这个元素自身上触发的事件一定会触发这个监听器,无论它监听哪一个阶段,因为这个元素为事件的目标,不存在冒泡还是捕获。
具体如何绑定监听器,可以向下继续阅读。

另外,可以在MDN找到有关事件类别参考以及事件对象的介绍,在阮一峰所编写的网道JS教程也有事件相关的介绍

事件简介

事件分为资源事件、网络事件、表单事件、鼠标键盘事件、媒体事件等多种类别。一部分事件被写入了Web规范当中,这些事件被称为标准事件。在MDN事件参考中可以查看所有事件以及标准事件。

在BOM中,所有事件对象都继承自Event类,具体还有几种类型,例如关系到鼠标点击的MouseEvent事件类型。用户可以令浏览器生成事件,例如点击或按键;同样的,其他资源也有可能生成事件,例如视频播放暂停、网页动画的开始结束等;除了这些方式以外,js代码也可以触发事件。

用户也可以通过js代码构造事件,也可以在处理事件的过程中取消事件。

通常情况下,浏览器对一些事件有默认行为。例如:点击<input type="submit">这个按钮会自动提交表单。如果你给该HTML元素添加返回false的处理函数,或者是取消了该事件,那么自动提交表单这个行为便会被阻止。

Web开发者要做的就是监听事件并对之做出处理,这就需要我们绑定事件的监听器来处理事件,处理事件的函数被称为事件处理函数。

注册事件监听

定义在HTML属性

这是最初级的事件绑定方式,一般刚开始学HTML的时候就会学到。在HTML节点上添加一个on开头的属性,后面接上事件名;属性的值则是一段js代码,它会被传入js引擎直接执行。常见的使用方式如下:

1
2
<button onclick="alert('Hello world!')">
<body onload="func1()">

第一行代码给这个按钮绑定了click事件,用户点击后就会弹出提示框;第二行代码则会在页面加载的时候触发名为func1的js方法。

这种方式存在一些问题:

  • 首先,事件硬编码在HTML里面,因此事件的注册和事件处理的js代码分离开来了,不利于维护,IDE也会给出警告;
  • 其次,无法为一个元素绑定多个事件;
  • 这种事件处理方式固定在冒泡阶段处理。

因此,Web开发中要避免这种通过HTML属性来绑定事件的方式。

定义为js代码中HTML元素的属性

执行以下js代码:

1
2
3
4
5
6
let button = document.getElementById('btn');
button.onclick = function(e){
alert('Hi!');
}
//也可以是一个函数名,不需加括号,例如:
//button.onclick = func1;

这里在button这个HTML元素对象上定义了一个名为onclick的方法,on后面接的click便是监听的事件名,赋值的一个匿名函数则是事件处理函数。
这种方法实际上是上面第1种方法的翻版,它使用了HTML元素的GlobalEventHandlers接口,也是通过在HTML元素上添加一个属性,然后属性值为一个函数或者函数名。因此它也具备同样的问题:事件名作为属性是硬编码的;无法添加多个事件监听器;固定在冒泡阶段处理事件。

使用addEventListener方法

任何一个HTML元素都是继承了Element接口,而它继承了Node接口,而Node接口本身继承了EventTarget接口。因此,任何HTML元素或是节点对象实际上都继承了EventTarget接口。
需要注意的是,很多对象(例如windowXMLHttpRequest)也都实现了这个接口,这个接口可以看做是BOM的公共事件处理接口。

EventTarget接口有以下3个方法用于处理事件:

1
2
3
target.addEventListener(type, listener [,useCapture]);
target.removeEventListener(type, listener [,useCapture]);
target.dispatchEvent(event);

这三个方法分别表示绑定事件监听器、移除事件监听器、触发事件。
前两个方法的参数形式都相同,其中:

  • type 表示你要监听/移除/触发的事件名;
  • listener 表示事件处理函数;
  • useCapture 可选参数,布尔值,默认为false表示在事件在冒泡阶段被触发,如果是true表示事件在捕获阶段被触发。

这种方式是最完备也是最规范的事件监听方式,它提供了绑定、移除、触发方法,可以在一个HTML元素或节点上定义多个事件,甚至可以控制事件在冒泡阶段还是捕获阶段处理。
接下来详细讲解的便是这种事件监听方式。

EventTarget事件处理

addEventListener绑定事件处理函数

EventTarget接口有以下3个方法用于处理事件:

1
2
3
target.addEventListener(type, listener [,useCapture]);
target.removeEventListener(type, listener [,useCapture]);
target.dispatchEvent(event);

使用addEventListener方法,即可为元素绑定事件处理器。这种方法可以给一个元素绑定多个监听器,触发事件时,这些监听器会按照绑定的顺序依次被触发。
如何来使用,可以参考下面的例子:

1
2
3
4
5
6
function hello(){
console.log('Hi!');
}

let button = document.getElementById('btn');
button.addEventListener('click', hello, false);

这里我们就给一个id为btn的HTML元素绑定了监听器,当触发click事件时,即用户点击了这个元素,就会调用事件的处理函数hello( ),从而在控制台打印了一串字符。
注意它的第三个参数为false,表示在冒泡阶段处理事件,也就是说如果这个id为btn的元素内部还有其他的绑定了点击事件监听器的元素,点击这个内部的元素时,内部元素的事件先触发,然后再冒泡到这个元素触发hello( )函数。
将第三个参数改为true,则会提前捕获事件,那么再点击内部的元素,先触发的就是这个hello( )了。

addEventListener方法有以下几个特点:
首先,多次添加同一阶段且同一处理函数的监听器是无效的:

1
2
button.addEventListener('click', hello, false);
button.addEventListener('click', hello, false);

例如上面的代码,第二行实际上是无效的,因为同阶段同处理函数的监听器只能绑定一次。
如果第二行的参数false改成true那么第二行代码就都有效了。
注意,如果将两行代码中的参数hello分别换成两个完全相同的匿名函数,例如都改成function(){},也是两行代码都有效的,因为匿名函数始终彼此不同,浏览器会把它们当成两个不同的处理函数,即使代码完全相同。

其次,触发监听器的时候,会向处理函数中传入参数:
将上面的js代码改为:

1
2
3
4
5
6
7
function hello(e){
console.log(e);
console.log(this);
}

let button = document.getElementById('btn');
button.addEventListener('click', hello, false);

这时,当我们点击这个id为btn的元素时,控制台会先打印出一个MouseEvent的对象,这是我们所触发的点击事件的一个事件对象,然后再打印出点击的这个元素,即this对象,这是点击事件触发的原始目标
浏览器做了以下操作:

  • 当事件触发的时候,浏览器会把这次事件触发的详细信息(例如如果是鼠标事件,则包含位鼠标的位置、点击的元素、是否按下了Ctrl和Alt这些按键等信息)包装成一个事件对象,作为第一个参数传给这个事件处理函数。这里就会把事件对象传入hello(e)函数,作为其中的参数e。
  • 同时,事件处理函数内部的this会指向这个触发事件的元素。如果你不想改变事件处理函数内部的this值,使用箭头函数即可,它会绑定其定义时的作用域作为内部的this

此外,该方法的第二和第三个参数都可以更详细的配置:
第二个listener参数,除了可以是一个函数名、一个回调的定义之外,也可以是一个具备handleEvent属性的对象,事件将使用这个handleEvent属性的函数来处理。
例如是这么一个对象:

1
2
3
4
5
6
{
//这个handleEvent成员即为事件处理函数
handleEvent: function (e) {
console.log('Hi!');
}
}

第三个useCapture参数,除了可以是一个布尔值之外,还可以是一个对象,形式如下:

1
2
3
4
5
6
{
//以下三个属性默认都是false
capture: true, //表示是否在捕获阶段处理事件
once: true, //事件是否只触发一次并自动移除
passive: true //浏览器将忽略监听函数调用preventDefault方法
}

removeEventListener移除事件处理函数

removeEventListener函数的签名和绑定事件处理函数的方法相同:

1
target.removeEventListener(type, listener [,useCapture]);

这里要注意的是,如果想移除某一个事件监听器,该方法的三个参数必须与绑定时的三个参数完全相同
浏览器js引擎会将匿名函数判断为互不相同,即使它们的代码相同。
例如下面代码所示的情况:

1
2
3
button.addEventListener('click', function (e) {}, false);
button.removeEventListener('click', function (e) {}, false);
//第二行代码无法移除前一行代码绑定的事件监听器

因此,如果你想在将来移除某个事件处理函数,那么定义它的时候尽量不要使用匿名函数,否则想移除它,会很麻烦。

dispatchEvent触发事件

dispatchEvent(event)用于在当前节点上触发事件,其中event参数是一个Event类型的对象,这是一个事件对象dispatchEvent(event)的参数为空或不是事件对象时,会报错。
它的使用方法如下:

1
2
3
4
button.addEventListener('click', hello, false);
//下面定义一个事件对象,并用它触发事件
let e = new Event('click');
button.dispatchEvent(e);

上述代码在button这个节点上触发了click事件。
dispatchEvent方法有一个返回值,表示事件是否被取消了。如果被事件取消了,它的返回值便是true

1
2
//如果事件被取消了,则这里的c为true
let c = button.dispatchEvent(e);

Event事件对象

构造函数

上面说了,当浏览器生成事件后,同时会产生一个事件对象作为参数传入事件处理函数中;我们使用dispatchEvent方法来触发事件的时候也必须传入一个事件对象。
你可以使用以下构造函数来创建事件对象:

1
new Event(type [,options])

第一个参数type表示事件名称,例如clickmouseover等;
第二个参数是一个配置对象,它的形式如下:

1
2
3
4
5
{
//以下属性均默认为false
bubbles: true, //表示事件对象是否冒泡
cancelable: true //表示事件是否可取消
}

这里需要注意,只有将bubbles显示设置为true,事件才会有冒泡阶段,否则生成的事件便只存在于捕获阶段;
cancelable表示事件能否被e.preventDefault( )取消,事件被取消就好像从未发生过,浏览器的默认行为也不会被触发。

事件对象实例的属性

由一个事件的触发而被浏览器创建出的事件对象,它具备这些属性:
.bubbles:布尔值,只读,表示事件是否会冒泡;
.eventPhase:只读,表示事件处在何种阶段,意义如下:0事件未发生,1捕获阶段,2目标阶段,3冒泡阶段;
.cancelable :布尔值,只读,表示事件是否可被取消。浏览器生成的原生事件大都是可被取消的,用户构造的则默认是不可被取消的。不可被取消的事件,其preventDefault( )方法无效;
.cancelBubble:布尔值,设置true时等同于调用stopPropagation( ),可以阻止事件传播;
.defaultPrevented:布尔值,只读,表示是否曾调用过该事件的preventDefault( )方法;
.currentTarget.target:表示事件传播到的当前节点、事件触发时的原始节点;
.type:事件类型名,例如click。当然也有可能是用户构造时传入的名字;
.timeStamp:毫秒时间戳,表示事件发生的时间,自网页加载成功开始计算;
.isTrusted:布尔值,表示事件是否用真实的用户行为产生;
.detail:只有浏览器UI的事件才有此属性,表示用户操作的具体类型,例如点击为1双击为2等。

此外,事件对象具备以下方法:
.preventDefault( ):取消浏览器的默认行为,例如点击链接后,浏览器会进行跳转,而使用该方法后浏览器就不会跳转了。事件对象的cancelable如果不为true,该方法无效;
.stopPropagation( ):阻止事件在DOM中继续传播,别的节点上定义的监听器均不会被触发了,不过当前节点上其他的监听器还是会触发;
.stopImmediatePropagation( ):同上,并且令当前节点上其他监听器也不会触发了。
.composedPath( ):返回一个数组,内容是从事件最内层节点依次至冒泡到的最外层,注意它会冒泡直到htmldocument最后到window

事件对象类型

事件类型有很多种,例如MouseEventKeyboardEventInputEvent等,它们都继承自Event。除了系统产生一个事件对象之外,用户还可以自行构造,如下:

1
let e = new MouseEvent(type [,options]);

第二个参数可以配置该事件对象的属性。

MouseEvent为例,它的事件对象具备以下属性:
screenXscreenYclientXclientY:鼠标位置相对于屏幕/程序窗口的水平/垂直位置;
ctrlKeyshiftKeyaltKeymetaKey:触发点击事件时是否按下了响应的快捷键;
button:表示按下的鼠标键:0为左键,1为中键,2为右键;
buttons:是3个二进制比特位,从左(高位)往右(低位)依次表示中键、右键、左键;
relatedTarget:表示事件相关节点,默认为null,如果是mouseentermouseover事件,表示鼠标离开的节点;如果是mouseoutmouseleave事件,表示鼠标将进入的节点;

还有其他属性不再赘述

如果以KeyboardEvent事件为例,它具备表示按下按键的codekey属性。
不同类型的事件具备不同的属性,具体可以查看网道JS教程

用户也可以自行生成对象,使用的类型为CustomEvent,可以用它的构造函数直接创建,它的第二个参数只有一个detail属性,表示事件要携带的额外信息。