今天我们来聊聊js的事件循环机制,又一座在js中我们需要跨过去的大山,它涉及的原理就更加底层。让我们一起来看看吧。
1. event-loop 事件循环
那什么是事件循环机制呢?我们先通过一段代码来认识一下。
let a = 1
console.log(a);
setTimeout(function () {
let b = 2
console.log(b);
a++
setTimeout(function () {
b++
}, 2000)
console.log(b);
}, 1000)
console.log(a);
我们定义了一个a为1,然后输出a。紧接着写了一个定时器,在定时器里面定义了一个b为2,然后输出b,然后a++。又写了一个定时器,里面b++,然后输出b,最后输出a。
请问这段代码的输出结果是什么?
在上一次的学习中,我们认识了一下异步这个概念。v8引擎碰到耗时的代码会将其挂起,等执行完不耗时的代码再回过头来执行耗时代码。其实这个说法并不准确,等你看完这篇文章,你将会有一个更加清楚的认识,我们先这样理解一下。
所以这段代码是会异步执行的,它会先执行不耗时代码,再执行耗时代码。
然后去执行定时器里的代码,这时有一个规律。在这个定时器里面是不是又有耗时代码和不耗时代码。所以v8先去执行不耗时代码,再去执行耗时代码,就像开启了一次新的循环一样,在这个循环里面进行一样的操作。
执行完这个定时器里不耗时的代码再去执行第二个定时器。所以输出结果应该是1、1、2、2。
其实这就是事件循环机制的核心:v8按照先执行同步再执行异步的策略,反复重复。
那在继续学习之前,我们得先搞清楚一点,v8引擎不是以代码是否消耗时间来区分耗时代码的。比如下面这段代码:
let a = 1
for (var i = 0; i < 1000000; i++) {
a++
}
console.log(a);
我们将i的循环终止条件设的非常大,那这段代码是耗时的吗。是耗时的,它可能要过好几秒才能出结果。但是它在v8眼里就不属于耗时代码,v8引擎有一套自己的评判标准,比如定时器在v8眼里就属于耗时代码。
接下来我们就去学习v8是怎么去执行一段同步异步交叉的代码的。
我们再来看一段代码:
console.log(1);
new Promise(function (resolve, reject) {
console.log(2);
resolve()
})
.then(() => {
console.log(3);
setTimeout(() => {
console.log(4);
}, 0)
})
setTimeout(() => {
console.log(5);
setTimeout(() => {
console.log(6);
}, 0)
}, 0)
console.log(7);
这段代码看起来就很恶心,它是怎么被v8执行的呢?现在的我们还解决不了这个问题,等你看完这篇文章,你就会恍然大悟。
我们先来介绍一下进程和线程的概念
2. 进程和线程
进程和线程,在生活中我们也经常用到。我们用手机打开微信,我们通常说我们打开了一个进程,这个进程就挂在后台一直运行。
但其实进程和线程是时间单位,用专业的话来说就是:
- 进程:在CPU运行指令和保存上下文所需要的时间
- 线程:执行一段指令所需要的时间
我们来举个例子,比如站在浏览器的视角。浏览器每开一个 tab 页,就是新开一个进程。而在这个进程中,浏览器将页面绘制出来给你看,发送http请求,v8引擎解析js代码,都算作线程。
- 渲染线程
- js引擎线程
- http线程
其实各个线程之间是能同步工作的,唯独渲染线程和js引擎线程是不能同步工作的。每个浏览器都是这样,如果渲染线程和js引擎线程能同步进行工作,就会出问题,因为js代码能操作html元素,css也能改变html元素样式,要是同步工作,js和css都对同一个html元素进行了操作,那它听谁的呢?所以它们两个是不能同步工作的。
我们再来站在v8引擎的角度上来看,我们写了一段js代码交给v8引擎运行,这时,我们也能说,v8引擎开了一个进程,而在这个进程中,只有一个线程,因为js是单线程语言。
3. eventLoop的执行步骤
我们再来聊聊eventLoop的执行步骤,当v8引擎碰到一段同步异步混合的代码,它是怎么执行的。
那我们就得来了解一下在v8眼里哪些是同步代码,哪些是异步代码。
我们在上面已经说了,v8引擎不是以代码是否消耗时间来区分同步和异步代码的。耗时的代码一定不是同步代码,不耗时的代码不一定是同步代码。
在v8眼里代码就是这样区分的:
同步代码
异步代码
- 微任务:promise.then() , process.nextTick() , MutationObserver()
- 宏任务: script, setTimeout, setInterval, setImmediate, I/O , UI-rendering
异步代码里面还分为微任务和宏任务。我们很常见的promise.then()就属于微任务,定时器就属于宏任务。
除了上面提到的微任务和宏任务,其它代码都属于同步代码。
知道了什么是同步代码和异步代码,才能理解清楚eventLoop的执行步骤。
eventLoop步骤:
- 执行同步代码(这属于宏任务)
- 执行完同步后,检查是否有异步代码需要执行
- 执行所有的微任务
- 如果有需要就渲染页面
- 执行宏任务,也是开启了下一次事件循环
知道了eventLoop的执行步骤,现在我们回到那份很恶心的代码,我们来梳理一下这份代码会怎么执行。
console.log(1);
new Promise(function (resolve, reject) {
console.log(2);
resolve()
})
.then(() => {
console.log(3);
setTimeout(() => {
console.log(4);
}, 0)
})
setTimeout(() => {
console.log(5);
setTimeout(() => {
console.log(6);
}, 0)
}, 0)
console.log(7);
编译的环节我们就直接跳过了,直接来到代码执行。
先执行所有的同步代码,所以第一个console.log(1)先执行,然后new Promise,它既不是微任务也不是宏任务,所以它也会执行。console.log(2)第二个执行。然后碰到then,这时来了,它是属于微任务的,所以它会被挂起,v8引擎会维护一个微任务队列来存放它。
所以先不会执行then,因为它是异步代码。然后碰到第一个定时器,它属于宏任务,v8引擎也会维护一个宏任务队列存放它。
所以也会跳过它,去执行后面的同步代码。所以console.log(7)第三个执行。
这时,全局的同步代码都执行完毕,去执行异步代码。先去执行微任务队列里的。所以then出队,then里面又相当于进行一个新的循环,有同步代码有异步代码,所以console.log(3)第四个执行。
然后碰到第二个定时器,也挂起到宏任务队列。
此时微任务队列执行完毕,就会去渲染页面,当然我们这里没有这个需求。所以开始执行宏任务队列,第一个定时器出队,所以console.log(5)第五个执行。
此时又碰到一个定时器,于是它入队。
然后执行第二个定时器,它出队,所以console.log(4)第六个执行。
再执行最后一个定时器,它出队,所以console.log(6)第七个执行。
此时执行完毕,所以顺序应该是1、2、7、3、5、4、6。我们来看一下是不是这样。
确实是这样,看来我们的分析没有错。
现在我们能够拎清楚了什么是同步代码,什么是异步代码,它们的执行过程是什么样的。我们再来看最后一道更复杂一点的。
console.log('script start');
async function async1() {
await async2()
console.log('async1 end');
}
async function async2() {
console.log('async2 end');
}
async1()
setTimeout(() => {
console.log('setTimeout');
}, 0)
new Promise((resolve, reject) => {
console.log('promise');
resolve()
})
.then(() => {
console.log('then1');
})
.then(() => {
console.log('then2');
});
console.log('script end');
这里一共有8条输出语句,它们的输出顺序会是什么呢?我们一起来看一下。
首先,先执行同步代码。console.log('script start')第一个执行,然后是函数声明,碰到async1(),于是去执行函数async1。此时碰到了await,这东西就有点特殊了。
- 浏览器对 await 的执行提前了 (等同于await 后面的代码当成同步)
- 会将后续(下面)代码挤入微任务队列
所以此时console.log('async1 end')会被挤入微任务队列中,转而去执行async2。
所以console.log('async2 end')第二个执行。然后碰到一个定时器,于是将它放入宏任务队列中。
然后是一个new Promise,它是同步代码,所以console.log('promise')第三个执行。然后两个then入微任务队列。所以console.log('script end')第四个执行。
此时同步代码执行完毕,于是开始执行微任务。console.log('async1 end')第五个执行。
then1、then2出队列,console.log('then1')、console.log('then2')分别第六个第七个执行。
微任务队列执行完毕,执行宏任务。定时器出队列, console.log('setTimeout')第八个执行。
至此执行完毕,我们来看看输出结果是否和我们分析的一样。
确实如此。现在你就学会了js的事件循环机制了。
4. setTimeout 定时器执行的时间准吗?
最后我们再来聊一个小话题。定时器我们最近经常碰到,那么它执行的时间准吗?
其实它的执行时间是不准的,这是为什么呢?且听我细细道来。
当浏览器读到setTimeout时,其实它会单独开一个线程去进行计时,直到计时结束setTimeout里的代码才会执行。
这里就会出一个问题,js主线程还在执行同步代码。假如在这个定时器后面有很长一段同步代码要执行,需要花费5秒钟,而我们给setTimeout设置的时间是2秒钟。当setTimeout挂起的时候,另外一个线程的倒计时就开始了,此时js主线程正在执行那5秒钟的同步代码。当定时器2秒钟的倒计时结束了,同步代码就会和定时器撞一起了,那么现在,js主线程是继续执行它的同步代码呢,还是暂停去执行定时器呢?
它是会继续执行它的同步代码的,就算倒计时结束了,定时器的回调还是会被一直挂起,直到同步执行完毕,微任务也执行完毕,才执行该回调。
所以这就会导致定时器的执行时间不准,明明我只设置了2秒钟的倒计时,它可能要4、5秒后才能出结果。
还有一点,假如在这个2秒钟的定时器之前有一个10秒钟的定时器已经在队列中了,就是在2秒钟的定时器前面入的队列。如果2秒钟的定时器走完了,也会先执行2秒钟的定时器的回调,因为它的时间更短。
所以我们说setTimeout 定时器执行的时间是不准的,这个误差大概在3秒钟左右。