做为单线程的语言,异步操作对于javascript来说是至关重要的机制,也是面试中经常会被问到的知识点。在总结了网上几位前端大牛的博客以后,我进行了大量的实际操作,将相关知识点都汇总到了这里。
Javascript做为浏览器脚本语言,其主要任务就是进行DOM操作以及用户交互,这两者都需要很严格的执行顺序。所以javascript在设计之初就是单线程,某一时刻只允许其做一件事,在今后也不太可能改变。
正因为是单线程,所以如果有某个任务一直卡着就会阻碍后面所有任务的运行。但是总有一些任务,例如网络传输,具有很强的未知性,万一失败难道后面所有的任务都不跑了吗?当然不行。
要解决这个问题,先让我们来看看js的运行环境。
如上图所示,虽然js是单线程语言,但是它有一个好帮手,就是浏览器,可以进行一个复杂操作的处理。浏览器会开放一些API供js去调用,同时还有任务队列和实现循环机制来将处理结果返回给js。
下面来详细看看各个部分的作用
heap - 内存的堆部分,是变量值的存储位置。
不过其实只有object和array这种复合变量才会在堆中储存,简单变量都是直接在栈中保存了,这个不重要
stack - 内存的栈部分,函数调用的存储位置,有新的函数被调用就会被push到栈的顶端,遵循后进先出的规则
web API - 浏览器开放给js的接口,例如网络传输,地理位置获取,摄像头视频获取等等。借助这些API,js就可以实现异步操作的目的了
callback queue - 当浏览器完成了指定的任务,就会将回调函数放入任务队列中。任务队列遵循先入先出的规则
event loop - 一个进程,专门用来检测函数调用栈空了没有,如果空了,就从任务队列中拿回调函数放入栈中,直到又有新的函数入栈为止,如此反复
看下面这段代码
console.log(1);
setTimeout(function(){
console.log(2)
},0);
console.log(3);
这里setTimeout
是Window
对象的一个方法,也是web API的一种,所以即使是0秒之后执行回调,也只是0秒之后被放入任务队列,只有等所有函数执行完成以后才会被执行。
所以最后的打印顺序为
1
3
2
js中的异步调用有很多,下面再用更多的例子来巩固上面的知识点
看下面的代码
<body>
<button type="button">Click me!button>
<script type="text/javascript">
let btn = document.querySelector('button');
btn.onclick=function(){
console.log('clicked')
}
console.log(1);
setTimeout(function(){
console.log(2)
},5000);
console.log(3);
script>
body>
结果会是什么呢?我们用图示来分解看看。
首先是整个脚本入栈,并且获取到button对象。然后执行到onclick
的时候发现是点击事件,是异步操作,于是丢给浏览器去处理
再往下,直接执行打印1的操作
再往下,又是一个异步的setTimeout
操作,再次丢给浏览器去处理,浏览器会在5秒钟后将其放入任务队列
再往下,直接执行打印3的操作
到此,所有的同步任务都执行完成,脚本退出,整个栈空了。到此耗时是毫秒级别的,没有点击事件发生。之后在5秒钟之内我点击了按钮,触发了onclick
事件的回调函数,其被放入任务队列
事件循环检测到栈是空的,于是将任务队列中的下一个任务放入栈执行
然后到了5秒钟的时候,setTimeout
事件触发,回调函数被放入任务队列。同样直接被事件循环放入栈内执行打印
所以如果5秒钟内点击按钮,打印
1
3
clicked
2
而如果5秒钟之后点击按钮,则会打印
1
3
2
clicked
基本上学会了这种分析思路,遇到异步问题都可以比较轻松的解决了。
例如下面的代码
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i)
}, 1000)
}
console.log(i)
打印的结果是什么呢?
可能有人会说,程序先放了3个异步操作到浏览器,然后按照先后顺序进入任务队列,等同步任务执行完成再执行任务队列,于是结果是
3
0
1
2
思路是对的,但是要注意js中用var声明变量时候的变量提升(hoisting)问题,变量i
已经是全局变量,所以最后的结果应该是
3
3
3
3
关于变量提升,可以参考另一篇博客《JS中的var,let,const的区别和使用》
再看下面的代码
console.log(1);
setTimeout(function(){
console.log(2)
},0);
let promise = new Promise(function(resolve, reject){
console.log(3);
resolve();
}).then(function(){console.log(4)});
console.log(5);
按照我们上面的逻辑推算,先执行所有的同步操作(注意promise的声明部分是直接执行),然后再按照先后顺序执行所有的异步操作,结果就应该是
1
3
5
2
4
对promise不太了解的朋友,可以参考另一篇博客《JS的promise以及promise链使用详解》
但是结果却出乎意料的是
1
3
5
4
2
这又是为什么呢?
原来不同的异步操作也是有优先级差别的。
根据异步操作的不同,可以把任务队列细分为宏任务(macro-task)和微任务(micro-task)。其中
宏任务大概包括:setTimeout, setInterval, setImmediate,script,Ajax,I/O
是的,脚本本身也是一个异步任务。例如,在执行一个脚本的时候,出发了按钮的onclick事件,同时有setTimeout的时间到期,于是组成了3个任务的宏任务队列,这3个任务按照先进先出的顺序被处理
微任务大概包括:process.nextTick, Promises, MutationObserver
宏任务和微任务交替被执行,每次有一个宏任务执行完,就会执行当前在排队的所有微任务,之后再执行一个宏任务,再执行所有微任务,如此反复。
于是可以用下面的新图来解释上面的那段代码。
首先是整个脚本做为一个宏任务被执行
接着打印1
再然后是一个异步操作setTimeout
,在0秒后被放入宏任务队列,在script之后会被执行
接着执行promise的构造函数部分,打印3,同时因为是直接resolve,所以把then方法的回调函数放入任务队列中,promise属于微任务,所以放到微任务队列中
之后打印5
此时script执行完毕,事件循环会去检查微任务队列,并全部执行,打印4
之后再找下一个宏任务去执行,打印2
之后事件循环还想找微任务队列去执行,发现已经空了。
了解了宏任务和微任务,再看下面的这个例子就应该非常容易了
<script type="text/javascript">
console.log(1);
setTimeout(function(){
console.log(2)
},0);
let promise = new Promise(function(resolve, reject){
console.log(3);
resolve();
}).then(function(){console.log(4)});
console.log(5);
script>
<script type="text/javascript">
console.log(6);
let promise2 = new Promise(function(resolve, reject){
console.log(7);
resolve();
}).then(function(){console.log(8)});
console.log(9);
script>
这里定义了两个script标签,要注意js会一次性把所有脚本加到栈内(或者说宏任务队列中),再开始从头执行,所以script1执行完毕的时候是这样子的
然后执行所有的微任务,打印4,然后开始执行下一个宏任务,也就是script2
等到script2执行完毕的时候如下
之后执行所有的微任务,打印8,然后是最后一个宏任务,打印2。
所以最后的结果如下
1
3
5
4
6
7
9
8
2
最后贴上参考1里面的一个例子,做为进阶练习
setImmediate(() => {
console.log(1);
},0);
setTimeout(() => {
console.log(2);
},0);
new Promise((resolve) => {
console.log(3);
resolve();
console.log(4);
}).then(() => {
console.log(5);
});
console.log(6);
process.nextTick(()=> {
console.log(7);
});
console.log(8);
//输出结果是3 4 6 8 7 5 1 2
需要注意setImmediate
会将回调函数放到宏任务队列的最前面,而process.nextTick
会将回调函数放到微任务队列的最前面。
总结下这一节的知识点
参考1 - JavaScript 事件循环及异步原理(完全指北)
参考2 - Understanding JavaScript — Heap, Stack, Event-loops and Callback Queue
我是T型人小付,一位坚持终身学习的互联网从业者。喜欢我的博客欢迎在csdn上关注我,如果有问题欢迎在底下的评论区交流,谢谢。