本文大部分内容是转载自
作者:欧怼怼
链接:https://juejin.cn/post/6969028296893792286
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
我们都知道 Js 是单线程的,但是一些高耗时操作就带来了进程阻塞问题。为了解决这个问题,Js 有两种任务的执行模式:同步模式(Synchronous)和异步模式(Asynchronous)。
在异步模式下,创建异步任务主要分为宏任务与微任务两种。ES6 规范中,宏任务(Macrotask) 称为 Task, 微任务(Microtask) 称为 Jobs。宏任务是由宿主(浏览器、Node)发起的,而微任务由 JS 自身发起。
宏任务与微任务的几种创建方式 👇
宏任务(Macrotask) | 微任务(Microtask) |
---|---|
setTimeout | requestAnimationFrame(有争议) |
setInterval | MutationObserver(浏览器环境) |
MessageChannel | Promise.[ then/catch/finally ] |
I/O,事件队列 | process.nextTick(Node环境) |
setImmediate(Node环境) | queueMicrotask |
script(整体代码块) |
如何理解 script(整体代码块)是个宏任务呢 🤔
实际上如果同时存在两个 script 代码块,会首先在执行第一个 script 代码块中的同步代码,如果这个过程中创建了微任务并进入了微任务队列,第一个 script 同步代码执行完之后,会首先去清空微任务队列,再去开启第二个 script 代码块的执行。所以这里应该就可以理解 script(整体代码块)为什么会是宏任务。
什么是 EventLoop ?
先来看个图
1. 判断宏任务队列是否为空
- 不空 --> 执行最早进入队列的任务 --> 执行下一步
- 空 --> 执行下一步
2. 判断微任务队列是否为空
- 不空 --> 执行最早进入队列的任务 --> 继续检查微任务队列空不空
- 空 --> 执行下一步
因为首次执行宏队列中会有 script(整体代码块)任务,所以实际上就是 Js 解析完成后,在异步任务中,会先执行完所有的微任务,这里也是很多面试题喜欢考察的。需要注意的是,新创建的微任务会立即进入微任务队列排队执行,不需要等待下一次轮回。
事件循环 Event Loop
其实宏任务队列和微任务队列的执行,就是事件循环的一部分了,所以放在这里一起说。
事件循环的具体流程如下:
- 从宏任务队列中,按照入队顺序,找到第一个执行的宏任务,放入调用栈,开始执行;
- 执行完该宏任务下所有同步任务后,即调用栈清空后,该宏任务被推出宏任务队列,然后微任务队列开始按照入队顺序,依次执行其中的微任务,直至微任务队列清空为止;
- 当微任务队列清空后,一个事件循环结束;
- 接着从宏任务队列中,找到下一个执行的宏任务,开始第二个事件循环,直至宏任务队列清空为止。
这里有几个重点:
- 当我们第一次执行的时候,解释器会将整体代码
script
放入宏任务队列中,因此事件循环是从第一个宏任务开始的; - 如果在执行微任务的过程中,产生新的微任务添加到微任务队列中,也需要一起清空;微任务队列没清空之前,是不会执行下一个宏任务的。
接下来,通过一个常见的面试题例子来模拟一下事件循环。
console.log("a");
setTimeout(function () {
console.log("b");
}, 0);
new Promise((resolve) => {
console.log("c");
resolve();
})
.then(function () {
console.log("d");
})
.then(function () {
console.log("e");
});
console.log("f");
/**
* 输出结果:a c f d e b
*/
复制代码
首先,当代码执行的时候,整体代码script
被推入宏任务队列中,并开始执行该宏任务。
按照代码顺序,首先执行console.log("a")
。
该函数上下文被推入调用栈,执行完后,即移除调用栈。
接下来执行setTimeout()
,该函数上下文也进入调用栈中。
因为setTimeout
是一个宏任务,因此将其callback
函数推入宏任务队列中,然后该函数就被移除调用栈,继续往下执行。
紧接着是Promise
语句,先将其放入调用栈,然后接着往下执行。
执行console.log("c")
和resolve()
,这里就不多说了。
接着来到new Promise().then()
方法,这是一个微任务,因此将其推入微任务队列中。
这时new Promise
语句已经执行结束了,就被移除调用栈。
接着做执行console.log('f')
。
这时候,script
宏任务已经执行结束了,因此被推出宏任务队列。
紧接着开始清空微任务队列了。首先执行的是Promise then
,因此它被推入调用栈中。
然后开始执行其中的console.log("d")
。
执行结束后,检测到后面还有一个then()
函数,因此将其推入微任务队列中。
此时第一个then()
函数已经执行结束了,就会移除调用栈和微任务队列。
此时微任务队列还没被清空,因此继续执行下一个微任务。
执行过程跟前面差不多,就不多说了。
此时微任务队列已经清空了,第一个事件循环已经结束了。
接下来执行下一个宏任务,即setTimeout callback
。
执行结束后,它也被移除宏任务队列和调用栈。
这时候微任务队列里面没有任务,因此第二个事件循环也结束了。
宏任务也被清空了,因此这段代码已经执行结束了。
await
ECMAScript2017中添加了async functions
和await
。
async
关键字是将一个同步函数变成一个异步函数,并将返回值变为promise
。
而await
可以放在任何异步的、基于promise
的函数之前。在执行过程中,它会暂停代码在该行上,直到promise
完成,然后返回结果值。而在暂停的同时,其他正在等待执行的代码就有机会执行了。
下面通过一个例子来体验一下。
async function async1() {
console.log("a");
const res = await async2();
console.log("b");
}
async function async2() {
console.log("c");
return 2;
}
console.log("d");
setTimeout(() => {
console.log("e");
}, 0);
async1().then(res => {
console.log("f")
})
new Promise((resolve) => {
console.log("g");
resolve();
}).then(() => {
console.log("h");
});
console.log("i");
/**
* 输出结果:d a c g i b h f e
*/
复制代码
首先,开始执行前,将整体代码script
放入宏任务队列中,并开始执行。
第一个执行的是console.log("d")
。
紧接着是setTimeout
,将其回调放入宏任务中,然后继续执行。
紧接着是调用async1()
函数,因此将其函数上下文放置到调用栈。
然后开始执行async1
中的console.log("a")
。
接下来就是await
关键字语句。
await
后面调用的是async2
函数,因此我们将其放入调用栈。
然后开始执行async2
中的console.log("c")
,并return
一个值。
执行完成后,async2
就被移出调用栈。
这时候,await
会阻塞async2
的返回值,先跳出async1
进行往下执行。
需要注意的是,现在async1
中的res
变量,还是undefined
,没有赋值。
紧接着是执行new Promise
。
执行console.log("i")
。
这时,async1
外面的同步任务都执行完成了,因此就重新回到前面阻塞的位置,进行往下执行。
这时res
成功赋值了async2
的结果值,然后往下执行console.log("b")
。
这时候async1
才算是执行结束,紧接着再将其调用的then()
函数放入微任务队列中。
这时script
宏任务已经全部执行完了,开始准备清空微任务队列了。
第一个被执行的微任务队列是promise then
,也就是将执行其中的console.log("h")
语句。
执行完Promise then
微任务后,紧接着开始执行async1
的promise then
微任务。
这时候微任务队列已经清空了,即开始执行下一个宏任务。