事件
参考:js 高级程序设计 第四版
js 与 HTML 的交互是通过事件实现的,事件代表文档或浏览器窗口中某个有意义的时刻。 可以使用仅在事件发生时执行的监听器(也叫处理程序)订阅事件。在传统软件工程领域,这个模型叫 “观察者模式”,其能够做到页面行为(在 js 中定义)与页面展示(在 HTML 和 CSS 中定义)的分离。
# 一、事件流
在第四代 Web 浏览器(IE4 和 Netscape Communicator 4)开始开发时,开发团队碰到了一个有意思 的问题:页面哪个部分拥有特定的事件呢?
要理解这个问题,可以在一张纸上画几个同心圆。把手指放 到圆心上,则手指不仅是在一个圆圈里,而且是在所有的圆圈里。两家浏览器的开发团队都是以同样的 方式看待浏览器事件的。当你点击一个按钮时,实际上不光点击了这个按钮,还点击了它的容器以及整 个页面。
# 1. 事件冒泡
IE 事件流被称为事件冒泡,这是因为事件被定义为从最具体的元素(文档树中最深的节点)开始触 发,然后向上传播至没有那么具体的元素(文档)。比如有如下 HTML 页面:
<!DOCTYPE html>
<html>
<head>
<title>Event Bubbling Example</title>
</head>
<body>
<div id="myDiv">Click Me</div>
</body>
</html>
2
3
4
5
6
7
8
9
在点击页面中的
(1) <div>
(2) <body>
(3) <html>
(4) document
也就是说,<div>元素,即被点击的元素,最先触发 click 事件。然后,click 事件沿 DOM 树一 路向上,在经过的每个节点上依次触发,直至到达 document 对象。
下图形象地展示了这个过程。
# 2. 事件捕获
事件捕获的意思是最不具体的节 点应该最先收到事件,而最具体的节点应该最后收到事件。事件捕获实际上是为了在事件到达最终目标前拦截事件。
如果前面的例子使用事件捕获,则点击<div>元素会以下列顺序触发 click 事件:
(1) document
(2) <html>
(3) <body>
(4) <div>
在事件捕获中,click 事件首先由 document 元素捕获,然后沿 DOM 树依次向下传播,直至到达实际的目标元素<div>。这个过程如下图所示。
# 3.DOM 事件流
DOM2 Events 规范规定事件流分为 3 个阶段:事件捕获、到达目标和事件冒泡。
事件捕获最先发生, 为提前拦截事件提供了可能。然后,实际的目标元素接收到事件。最后一个阶段是冒泡,最迟要在这个 阶段响应事件。
仍以前面那个简单的 HTML 为例,点击<div>元素会以如下图所示的顺序触发事件。
在 DOM 事件流中,实际的目标(<div>元素)在捕获阶段不会接收到事件。这是因为捕获阶段从 document 到<html>再到<body>就结束了。下一阶段,即会在<div>元素上触发事件的“到达目标” 阶段,通常在事件处理时被认为是冒泡阶段的一部分。然后,冒泡阶段开始,事件反向传 播至文档。
# 二、事件对象
在 DOM 中发生事件时,所有相关信息都会被收集并存储在一个名为 event 的对象中。这个对象包 含了一些基本信息,比如导致事件的元素、发生的事件类型,以及可能与特定事件相关的任何其他数据。 例如,鼠标操作导致的事件会生成鼠标位置信息,而键盘操作导致的事件会生成与被按下的键有关的信 息。所有浏览器都支持这个 event 对象,尽管支持方式不同。
# 1. DOM 事件对象
在 DOM 合规的浏览器中,event 对象是传给事件处理程序的唯一参数。 下面的例子展示了在两种方式下都可以使 用事件对象:
let btn = document.getElementById('myBtn')
btn.onclick = function(event) {
console.log(event.type) // "click"
}
btn.addEventListener(
'click',
(event) => {
console.log(event.type) // "click"
},
false
)
2
3
4
5
6
7
8
9
10
11
这个例子中的两个事件处理程序都会在控制台打出 event.type 属性包含的事件类型。这个属性中 始终包含被触发事件的类型,如"click"(与传给 addEventListener()和 removeEventListener() 方法的事件名一致)。
在事件处理程序内部,this 对象始终等于 currentTarget 的值,而 target 只包含事件的实际 目标。
如果事件处理程序直接添加在了意图的目标,则 this、currentTarget 和 target 的值是一样 的。下面的例子展示了这两个属性都等于 this 的情形:
let btn = document.getElementById('myBtn')
btn.onclick = function(event) {
console.log(event.currentTarget === this) // true
console.log(event.target === this) // true
}
2
3
4
5
上面的代码检测了 currentTarget 和 target 的值是否等于 this。因为 click 事件的目标是按 钮,所以这 3 个值是相等的。如果这个事件处理程序是添加到按钮的父节点(如 document.body)上, 那么它们的值就不一样了。比如下面的例子在 document.body 上添加了单击处理程序:
document.body.onclick = function(event) {
console.log(event.currentTarget === document.body) // true console.log(this === document.body); // true console.log(event.target === document.getElementById("myBtn")); // true
}
2
3
这种情况下点击按钮,this 和 currentTarget 都等于 document.body,这是因为它是注册事件 处理程序的元素。而 target 属性等于按钮本身,这是因为那才是 click 事件真正的目标。由于按钮 本身并没有注册事件处理程序,因此 click 事件冒泡到 document.body,从而触发了在它上面注册的 处理程序。
type 属性在一个处理程序处理多个事件时很有用。比如下面的处理程序中就使用了 event.type:
let btn = document.getElementById('myBtn')
let handler = function(event) {
switch (event.type) {
case 'click':
console.log('Clicked')
break
case 'mouseover':
event.target.style.backgroundColor = 'red'
break
case 'mouseout':
event.target.style.backgroundColor = ''
break
}
}
btn.onclick = handler
btn.onmouseover = handler
btn.onmouseout = handler
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
两个常见的事件方法
preventDefault()方法用于阻止特定事件的默认动作。比如,链接的默认行为就是在被单击时导 航到 href 属性指定的 URL。
stopPropagation()方法用于立即阻止事件流在 DOM 结构中传播,取消后续的事件捕获或冒泡。
例:
let btn = document.getElementById('myBtn')
btn.onclick = function(event) {
console.log('Clicked')
event.stopPropagation()
}
document.body.onclick = function(event) {
console.log('Body clicked')
}
2
3
4
5
6
7
8
Vue 的事件修饰符的 .prevent 和 .stop 实现方式对应 preventDefault()和 stopPropagation()
# 三、用户界面事件
几种常见的用户界面事件
# 1. load 事件
load 事件可能是 JavaScript 中最常用的事件。在 window 对象上,load 事件会在整个页面(包括所有外部资源如图片、JavaScript 文件和 CSS 文件)加载完成后触发。
可以通过两种方式指定 load 事 件处理程序。第一种是 JavaScript 方式,如下所示:
window.addEventListener('load', (event) => {
console.log('Loaded!')
})
2
3
这是使用 addEventListener()方法来指定事件处理程序。与其他事件一样,事件处理程序会接 收到一个 event 对象。这个 event 对象并没有提供关于这种类型事件的额外信息,虽然在 DOM 合规 的浏览器中,event.target 会被设置为 document,但在 IE8 之前的版本中,不会设置这个对象的 srcElement 属性。
第二种指定 load 事件处理程序的方式是向<body>元素添加 onload 属性,如下所示:
<!DOCTYPE html>
<head>
<title>Load Event Example</title>
</head>
<body onload="console.log('Loaded!')">
</body>
</html>
2
3
4
5
6
7
一般来说,任何在 window 上发生的事件,都可以通过给<body>元素上对应的属性赋值来指定, 这是因为 HTML 中没有 window 元素。这实际上是为了保证向后兼容的一个策略,但在所有浏览器中都 能得到很好的支持。实际开发中要尽量使用 JavaScript 方式。
图片上也会触发 load 事件,包括 DOM 中的图片和非 DOM 中的图片。可以在 HTML 中直接给 元素的 onload 属性指定事件处理程序,比如:
<img src="smile.gif" onload="console.log('Image loaded.')" />
这个例子会在图片加载完成后输出一条消息。同样,使用 JavaScript 也可以为图片指定事件处理程序:
let image = document.getElementById('myImage')
image.addEventListener('load', (event) => {
console.log(event.target.src)
})
2
3
4
这里使用 JavaScript 为图片指定了 load 事件处理程序。处理程序会接收到 event 对象,虽然这个 对象上没有多少有用的信息。这个事件的目标是<img>元素,因此可以直接从 event.target.src 属 性中取得图片地址并打印出来。
在通过 JavaScript 创建新<img>元素时,也可以给这个元素指定一个在加载完成后执行的事件处理 程序。在这里,关键是要在赋值 src 属性前指定事件处理程序,如下所示:
window.addEventListener('load', () => {
let image = document.createElement('img')
image.addEventListener('load', (event) => {
console.log(event.target.src)
})
document.body.appendChild(image)
image.src = 'smile.gif'
})
2
3
4
5
6
7
8
# 2. resize 事件
当浏览器窗口被缩放到新高度或宽度时,会触发 resize 事件。这个事件在 window 上触发,因此 可以通过 JavaScript 在 window 上或者为<body>元素添加 onresize 属性来指定事件处理程序。优先使 用 JavaScript 方式:
window.addEventListener('resize', (event) => {
console.log('Resized')
})
2
3
类似于其他在 window 上发生的事件,此时会生成 event 对象,且这个对象的 target 属性在 DOM 合规的浏览器中是 document。IE、Safari、Chrome 和 Opera 会在窗口 缩放超过 1 像素时触发 resize 事件,然后随着用户缩放浏览器窗口不断触发。无论如何,都应该避免在这个事件处理程序中执行过多 计算。否则可能由于执行过于频繁而导致浏览器响应明确变慢。
注意 浏览器窗口在最大化和最小化时也会触发 resize 事件
# 四、事件委托
“过多事件处理程序”的解决方案是使用事件委托。事件委托利用事件冒泡,可以只使用一个事件 处理程序来管理一种类型的事件。例如,click 事件冒泡到 document。这意味着可以为整个页面指定 一个 onclick 事件处理程序,而不用为每个可点击元素分别指定事件处理程序。比如有以下 HTML:
<ul id="myLinks">
<li id="goSomewhere">Go somewhere</li>
<li id="doSomething">Do something</li>
<li id="sayHi">Say hi</li>
</ul>
2
3
4
5
这里的 HTML 包含 3 个列表项,在被点击时应该执行某个操作。对此,通常的做法是指定 3 个事件处理程序:
let item1 = document.getElementById('goSomewhere')
let item2 = document.getElementById('doSomething')
let item3 = document.getElementById('sayHi')
item1.addEventListener('click', (event) => {
location.href = 'http:// www.wrox.com'
})
item2.addEventListener('click', (event) => {
document.title = "I changed the document's title"
})
item3.addEventListener('click', (event) => {
console.log('hi')
})
2
3
4
5
6
7
8
9
10
11
12
如果对页面中所有需要使用 onclick 事件处理程序的元素都如法炮制,结果就会出现大片雷同的 15 只为指定事件处理程序的代码。使用事件委托,只要给所有元素共同的祖先节点添加一个事件处理程序, 就可以解决问题。比如:
let list = document.getElementById('myLinks')
list.addEventListener('click', (event) => {
let target = event.target
switch (target.id) {
case 'doSomething':
document.title = "I changed the document's title"
break
case 'goSomewhere':
location.href = 'http:// www.wrox.com'
break
case 'sayHi':
console.log('hi')
break
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这里只给<ul id="myLinks">元素添加了一个onclick事件处理程序。因为所有列表项都是这个 元素的后代,所以它们的事件会向上冒泡,最终都会由这个函数来处理。但事件目标是每个被点击的列 表项,只要检查 event 对象的 id 属性就可以确定,然后再执行相应的操作即可。相对于前面不使用事 件委托的代码,这里的代码不会导致先期延迟,因为只访问了一个 DOM 元素和添加了一个事件处理程 序。结果对用户来说没有区别,但这种方式占用内存更少。所有使用按钮的事件(大多数鼠标事件和键 盘事件)都适用于这个解决方案。
只要可行,就应该考虑只给 document 添加一个事件处理程序,通过它处理页面中所有某种类型的 事件。相对于之前的技术,事件委托具有如下优点。
document 对象随时可用,任何时候都可以给它添加事件处理程序(不用等待 DOMContentLoaded 或 load 事件)。这意味着只要页面渲染出可点击的元素,就可以无延迟地起作用。
节省花在设置页面事件处理程序上的时间。只指定一个事件处理程序既可以节省 DOM 引用,也 24 可以节省时间。
减少整个页面所需的内存,提升整体性能。
最适合使用事件委托的事件包括:click、mousedown、mouseup、keydown 和 keypress。 mouseover 和 mouseout 事件冒泡,但很难适当处理,且经常需要计算元素位置(因为 mouseout 会 在光标从一个元素移动到它的一个后代节点以及移出元素之外时触发)。