Javascript的异步原理和事件循环图文详解 - Go语言中文社区

Javascript的异步原理和事件循环图文详解


做为单线程的语言,异步操作对于javascript来说是至关重要的机制,也是面试中经常会被问到的知识点。在总结了网上几位前端大牛的博客以后,我进行了大量的实际操作,将相关知识点都汇总到了这里。

为什么是单线程?

Javascript做为浏览器脚本语言,其主要任务就是进行DOM操作以及用户交互,这两者都需要很严格的执行顺序。所以javascript在设计之初就是单线程,某一时刻只允许其做一件事,在今后也不太可能改变。

正因为是单线程,所以如果有某个任务一直卡着就会阻碍后面所有任务的运行。但是总有一些任务,例如网络传输,具有很强的未知性,万一失败难道后面所有的任务都不跑了吗?当然不行。

要解决这个问题,先让我们来看看js的运行环境。

Javascript环境

1-environment.png

如上图所示,虽然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);

这里setTimeoutWindow对象的一个方法,也是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的时候发现是点击事件,是异步操作,于是丢给浏览器去处理

2-onclick.jpg

再往下,直接执行打印1的操作

3-console.jpg

再往下,又是一个异步的setTimeout操作,再次丢给浏览器去处理,浏览器会在5秒钟后将其放入任务队列

4-settimeout.jpg

再往下,直接执行打印3的操作

5-console.jpg

到此,所有的同步任务都执行完成,脚本退出,整个栈空了。到此耗时是毫秒级别的,没有点击事件发生。之后在5秒钟之内我点击了按钮,触发了onclick事件的回调函数,其被放入任务队列

6-click.jpg

事件循环检测到栈是空的,于是将任务队列中的下一个任务放入栈执行

7-console.jpg

然后到了5秒钟的时候,setTimeout事件触发,回调函数被放入任务队列。同样直接被事件循环放入栈内执行打印

8-console.jpg

所以如果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

宏任务和微任务交替被执行,每次有一个宏任务执行完,就会执行当前在排队的所有微任务,之后再执行一个宏任务,再执行所有微任务,如此反复

于是可以用下面的新图来解释上面的那段代码。

首先是整个脚本做为一个宏任务被执行

9-script.jpg

接着打印1

10-console.jpg

再然后是一个异步操作setTimeout,在0秒后被放入宏任务队列,在script之后会被执行

11-settimeout.jpg

接着执行promise的构造函数部分,打印3,同时因为是直接resolve,所以把then方法的回调函数放入任务队列中,promise属于微任务,所以放到微任务队列中

12-console.jpg

之后打印5

13-console.jpg

此时script执行完毕,事件循环会去检查微任务队列,并全部执行,打印4

14-console.jpg

之后再找下一个宏任务去执行,打印2

15-console.jpg

之后事件循环还想找微任务队列去执行,发现已经空了。

更复杂的一个例子

了解了宏任务和微任务,再看下面的这个例子就应该非常容易了

<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执行完毕的时候是这样子的

16-script1.jpg

然后执行所有的微任务,打印4,然后开始执行下一个宏任务,也就是script2

17-micro.jpg

等到script2执行完毕的时候如下

18-script2.jpg

之后执行所有的微任务,打印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会将回调函数放到微任务队列的最前面。

总结

总结下这一节的知识点

  • 单线程的js借助浏览器的API以及任务队列完成了异步操作
  • 任务队列分为宏任务队列和微任务队列,通过事件循环交替执行
  • 多个script的时候,是一起先被放入宏任务队列再开始执行,再来的宏任务会排在后面

参考

我是T型人小付,一位坚持终身学习的互联网从业者。喜欢我的博客欢迎在csdn上关注我,如果有问题欢迎在底下的评论区交流,谢谢。

版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/Victor2code/article/details/106985959
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。
  • 发表于 2021-04-11 14:26:05
  • 阅读 ( 904 )
  • 分类:

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢