我们知道,在多线程语言中,比如oc,swift中,想要进行异步编程,可以新开一个子线程,将一些耗时任务放到子线程中执行,执行结束之后再与主线程通信。js是单线程的,那么它是怎么做到异步的呢?
要回答这个问题需要对js中的事件循环(Event Loop
)有所了解。js的运行环境(浏览器/Node)提供了一个事件循环机制(Event Loop)允许代码非阻塞的运行。
在说事件循环之前先讲下什么是调用栈(Call stack): 相关解释可以参考下MDN文档:developer.mozilla.org/zh-CN/docs/...
大致意思函数的调用栈就是追踪函数的一种机制,能够帮助我们理解哪些函数正在被执行,执行的函数又执行了什么其他的函数。它能帮助我们记录当前程序的所在位置。
从名字也能看的出来它采用了栈这种数据结构,符合LIFO(后进先出)原则
一开始,我们得到一个空空如也的调用栈。随后,每当有函数被调用都会自动地添加进调用栈,执行完函数体中的代码后,调用栈又会自动地移除这个函数。最后,我们又得到了一个空空如也的调用栈
ok,了解了这些基本概念,我们可以看下js的基本架构:
- 堆(heap): 对象在堆中分配内存
- 栈(stack): 函数调用形成了由若干帧组成的栈
- WebAPI:浏览器提供。他们不是js语言的一部分,为我们的js代码提供额外的能力。比如:定位、DOM、网络IO、定时器等
举个例子:
js
function main() {
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
console.log("C");
}
main();
其运行顺序如下:
- 调用main函数,将其压入调用栈(Call Stack),并执行main函数的第一行代码,打印A。log执行完毕就出栈
- 执行
setTimeout
,将其压入调用栈。其回调函数为exec()
,setTimeout使用浏览器API实现延迟回调,一旦将计时器的控制权交给浏览器,该栈帧就会出栈 - 将log C压入栈。同时可以看到浏览器API正在执行计时器
- log C执行完毕之后出栈,这时候浏览器执行完定时器,将回调函数添加到消息队列,所以调用栈此时是空的。
- 回调函数被压入调用栈并执行,C被打印。
这就是js的事件循环,简单来说,事件循环是一种运行机制,保证了js作为一个单线程语言,能够进行异步操作。
这里我们就能回答,为什么js是单线程,却能进行异步操作?答案就是:js语言本身是单线程,异步行为并不是js语言本身的一部分,而是建立在js的运行环境(浏览器或者node)中,访问浏览器/node api实现的。浏览器执行完耗时任务之后,然后将对应的回调函数放到一个队列中依次执行。
从上面的示例中,我们可以看到回调函数会被放到一个队列当中,然后依次放入到调用栈中执行。但事件循环并不是只维护一个队列,实际上是有两个任务队列:
-
宏任务队列(macrotask queue): setTimeout,setInterval,I/O操作(如文件读写、网络请求等,DOM监听,UI Rendering,requestAnimationFrame等
-
微任务队列(microtask queue) : Promise的回调(
.then .catch .finally
),queueMicrotask()
,process.nextTick
(Node.js 环境),MutationObserver 等。
当调用栈为空时会执行任务队列里的事件(如果有的话),而微任务总是优先于宏任务执行的。 也就是说当有微任务的时候,宏任务队列中的任务都需要等待微任务执行完毕。而且在每一次循环开始时,都会检查微任务是否存在。即使在执行宏任务中添加了微任务,在调用栈为空时也会先执行微任务,而不是执行宏任务队列中的下一个(如果有的话)。
举个例子,也是经常出现的一个面试题:
js
setTimeout(function set1 () {
console.log("setTimeout 1");
new Promise(function (resolve) {
resolve();
}).then(function set1Then() {
new Promise(function set1Promise (resolve) {
resolve();
}).then(function () {
console.log("then 4");
});
console.log("then 2");
});
});
new Promise(function (resolve) {
console.log("Promise 1");
resolve();
}).then(function () {
console.log("then1");
});
setTimeout(function set2 () {
console.log("setTimeout 2");
});
console.log(2);
queueMicrotask(() => {
console.log("queueMicrotask");
});
new Promise(function (resolve) {
resolve();
}).then(function () {
console.log("then 3");
});
我们来分析一下上面这段代码的打印顺序,首先调用栈为空,
-
我们遇到
setTimeout
函数,调用WebAPI计时,其回调函数会在指定时间放到宏任务队列,现在是[set1
] -
初始化一个Promise对象,其构造函数执行,打印 Promise 1,并调用reslove,reslove会调用then回调,将then回调放到微任务队列,微任务队列里的任务有 [
then1
] -
遇到第二个
setTimeout
,跟步骤一同样,回调函数放到宏任务队列,目前宏任务队列[set1
,set2
] -
执行console.log(2); 打印2,目前打印结果有:[
Promise 1
,2
] -
遇到一个微任务
queueMicrotask
,微任务队列: [then1
,queueMicrotask
] -
初始化Promise,跟第二步类似,将then 3添加到微任务队列:[
then1
,queueMicrotask
,then 3
] -
调用栈已经为空,可以执行任务队列,这时候优先执行微任务队列,所以会依次打印
then1
,queueMicrotask
,then 3
,这时候打印的有:[Promise 1
,2
,then1
,queueMicrotask
,then 3
] -
微任务已经为空,可以执行宏队列[
set1
,set2
],set1比较特殊,先打印setTimeout 1
,然后初始化一个Promise,该Promise直接reslove,将其then回调set1Then添加到微任务队列,此时:打印有[
Promise 1
,2
,then1
,queueMicrotask
,then 3
,setTimeout 1
]微任务队列有:[
set1Then
]宏任务队列有:[
set2
] -
再执行下一次循环时,依然会先查看是否有微任务,所以依旧会先执行
set1Then
,该方法会初始化一个Promise,将其then回调添加到微任务队列,此时微任务队列有[then4
],但此时set1Then
函数并没有完成,也就是还没有出栈,所以会继续往下执行,打印then2
然后出栈,此时打印有:[
Promise 1
,2
,then1
,queueMicrotask
,then 3
,setTimeout 1
,then2
]微任务队列:[
then4
]宏任务队列:[
set2
] -
先执行微任务,后执行宏任务,一次打印then4,setTimeout 2。此时所有任务都执行完毕,打印顺序为:[
Promise 1
,2
,then1
,queueMicrotask
,then 3
,setTimeout 1
,then2
,then4
,setTimeout 2
]
接下来在看另一个常用的api:
js
setTimeout(handler, timeout);
setTimeout接受两个参数,一个回调函数,一个可选的时间值(可选,默认为0,单位是毫秒)。那么该回调函数一定是在指定的时间之后执行吗?设置为0是立即执行吗?
通过上面我们可以很轻易的回答:setTimeout的回调函数并不一定会在指定的时间之后执行,有可能会需要更长的时间。即使传递为0也不一定会立即执行。
该回调函数能否执行取决于
- 当前的调用栈是否有正在执行的任务
- 当前的任务队列是否有其他需要优先执行的任务
所以setTimeout的时间值其实是最小延迟时间,具体的执行时间还要看当前调用栈以及任务队列。
总结:
- js中异步是通过调用运行环境(浏览器/Node)中的api来实现的,浏览器/Node在适当的时候调用传入的回调函数
- 事件循环是js异步的基础,负责监控调用栈和任务队列。循环调度事件推入到调用栈执行。
- 任务队列分为微任务队列 和宏任务队列,微任务总是优先于宏任务执行,在每次执行宏任务的时候会先查看是否有微任务存在
推荐阅读: