前端复习之事件系统

事件是用户与界面交互的基础。

什么是事件驱动

JavaScript是一种事件驱动语言。那么到底什么是事件驱动呢?

事件驱动编程是一种编程范式。在事件驱动这种范式中,执行什么操作是由事件来决定的,比如用户点击事件,其他编程线程事件,数据库请求事件。事件的处理由事件处理函数或回调函数来完成。

Event callback is a function that is invoked when something significant happens like when click event is performed by user or the result of database query is available.

事件回调函数由某个特定的事件来触发,比如用户点击或者请求数据库数据。

I/O编程 VS 事件驱动编程

I/O编程规定事件在某个进程结束之后执行。我们来看一组对比:

1
2
3
// I/O编程
let result = query("SELECT * FROM posts WHERE id = 1");
myResult(result);

在I/O编程中,myResult函数的执行需要等数据库请求这一进程结束之后才会执行。

1
2
3
4
5
// 事件驱动编程
let query_finished = function(result){
myResult(result);
}
query("SELECT * FROM posts WHERE id = 1", query_finished);

在事件驱动编程中,先定义在数据库请求结束之后要进行什么操作,并将这个操作存储在一个叫做query_finished的函数中,然后将这个函数作为参数传递给数据库请求这一事件。当数据库请求结束时,就会自动触发query_finished函数,而不是仅仅返回请求结果。

像这种通过定义在特定事件发生时触发特定函数,而非仅仅返回一个结果值的编程风格就叫做事件驱动或异步编程。这种编程风格在执行I/O时,当前进程不会发生阻塞,且可以多个I/O同时进行,每一个I/O的回调函数都会在操作结束时被触发。

可以把事件驱动联想成一个联动装置,一个动作会触发另一个动作,即一个事件触发一个函数。

事件循环 Event Loop

和事件驱动相辅相成的是事件循环机制。事件循环机制是在不断的循环过程中实现两大功能:

  • 事件监测
  • 事件处理器触发

事件循环机制监测任意时间内是否有某个事件发生,如果有,则需要触发相应的事件回调函数。

JavaScript的事件循环是一个大的话题,这里我们简单梳理一下:

浏览器是多进程的

浏览器是多线程运行的,每打开一个tab标签页,都会有独立的进程被创建出来。如果我们打开任务管理器就会发现,浏览器中每个tab也都有一个独立的进程,此外浏览器还有一个主进程。

浏览器包含哪些进程
  • Browser进程 是浏览器的主进程(有且只有一个)。负责浏览器界面显示、页面管理、网络资源管理等
  • 第三方插件进程 每种类型的插件对应一个进程(仅当使用时才创建)
  • GPU进程 最多一个,用于3D绘制等
  • 浏览器渲染进程 是浏览器内核,默认每个Tab页面一个进程,用于页面渲染、脚本执行、事件处理等
浏览器渲染进程

浏览器渲染进程是浏览器内核,页面渲染、JS的执行、事件的循环都在这个进程中进行。渲染进程是多线程的。渲染进程中包括以下线程:

  • GUI渲染线程 负责渲染浏览器界面(解析HTML、CSS等)、重绘和回流,它与JS引擎线程互斥
  • JS引擎线程 负责处理JS脚本程序
  • 事件触发线程 用来控制时间循环
  • 定时触发器线程 setTimeoutsetInterval所在线程
  • 异步http请求线程

其中,JS引擎线程用于处理任务,所有的同步任务都在这个线程上执行,形成一个执行栈

在这个线程之外,还有一个“任务队列”,其他线程的异步任务在满足触发条件后,就添加到“任务队列”中,表示这个事件可以进入“执行栈”了。主线程在空闲时会从“任务队列”中读取事件,它执行这个事件的方式就是执行这一事件对应的回调函数。

“任务队列”是一个先进先出的数据结构,排在前面的事件优先被读取,主线程的读取过程是自动的,只要执行栈一清空,“任务队列”上的第一位事件就自动进入主线程。

主线程的这一不断执行——读取“任务队列”——执行的运行机制成为Event Loop(事件循环)

image-20180905221100238

JS的多线程:Web Worker

事件循环机制意味着如果页面中有大量的同步任务在执行,那么事件函数很可能需要长时间在消息队列中,无法被执行,导致页面看起来很迟钝,影响使用体验。

如果真的遇到这样的问题,浏览器提供了一个新的解决方案,叫做Web Workers。一个Worker就是一个独立于主程序的JS进程。

MDN的官方解释是:

Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面;

一个worker是使用一个构造函数创建的一个对象(e.g. Worker()) 运行一个命名的JavaScript文件, 这个文件包含将在工作线程中运行的代码;

workers 运行在另一个全局上下文中,不同于当前的window;

因此,使用 window快捷方式获取当前全局的范围 (而不是self) 在一个 Worker 内将返回错误

简单来说,

  • 创建Worker时,JS引擎向浏览器申请创建一个子线程(完全受主线程控制,且不能操作DOM)
  • JS引擎线程与worker线程之间通过特定的方式通信

比如我们需要一个独立的线程来帮我们作平方数运算,由于平方运算非常耗时,所以我们在一个叫做”squareworker.js”的文件中来写代码。

为了避免worker线程和其他线程同时操作相同的数据导致错误发生,workers线程不与主线程环境共享它的全局环境和数据,而是利用信息往返来实现数据交换。

1
2
3
4
5
6
7
8
9
10
11
12
// squareworker.js中输出结果数据
addEventListener("message", event => {
postMessage(event.data * event.data);
})

// main.js中创建worker
let squareWorker = new Worker("squareworker.js");
squareWorker.addEventListener("message", event => {
console.log("The worker responded:", event.data);
})
squareWorker.postMessage(10);
squareWorker.postMessage(24);

我们可以尝试在浏览器中打开(会存在兼容性问题,Chrome需要下载subworker.js文件支持,火狐可以正常打开)

image-20180905223822638

事件流

事件流指从页面中接收事件的顺序,也可以理解为事件在页面中传播的顺序。

背景

早期的IE事件传播方向是从上至下,即从document逐级向下传播到模板元素;而Netscape Navigator则是相反的顺序,从目标元素开始向上逐级传播至window。

后来ECMAScript在DOM2中对事件流进行了进一步规范,基本上是上述两者的结合。事件流要经历三个阶段:

事件捕获

当事件发生时,document对象会发出一个事件流,由上至下逐级DOM节点传播,直到到达触发事件的目标元素为止,这个过程叫做事件捕获

事件处理

到达目标元素之后,执行目标元素绑定的处理函数。如果目标元素没有绑定监听函数,则不做任何处理。

事件冒泡

事件流从目标元素开始,向最外层的DOM节点逐级依次传递,途中如果有节点绑定了事件处理函数,这些函数会被执行。

对于事件处理器来说,可以调用stopPropagation方法来取消传播,从而阻止目标对象的外层元素的事件处理器被触发。比如当一个按钮被包含在另一个可点击元素内,我们不希望点击按钮触发外层元素的点击事件时,就可以使用取消冒泡的方法。

image-20180905163425057

所以捕获先于冒泡执行。

事件委托

通过事件对象的target属性和冒泡过程,我们可以给父元素添加事件监听器,来监听和处理子元素的事件,避免重复为子元素绑定相同的事件。当目标元素被触发时,这个事件会向外传递,冒泡到父元素上,触发父元素绑定的事件函数,父元素可以通过event.target获取到被触发的目标元素。

比如有一个node节点包含一串button元素,点击按钮会触发相同的事件,那么给这个node节点绑定点击事件就会比给每一个button都绑定点击事件方便得多。而通过event.target即可得知哪一个button被点击了。

1
2
3
4
5
6
7
8
9
10
<button>A</button>
<button>B</button>
<button>C</button>
<script>
document.body.addEventListener("click", event => {
if (event.target.nodeName == "BUTTON") {
console.log("Clicked", event.target.textContent);
}
});
</script>

事件对象 Event Object

当我们使用事件处理函数时,实际上我们传递了一个参数:事件对象(event object)。这个对象包含了关于事件的其他信息,这些信息因事件类型的不同而不同。

事件对象只在事件发生时产生,并且只能在事件处理函数内部访问,在所有事件处理函数执行结束后,事件对象就被销毁。

事件对象的属性

bubbles

返回布尔值,表示事件是否是冒泡事件类型。

cancelable

返回布尔值,表示事件是否拥有可取消的默认动作。

target

大多数事件对象都有target属性,指的是触发事件的目标节点。我们可以通过这个属性来确保触发的是目标元素,而不是冒泡过程中意外触发的其他节点。

type

返回当前事件对象表示的事件的名称。

事件对象的方法

preventDefault()

通知浏览器不要执行与事件关联的默认动作。

很多事件都有自己的默认动作,比如点击链接会自动跳转到链接的目标地址,点击右键会自动出现右键菜单。对于大多数事件类型来说,事件处理器会在默认动作前被调用,但是如果不希望默认动作出现,则可以使用preventDefault()事件来阻止默认动作触发。

但是建议只有在必需的时候使用这一方法,因为破坏用户习惯的默认动作会带来不好的使用体验。此外,在某些浏览器上,有些事件的默认动作无法被完全屏蔽,比如在chrome上,关闭当前标签页的快捷键就无法被改变。

stopPropagation()

不要派发事件。

兼容性问题

事件对象也存在一些兼容性问题。

事件对象

IE8之前,注册事件处理函数时,事件对象并没有被传递到函数内部,因此需要使用window.event来获取事件:

1
2
3
function getEvent(event) {
event = event || window.event;
}
preventDefault()

IE没有preventDefault()方法,所以我们需要设置的是:

1
window.event.returnValue = false;
stopPropagation()

IE也没有stopPropagation()方法,所以需要设置cancelBubble属性

1
event.cancelBubble = true;

事件处理

有个三种方式来给元素添加事件:

  • Inline event handlers 内联事件处理器
  • Event handler properties 事件处理属性
  • Event listeners 事件监听器

内联事件处理器

内联事件处理器,也称为HTML事件处理程序,是指直接给元素添加事件属性,事件属性的属性值为事件函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
<button onclick="changeText()">
Click me!
</button>
<p>
Try to change me.
</p>

<script>
const changeText = () => {
const p = document.querySelector("p");
p.textContent = "I changed because of an inline event handler.";
}
</script>

当我们打开页面时:

First rendering of events.html

当我们点击按钮后:

First response to event on events.html rendering

内联事件处理器是最直接的理解事件的方式,但是在实际编程过程中,除了测试和教学目的,很少真正使用它们。

内联事件处理器就和CSS内联样式一样,不易于代码维护,所以不建议使用。

事件处理属性(DOM0级事件处理程序)

事件处理属性的触发机制和内联事件处理器类似,只不过我们通过JavaScript来设置元素的属性,而不是直接在HTML中设置。

1
2
3
4
5
6
7
8
9
10
11
<button>Click me!</button>
<p>Try to change me.</p>

<script>
const changeText = () => {
const p = document.querySelector("p");
p.textContent = "I changed because of an event handler property.";
}
const button = document.querySelector("button");
button.onclick = changeText;
</script>

这里需要注意两个细节:

  • 通过JavaScript来添加onclick属性时,onclick并不用遵循驼峰命名法写成onClick
  • onclick属性添加函数作为属性值时,不需要添加括号(),因为我们仅将函数传递给属性,而不是在此时触发函数

通过button.onclick = null可以删除事件处理程序。

通过事件处理属性来添加函数比内联处理器要易于维护,但它同样有一些缺陷,比如它无法传递多个事件处理函数给同一个事件,如果给点击事件赋上多个onclick属性值,后一个事件处理函数会覆盖前一个函数。此外,这种方法也不能控制事件流到底是捕获还是冒泡。

事件监听器

事件监听器用于监听元素上的事件,我们通过addEventListener()方法来给监听事件。

1
2
3
4
5
6
7
8
9
10
11
<button>Click me!</button>
<p>Try to change me.</p>

<script>
const changeText = () => {
const p = document.querySelector("p");
p.textContent = "I changed because of an event listener.";
}
const button = document.querySelector("button");
button.addEventListener("click", changeText);
</script>

addEventListener()接受三个参数:

  1. 要处理的事件名(不需要添加前缀on
  2. 作为事件处理程序的函数
  3. 一个Boolean值,默认false表示使用冒泡机制,true表示使用捕获机制

addEventListener()有以下优势:

  • addEventListener()可以给同一个元素的同一个事件添加多个函数(同一个事件触发同一个函数的情况,添加数次只生效一次),触发顺序即添加顺序
  • 可以给事件添加匿名函数
  • 可以通过removeEventListener()来移除所有事件
  • 可以给documentwindow对象来添加事件

因此,addEventListener()是目前JavaScript中使用最广泛的事件处理方法。

IE处理方法

IE9之前都不支持addEventListener()方法,而是使用两个相似的方法:

  • attachEvent() 添加事件处理器
  • detachEvent() 删除事件处理器

它们都接受两个参数,第一个是事件处理程序的名称(不是事件,所以要添加on前缀);第二个是事件处理函数;之所以没有第三个参数是因为IE8之前只支持冒泡事件流。

由于支持的是冒泡事件流,如果给元素绑定多个事件处理函数,事件触发的顺序是添加顺序的相反顺序。

attachEvent有一个缺点,不同于addEventListener()this值指向目标元素,attachEvent()this值指向window对象。

跨浏览器的写法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let EventUtil = {
addHandler: 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;
}
},
removeHandler: 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;
}
}
}

常见事件

鼠标事件

Event Description
click 鼠标点击触发
dbclick 鼠标双击触发
mouseenter 鼠标进入元素触发
mouseleave 鼠标移出元素触发
mousemove 每一次鼠标在元素内移动触发

click点击事件包括了mousedownmouseup事件。

表单事件

Event Description
submit 表单提交时触发
focus 某个元素获得焦点时触发
blur 元素失去焦点时触发

键盘事件

Event Description
keydown 当某个键被按下时触发
keyup 当某个键被松开时触发
keypress 当某个字符键被按时触发(非字符键如alt,shift,ctrl)

键盘事件是一种特殊的事件,它触发的事件对象能够返回按键的相关属性,如keyCode,key,code。我们以字母a为例:

Property Description Example
keyCode 按键对应的键码值 65
key 按键名 a
code 按键的Unicode字符 KeyA

触摸事件

Event Description
touchstart 当一个手指开始触碰屏幕
touchmove 当一个手指在屏幕上移动
touchend 当手指离开屏幕

手势事件

Event Description
gesturestart 两只手指触碰屏幕
gesturechange 任意手指移动
gestureend 某一只手指离开屏幕,只剩一只手指在屏幕上

触发手势事件的时候也会触发触摸事件。

滚动事件

Event Description
scroll 当元素开始滚动

参考:

Introduction to Event-Driven Programming

Understanding Events in JavaScript

Handling Events:: Eloquent JavaScript

前端小知识–JavaScript事件流

JavaScript 运行机制详解:再谈Event Loop