前言:
话不多说先看题:
document.body.addEventListener('click', () => {
setTimeout(() => {
console.log(1);
})
console.log(2);
}, false);
document.body.addEventListener('click', () => {
setTimeout(() => {
console.log(3);
})
Promise.resolve().then(() => console.log(4))
console.log(5);
}, true);
接下让让我们由浅入深,一步步解析本题。
事件流:
DOM操作中存在诸多事件,这些事件的发生过程就称为事件流。
它描述了事件从触发的源头开始,在 DOM
(文档对象模型)树中传播的过程。当一个事件在页面中的某个元素上被触发时,比如用户点击了一个按钮或者按下了一个键盘按键,这个事件不会仅仅局限于被触发的元素本身,而是会按照一定的顺序在 DOM
树中流动,这个流动的过程就是事件流。
事件流的三个阶段:
事件冒泡:
事件开始时有具体的元素接收,然后逐级向上传播到DOM最顶层结点过程
目标阶段:
到达的具体元素
事件捕获:
由DOM最顶层节点开始,然后逐级向下传播到最具体的元素接收的过程
如下图所示:
事件流执行流程:
如下代码所示我创建了三个盒子,并将其分别进行嵌套,并在每个盒子的身上注册了点击事件,打印盒子代表的字母。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#a {
width: 400px;
height: 400px;
background-color: rgb(245, 56, 13);
}
#b {
width: 200px;
height: 200px;
background-color: rgb(27, 140, 238);
}
#c {
width: 100px;
height: 100px;
background: pink;
}
</style>
</head>
<body>
<div id="a">a
<div id="b">b
<div id="c">
c
</div>
</div>
</div>
<script>
let a = document.getElementById('a')
let b = document.getElementById('b')
let c = document.getElementById('c')
a.addEventListener('click', () => {
console.log('a被点击');
})
b.addEventListener('click', () => {
console.log('b被点击');
},)
c.addEventListener('click', () => {
console.log('c被点击');
})
</script>
</body>
</html>
我们会发现点击了c元素,使得三个点击事件都被触发了。这是由于三个点击事件都在冒泡阶段触发,addEventListener
方法是能够接受三个参数的,分别为事件类型、回调函数、冒泡(false
)or捕获(true
)。当我们没有对addEventListener
传递第三个参数时,默认是false
,也就是事件默认在冒泡阶段触发。
让我们再次回到鼠标点击时的那一刻到底发生了什么?由于鼠标点击,浏览器检测到鼠标点击后,此时会触发addEventListener
这个异步操作。事件流开始出现(一次点击只会出现一次事件流),由于我们的鼠标是点击到了最里层的c盒子。由上图的事件流图可知目标阶段就是停留在了c盒子上,此时开始从c盒子沿着父盒子一次冒泡。又因为在三个监听函数里,默认都是在冒泡阶段触发打印,所以会从c盒子开始沿着父盒子开始打印。
捕获阶段
当我们将第三个参数填入true
时,会将该事件改造为在捕获阶段才执行
a.addEventListener('click', () => {
console.log('a被点击', true);
})
b.addEventListener('click', () => {
console.log('b被点击');
},)
c.addEventListener('click', () => {
console.log('c被点击');
})
当我们依旧点击c盒子时,此时打印结果是首先打印了a盒子,同样的由上图的事件流过程可知,首先进入捕获阶段,此时a盒子已经被捕获到了,因此是第一个被打印的。
阻止冒泡:
event.stopPropagation()
a.addEventListener('click', () => {
console.log('a被点击');
}, true)
b.addEventListener('click', () => {
console.log('b被点击');
},)
c.addEventListener('click', () => {
console.log('c被点击');
})
如上图所示,event.stopPropagation()
是阻止事件冒泡的一种方式,当程序进入冒泡阶段并冒泡到了b盒子处时,将b盒子的打印执行完毕,会直接掐断事件流,即事件流冒泡到b盒子处时就结束了,所以不会冒泡到a盒子处,也不会执行a盒子的监听事件中的打印语句了。
b.addEventListener('click', () => {
event.stopPropagation()
console.log('b被点击');
},)
值得一提的是,在我们将event.stopPropagation()
写在了打印语句之前,仍然会将打印b执行,由此可见event.stopPropagation()
是会等待该监听盒子中的回调函数执行完毕后才会阻止冒泡,即只会阻断a盒子的冒泡而不会阻断b盒子自己的冒泡。
event.stopImmediatePropagation()
这个方法也用于阻止事件传播,但它不仅会停止事件继续传播,还会阻止同一元素上其他事件处理程序的执行。
即使同一个元素上有多个事件处理程序,只要其中一个调用了 stopImmediatePropagation()
,那么其他事件处理程序都不会被执行。
题目解析
宏任务
addEventListener
属于I/O 是属于宏任务,每一个监听事件就相当于一次事件循环(event loop)。
话不多说直接上代码
document.body.addEventListener('click', () => {
console.log(1);
});
document.body.addEventListener('click', () => {
console.log(2);
});
通过点击了一次网页,毫无疑问地按照顺序打印了1和2。(此时只会触发一次事件流)
我们再从event loop
的视角出发看待这段代码,事件是相当于一个宏任务,两个事件就是两个宏任务。此两个宏任务按照代码片段的顺序进入宏任务的队列当中,队列是遵循先进先出的原则,因此第一个进入的宏任务就会先一步执行,也就是打印1会先执行,然后将第二个宏任务执行,打印2,此时程序执行完毕。
微任务
我们都知道promise
对象.then
方法是一个经典的微任务,那如果我们将微任务加入会怎么样呢?
代码如下:
document.body.addEventListener('click', () => {
Promise.resolve().then(() => console.log(1))
console.log(2);
});
document.body.addEventListener('click', () => {
Promise.resolve().then(() => console.log(3))
console.log(4);
});
我们还是从event loop
视角出发,第一个监听事件是一个宏任务,此时在宏任务里面的console.log(2);
是属于同步代码,而Promise.resolve().then(() => console.log(1))
是一串异步代码,同步代码先执行,异步代码后执行。让我们再深入一点,Promise.resolve().then(() => console.log(1))
异步代码中的微任务,会将微任务加入到微任务的队列当中,因此执行顺序是第一个宏任务执行同步代码,检测微任务队列,有没有执行代码,将微任务队列代码执行,检测宏任务队列进行循环。
事件流的结合
document.body.addEventListener('click', () => {
Promise.resolve().then(() => console.log(1))
console.log(2);
}, false);
document.body.addEventListener('click', () => {
Promise.resolve().then(() => console.log(3))
console.log(4);
}, true);
如代码段所示,我们将第二个监听事件改造成了捕获阶段。我们再次回到点击时,首先接受到点击,触发事件流,在捕获阶段已经捕获到了第二个点击事件是一个宏任务,此时宏任务入队列,捕获阶段和目标阶段结束,进入冒泡阶段,第一个点击事件的宏任务入队列。在此时第一个点击事件已经排在后面,因此才会打印 4 3 2 1
宏任务的嵌套
回到文章开头的那道题,相信大家应该已经能够看懂了
document.body.addEventListener('click', () => {
setTimeout(() => {
console.log(1);
})
console.log(2);
}, false);
document.body.addEventListener('click', () => {
setTimeout(() => {
console.log(3);
})
Promise.resolve().then(() => console.log(4))
console.log(5);
}, true);
首先,捕获阶段使得第二个点击事件先触发,宏任务中的同步代码先执行,微任务代码接着执行,setTimeout
明显是一个宏任务重新进入宏任务队列等待前面的宏任务执行完成,接着第二个宏任务(第一个点击事件)同样同步代码->微任务->宏任务入队列,接着第三个宏任务->第四个宏任务->代码执行完成。