俗话说,知己知彼,百战不殆。我们想要想掌握事件循环,就得先知道事件循环位于什么地方,只有先知道了事件循环它所处的位置,它的功能,它作用,这样理解起来就会特别容易。
事件循环在哪里?
我们先来看看事件循环它在哪里?要想搞明白这个问题,我们就得思考一道面试题了,”输入URl到页面渲染都干了什么事情?“ 我想你心中已经有了标准的答案,但是你有没有想过,浏览器在拿到服务器响应的资源后,浏览器难道只做了解析HTML,解析CSS,构建DOM树,构建CSS树,合成渲染树,绘制布局,加载JS脚本吗?结果并不是这样的,在构建页面之后还要在做一件非常重要的事情,就是用户交互产生事件经由事件循环处理。
下面是我简要的画的一幅图:
在图中页面经由浏览器渲染完毕之后就会进入交互阶段,这时用户交互产生的点击事件,键盘事件等等,都由事件循环统一处理。
接下来我们在聚焦于上图中的队列,把它在放大,如下图:
上图的队列当中存放的就是用户交互产出的各种事件和任务的回调函数;按照先进先出的原则等待调用栈的调用。
值得注意的是:同时刻浏览器只能执行一个代码片段(单线程执行模型),当一个事件被触发之后,浏览器需要执行相关的执行函数,为了避免用户等待过长时间,直到下一个事件触发。所以浏览器需要一种方式来跟踪已经发生但是尚未处理的事件。所以在这里引入了事件队列,也就是上图我们画出的队列。
另外我们还需要注意的是:在往任务队列中添加消息的操作是发生在页面构建阶段与事件处理阶段以外的。并且这个过程是不会参与事件处理线程。事件的处理大量依赖事件队列,所有的事件都以其出现的顺序存储在事件队列当中。
我们现在已经找到了事件循环的位置,它位于用户和页面的交互阶段,也知道了它的作用就是处理用户交互产生的事件。那么接下来我们就深入讲解一下事件循环。
深入事件循环
在完整的页面生命周期中,事件循环不仅仅包含事件队列,除了事件队列,还要保持浏览器执行的其他操作,这些操作被称之为任务。存放这些任务的队列也叫任务队列。
这个任务分为了两类:
- 宏任务
宏任务的例子有很多,包括创建主文档对象,解析HTML,执行主线JavaScript代码,更改当前页面的URL以及各种事件,比如页面的加载,输入,网络事件和定时器任务。 从浏览器的角度来看,宏任务代表了一个个独立的工作单元,运行完任务后,浏览器可以进行其他的调度,比如重新渲染页面或执行垃圾回收。
- 微任务
微任务,它是一种更小的任务,微任务更新应用程序的状态,但必须在浏览器任务继续执行其他任务之前执行,浏览器任务包括重新渲染页面的UI,微任务包括Promise的回调函数,DOM发生变化等,微任务需要尽可能快的,通过异步方式执行,同时不能产生全新的微任务,微任务能够让我们在重新渲染UI之前执行指定的行为,避免不必要的UI重绘,因为UI的重绘会使得应用程序的状态不连续。
事件循环的实现至少应该含有一个用于宏任务的队列,和至少一个用于微任务的队列。大部分实现通常会有更多用于不同类型的宏任务和微任务队列,这也使得事件循环能够根据任务类型进行优先处理。
任务对应的有两种,微任务和宏任务, 所以也对应的存在宏任务队列和微任务队列。
- 事件循环基于两个基本的原则
-
一次处理一个任务
-
一个任务开始后直到运行完成,不会被其他任务中断。
在看完了宏任务和微任务的基本概念,我们现在来看图学习事件循环的原理:
在图中:
事件循环将首先检查宏任务队列,如果宏任务等待,则立即开始执行宏任务,直到该任务运行完成,(或者任务队列为空),事件循环将移动去处理微任务队列,如果有任务在该队列中等待,则事件循环将依次开始执行,完成一个后执行余下的微任务,直到队列中所有微任务执行完毕。
注意处理宏任务和微任务队列之间的区别: 单次循环迭代中,最多处理一个宏任务,(其余的再队列中等待),而队列中的所有微任务都会被处理。
当微任务队列处理完成并清空时,事件循环会检查是否需要进行更新UI渲染。如果需要则会重新渲染UI视图。 至此,当前事件循环结束,之后将会回到最初的第一个环节,再次检查宏任务队列,并开启新一轮的事件循环。
事件循环的细节
事件循环存在许多细节需要明确,如下:
-
宏任务队列和微任务队列都是独立于事件循环的,这也就意味着任务队列的添加行为也发生在事件循环之外, (为什么任务队列的添加行为要独立于事件循环之外?主要是因为防止阻塞事件循环导致浏览器响应用户交互缓慢) 因此检查和添加任务的行为,是独立于事件循环完成的。
-
因为JS基于单线程执行模型,所以宏任务和微任务都是逐个执行的,当一个任务开始执行后,在完成之前,中间不会被任何任务中断,除非浏览器决定终止执行该任务,比如某个任务执行时间过长或者内存占用过大。
-
所有微任务会在下一次渲染之前执行完成, 因为他们要在渲染UI之前完成更新应用程序的状态。
-
浏览器通常会尝试每秒渲染60次页面,已到达每秒60帧的速度,这意味着浏览器会尝试在16ms内渲染一帧,在理想情况下,单个任务和该任务附属的所有微任务都应在16ms内完成。
现在然我们思考一下,在浏览器完成页面渲染,进入下一轮事件循环迭代后,可能发生的三种情况。
-
在另一个16ms结束前,事件循环执行到”是否需要进行渲染“的决策环节,因为更新UI是一个复杂的操作,所以如果没有显式的指定需要页面渲染,浏览器可能不会选择在当前的循环中执行UI渲染操作。
-
在最后一次渲染完成大约16ms,事件循环执行到”是否需要进行渲染“的决策环节,在这种情况下,浏览器会进行UI更新,以便用户能够感受到顺畅的应用体验。
-
执行下一个任务(和相关的所有微任务)耗时超过16ms,在这种情况下,浏览器将无法以目测帧率重新渲染页面,且UI无法更新,
至此,事件循环的工作原理介绍完毕。
仅含宏任务的示例
JS单线程执行模型一种不可避免的结果是:同一时间只能执行一个任务,这意味着所有任务都必须在队列中排队等待执行时机。
现在让我们来尝试一个简单的示例练练手,一个简单的只有宏任务的示例,请看下面的例子:
<script>
setTimeout(() => {
console.log('set-1')
})
console.log('a')
</script>
复制代码
这个例子非常的简单,我想你已经知道了答案。
答案: a set-1
那么它的执行过程是什么样子的呢?
-
当页面加载JS,script片段就以宏任务添加入任务队列当中执行。
-
当执行到 script片段时, 首先发现了setTimeOut(()=>{},0)这个宏任务,所以浏览器就将这个宏任务在事件循环之外执行等待延时时间到来,之后将其添加入任务队列当中。
-
在这个过程中不会阻塞代码执行,代码会继续向下执行,打印 console.log(‘a’),浏览器发现当前的宏任务执行完毕,将查找微任务队列执行所有的微任务,但是当前的代码示例没有微任务的存在,所以直接在宏任务队列当中查找下一个宏任务执行。
-
setTimeOut的回调函数进入调用栈进行执行,打印 console.log(‘set-1’)。
包含宏任务和微任务示例
接下来的示例较上一个示例复杂一点,我们一起来看一下,你先别着急看答案。
<script>
// 宏任务 与 微任务 示例
setTimeout(() => {
console.log('set-1')
})
setTimeout(() => {
console.log('set-2')
}, 100)
setTimeout(() => {
console.log('set-3')
}, 10)
const pro = new Promise((res, rej) => {
console.log('1');
res('2')
console.log('3')
})
pro.then(res => {
console.log("res", res)
return '4'
}).then(res => {
console.log("res", res)
})
setTimeout(() => {
pro.then((res) => {
console.log("res", res)
console.log('5')
})
console.log('7')
}, 1000)
setTimeout(async () => {
console.log('9')
const num = await pro.then((res) => {
console.log("res", res)
return '8'
});
console.log("num", num)
}, 0)
console.log('6')
</script>
复制代码
尝试拿着纸和笔画一画,写一写,代码有点多,容易记错。
答案: 1, 3, 6, res 2,res 4,set-1,9,res 2,num 8,set-3,set-2,7,res 2,5
我们一来看看它的执行过程:
-
script 片段以宏任务的添加进任务队列,开始执行。
-
在这个script片段中的代码从上至下一次执行,首先遇到了三个setTimeOut,有浏览器控制在事件循环之外等待延时时间到来,之后添加进入宏任务队列。
-
接下来执行new Promise(),因为 Promise是立即执行函数,所以会立即执行 console.log(‘1’) res(‘2’) console.log(‘3’),打印 1,3。
-
代码执行到了pro.then().then() 因为这些事微任务,所以全部添加入微任务队列。留待本次宏任务执行完之后执行。
-
接下了两个setTimeOut()依然会由浏览器控制,在事件循环之外等待延时时间到来,之后添加入宏任务队列。这两个宏任务会和上面的三个宏任务一起进行等待添加入宏任务队列,它们会根据执行的顺序和等待的延时时间,顺序的添加入宏任务队列。
-
执行 console.log(‘6’) 打印 6, 本次宏任务执行完毕,接下来查看微任务队列,执行所有的微任务。
-
因为目前的微任务队列中有pro.then().then(),执行所有的微任务,所以会打印 res 2,res 4。 接下来浏览器会根据情况是否进行UI渲染。接着查看宏任务队列,执行下一个宏任务。
-
开始执行 setTimeout(() => { console.log(‘set-1’) }); 这个宏任务,回调函数被调用执行,会打印出 set-1。在这里为什么会是它先进入宏任务队列呢? 两个原因,一个是因为它没有第二个参数,浏览器会默认至少16ms之后会执行它,另一个就是代码从上到下它先执行。 之后会查看是否存在微任务,若无,执行下一个宏任务。
-
开始执行 setTimeout(async() => { },0); 这个宏任务,先打印 9 然后 await等待变为同步,打印res 2 之后会往下执行打印 num 8。 那么为什么会是这个宏任务在宏任务队列的第二个呢? 两个原因:一个是因为它的第二个参数为0,其实浏览器依然会至少16ms之后会执行它,另一个是,上一个宏任务比它先执行,所以它是第二个。 之后会查看是否存在微任务,若无,执行下一个宏任务。
-
开始执行 setTimeout(() => { console.log(‘set-3’) }, 10); 这个宏任务; 打印 set-3。这个为什么是第三个执行你自己考虑考虑。之后会查看是否存在微任务,若无执行下一个宏任务。
-
开始执行 setTimeout(() => { console.log(‘set-2’) }, 100); 这个宏任务,打印 set-2。之后会查看是否存在微任务,若无,执行下一个宏任务。
-
开始执行 setTimeout(() => { }, 1000) 这个宏任务,遇到pro.then(),添加进入微任务队列,之后向后执行打印 7 ,本次宏任务执行完毕,查看微任务队列,执行所有的微任务。 打印 res 2,5。之后会查看是否存在微任务,若无,执行下一个宏任务。
-
若无宏任务,等待用户交互。
总结
-
事件循环的处理逻辑为,首先处理宏任务队列中的宏任务,然后在处理当前微任务队列中的所有微任务,之后根据情况是否进行UI渲染,之后再处理宏任务队列当中的下一个宏任务,若无宏任务,就等待用户交互。
-
由于JS是单线程的模型,所以每次只能执行一个任务,且不可被打断。