温故知新,谈谈浏览器的原生事件

1. 前言

在现在越来越依赖框架之后,发现框架中的事件绑定已经滚瓜烂熟了,但是原生 HTML 的事件绑定却会发生有时候突然忘了的尴尬,并且在当时什么都不懂的时候,事件绑定这块一直是个坑。那么就来在这里温故一下原生的事件绑定,结合一些新的思想,来重新回顾一下这一块。

2. 事件处理器属性

任何一个 Element 对象都会有相对应的属性,事件处理器属性就是将事件绑定给我们的一个 Element 对象,使函数成为其自身的一个属性,最常见的方式:

1
2
3
4
var btn = document.querySelector('button');
btn.onclick = function(){
console.log(1);
}

我们还可以写成:

1
2
3
4
5
var btn = document.querySelector('button');
function handleClick(){
console.log(1);
}
btn.onclick = handleClick

那么问题来了,我们如何向一个 handle 函数传递一个参数呢?或许会写成:

1
2
3
4
5
var btn = document.querySelector('button');
function handleClick(msg){
console.log(msg);
}
btn.onclick = handleClick("this is a message")

这时候就会发现,在网页初始化时已经执行了该函数,此时 btn.onclick 的值已经被赋为了 handleClikc 函数执行后的返回值,这是因为我们在写入 handleClick("this is a message") 就意味着函数在此出执行,并将函数返回值赋值给等号前的对象,说的高大上一点就是做了一次 LHS 引用。

所以呢,如何解决呢?这一招就是从 React 事件绑定学来的,既然 onclick 的值是一个待执行函数,那么我们就在此定义一个函数将目标函数包裹起来,此时外部函数未执行,内部函数引用存放于堆内存中,此时也不会执行。于是我们就可以这样绑定:

1
2
3
4
5
6
7
var btn = document.querySelector('button');
function handleClick(msg){
console.log(msg);
}
btn.onclick = function() {
handleClick("this is a message")
}

这里写一个事件节流函数,主要使用了这个方式传递节流函数的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<body>
<button id="button">Click me!</button>
<script>
function throttle(fn, time) {
let startTime = 0
return function () {
const args = Array.from(arguments)
let currentTime = new Date().valueOf()
if (currentTime - startTime > time) {
fn.apply(this, args)
startTime = currentTime
}
}
}

const throttleClick = throttle(function (msg) {
console.log(msg);
}, 1000)

document.querySelector("#button").onclick = () => { throttleClick("msg") }
</script>
</body>

3. 行内事件处理器

行内事件处理器就是将执行函数作为 HTML 标签的一个属性写入,但是这是一种非常原始的写法,这种习惯也并不好。

我们在这里需要注意的是,与 事件处理器属性 不同,行内事件处理器是将函数的执行写于行内,举一个例子:

1
<button onclick="handleClick()">Click me!</button>

其意思就是当按钮被点击时,JS 引擎会解析 onclick 属性中的 js 代码并执行,而并非是事件处理器属性的为其绑定一个函数在其触发事件时被调用。就算是我们直接写一个纯 JS 代码也会被执行:

1
<button onclick="alert('Hello, this is my old-fashioned event handler!');">Press me</button>

但这样我们就可以直接对函数传参,不过为了养成一个良好的编程习惯,还是不推荐使用。

4. addEventListener removeEventListener

新的事件触发机制被定义在 Document Object Model (DOM) Level 2 Events Specification, 这个细则给浏览器提供了一个函数 — addEventListener()。这个函数和事件处理属性是类似的,但是语法略有不同

这个解释的逻辑就是,我们为一个 Element 添加了一个事件监听器,当被监听的事件触发之后,就会执行监听器中我们传入的函数。这样的思想有一个显而易见的好处,那就是可以针对一个元素的同一事件,添加多个监听,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
<body>
<button id="btn">btn</button>
<script>
function click1() {
console.log(1);
}
function click2() {
console.log(2);
}
document.querySelector("#btn").addEventListener('click', click1)
document.querySelector("#btn").addEventListener('click', click2)
</script>
</body>

点击后输出:

1
2
1
2

同时我们要注意 addEventListener 的第三个参数,是否开启事件捕获,默认为 false

addEventListener 是为事件添加了事件监听,相应的,我们还可以使用 removeEventListener 移除一个已添加的事件。在 SPA 应用中每个页面在销毁的时候需要注意注销页面上(并非组件上)挂载的事件,如 window 上绑定的滚动事件等:

1
document.querySelector("#btn").removeEventListener('click', click1)

addEventListener 仅支持 IE9+

5. 阻止行为

随着事件的发生,往往还伴随着一些不可预料的行为发生,我们需要阻止这些行为,从而解决他们对我们当前业务的影响。我们通常可以阻止的行为有:事件的默认行为、冒泡行为、捕获行为。

阻止默认行为:

使用 e.preventDefault() 可以阻止事件的默认行为,e 为当前的事件对象,如:

1
2
3
4
form.onsubmit = function(e) {
// 阻止了表单的默认提交
e.preventDefault()
}

阻止冒泡行为:

使用 e.stopPropagation() 可以阻止冒泡行为,e 为当前的事件对象。

阻止捕获行为:

事件捕获是默认阻止的,如果想要开启事件捕获就可以使用 Element.addEventListener() 的第三个参数设置为 true 来开启事件捕获。

6. 事件的冒泡与捕获

事件的触发被分为两个阶段,事件的冒泡阶段与事件的捕获阶段,当我们使用 Element.addEventListener() 添加元素事件时,该方法的第三个参数决定了当前绑定的函数所执行的阶段:

  • 默认为 true 时,被绑定的函数仅仅在冒泡阶段被调用;
  • 当修改为 false 时,被绑定的函数仅仅在捕获阶段被调用。

这里之前有一个误解,误以为第三个参数修改的是目标对象在触发事件时是否开启事件捕获。其实际上的意思时当前绑定的事件如果是由于子元素的事件触发而被动触发的,那么 addEventListener 的第三个参数,决定了当前的事件被动触发是在事件捕获阶段触发的还是在事件冒泡阶段触发的。

举个例子:

1
2
3
4
5
<div id="a">
<div id="b">
<div id="c"></div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#a {
width: 300px;
height: 300px;
background: pink;
}

#b {
width: 200px;
height: 200px;
background: blue;
}

#c {
width: 100px;
height: 100px;
background: yellow;
}
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
var a = document.getElementById("a"),
b = document.getElementById("b"),
c = document.getElementById("c");
c.addEventListener("click", function (event) {
console.log("c1");
});
c.addEventListener("click", function (event) {
console.log("c2");
}, true);

b.addEventListener("click", function (event) {
console.log("b");
}, true);

a.addEventListener("click", function (event) {
console.log("a1");
}, true);
a.addEventListener("click", function (event) {
console.log("a2")
});
a.addEventListener("click", function (event) {
console.log("a3");
event.stopImmediatePropagation();
}, true);
a.addEventListener("click", function (event) {
console.log("a4");
}, true);

效果:

当我们点击最内层的元素 c 时,输出的结果为:

1
2
a1
a3

当用户点击了最内层的元素 a 时,事件流首先进行捕获,查看父级元素上是否有设置捕获阶段触发的函数,然后依次触发父级元素上在事件捕获阶段所触发的事件。但是当进行到 event.stopImmediatePropagation() 这一行时,该方法阻断了接下来所有事件的执行,因此事件触发到此结束,a 元素上绑定的事件并没有被触发,更不用说事件冒泡阶段所触发的事件了,整体的流程如下:

当我们去掉 event.stopImmediatePropagation() 时,事件就按照正常的顺序被触发,具体的流程如下:

还有值得一提的时,我们可以看到最内层的元素 a 身上也绑定了两个事件,其中一个事件开启了事件捕获,那么当我们点击元素 a 本身的时候,其会按照事件顺序去触发事件,其触发事件的顺序并非与是否开启事件捕获相关。第三个参数所决定的永远只是当前绑定的事件被动触发的时机,而当事件被主动触发时,时按照书写顺序触发事件的