前言
拆解实现 Promise 及其周边文中大量聊到关于宏任务和微任务的知识点,其实这和事件循环机制息息相关。本文也将和大家一起来抠一抠事件循环机制
的细节。
单线程语言
JavaScript
是单线程语言,这点众所周知。那为啥JavaScript
是单线程语言,从根本上改为多线程不好么?
阮一峰前辈文中提到原因,搬运一下:JavaScript
从诞生起就是单线程。原因大概是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。后来就约定俗成,JavaScript
为一种单线程语言。
简单来讲个场景:如果两个线程同时操作一个DOM
,一个修改,一个删除,那以哪个为基准?为了避免这种场景,所以JS是单线程的。
H5提出的Web worker
的标准,允许JavaScript
创建多个线程,但子线程完全受主线程控制,所以,JavaScript
本身依旧是单线程。
那JavaScript
单线程语言给我们造成了哪些问题呢? 举个单线程处理任务的?:
lethello='hello'letworld='world'console.log(hello+','+world)
上述代码,JS引擎编译完成之后,会把所有的任务代码放入主线程。等主线程开始执行,这些任务会按照顺序从上而下依次执行,至打印出hello,world
后,主线程会自动退出。
一切都很美好~但现实是复杂且残酷的?♀️,不可能一直按部就班。如果单个任务执行时间过长导致后续任务阻塞,该怎么处理?
执行栈和任务队列
单线程就意味着,所有的任务需要排队。若前个任务执行时间过长,后一个任务就不得不一直等待,如IO线程(Ajax
请求数据),不得不等待结果出来,再往下执行。
但这个等待是没有必要的,我们可以挂起等待中的任务,继续执行后续的任务。
因此,任务可分为两种:一种是同步任务;一种是异步任务。 同步任务:均在主线程上执行,用执行栈管理同步任务的进行。 异步任务:异步操作完成,先进入任务队列,等主线程执行栈空了,就去读取任务队列中的异步任务。
functionhelloWorld(){console.log('innerfunction')setTimeout(function(){console.log('executesetTimeout')})}helloWorld()console.log('outerfunction')
通过Loupe工具分析上述代码是否如我们所说的一样。
helloWorld
函数先进入执行栈,开始执行helloWorld
函数内的代码。
console.log('函数内')
进入执行栈,打印函数内
。
执行setTimeout
,属于定时任务,需要延迟等待,所以先挂起,后将匿名函数入队且继续执行主线程上的其余代码。
console.log('函数外')
进入执行栈,打印函数外
。
主线程代码执行完毕,读取任务队列里的里的匿名函数,执行打印execute setTimeout
。
代码执行顺序与先前结论完美的契合~
事件循环
之所以称事件循环
,是因为主线程从任务队列读取事件是循环不断的。为了更好地理解Event Loop转引自Philip Roberts的演讲《Help, I'm stuck in an event-loop》)
上图所示,主线程运行,会产生堆和栈,栈中的代码调用WebAPIs
,当满足触发条件后,会将指定的回调函数或事件进行入队。当栈中代码执行完毕,就会循环读取任务队列里的事件,如此往复。
从图中还可以获取一个信息点:任务队列中的任务类型不仅只有一种,它包含了如输入事件(鼠标滚动、点击)、微任务、文件读写、WebSocket、定时器等等。其中如输入事件、文件读写、WebSocket都属于异步请求,等待I/O设备完成即可。而定时器是如何指定代码在规定时间之后进行?微任务又是什么?
定时器
定时器主要由setTimeout
和setInterval
两个函数,两者类似,区别在执行次数,前者一次性执行,后者则反复执行。以setTimeout
为例,基本用法如下。
functionhelloWorld(){console.log('helloworld')}lettimer=setTimeout(helloWorld,1000)
很简单,上述代码将通过setTimeout
在1000ms后输出hello world
。 不知道你有没有疑问?上文提到,推入任务队列中的任务都是按顺序读取执行,那么定时器的回调函数是如何保证在指定时间内被调用? 翻阅资料,发现Chromium
中有关于设计延迟队列的概念,而延迟队列中的任务都是根据发起时间和延迟时间计算是否到期。若任务到期,则会先执行完成到期任务,再进行下一次循环。 使用定时器,还有一些注意事项? 若主线程任务执行时间过长,会影响定时器任务的执行。
functionhelloWorld(){console.log('helloworld')}functionmain(){setTimeout(helloWorld,0)for(leti=0;i<5000;i++){console.log(i)}}main()
如上代码,setTimeout
函数虽设置了一个0延时的回调函数,但回调需在执行5000次循环后才可调用。查看Performance
面板执行helloWorld
将近延迟了400
ms,如下图所示。
如果定时器存在嵌套调用,系统会设置最短时间间隔为4ms
functionhelloWorld(){setTimeout(helloWorld,0)}setTimeout(helloWorld,0)
Chrome
中,定时器被嵌套调用5次以上,会判定当前方法阻塞,如果时间间隔小于4ms
,会将每次间隔时间设置为4
ms。如下图所示。
未激活页面,定时器执行最小间隔为1000
ms 若标签页不是当前的激活标签,定时器最小时间间隔为1000ms
,目的也是为了优化厚爱加载损耗及降低耗电量。
延迟页面时间最大值 Chrome
、Safari
、Firefox
都是32bit
存储延时值,所以最大只能存储2^31 - 1 = 2147483647(ms)
。31
是因为二进制最高位是符号位,-1
是因为有0
的存在。
宏任务与微任务
了解微任务,那宏任务也得弄明白不是~。如下表,为宏任务与微任务相关技术。
那宏任务与微任务在什么时候执行呢?
宏任务:新的任务添加到任务队列的尾部,当循环系统执行该任务的时候执行回调函数。 微任务:当前宏任务执行结束之前执行回调函数。
执行时机可以看出:每个宏任务都关联一个微任务队列。 执行顺序可以得出:先执行宏任务,然后执行当前宏任务下的微任务,若微任务产生新的微任务,则继续执行微任务,微任务执行完毕后,再继续下一轮宏任务的事件循环。
实践是检验真理的唯一标准,举个Promise的例子?
console.log('start')setTimeout(function(){//宏任务console.log('setTimeout')},0)letp=newPromise((resolve,reject)=>{console.log('初始化Promise')resolve()}).then(function(){console.log('内部Promise1')//微任务}).then(function(){console.log('内部Promise2')//微任务})p.then(function(){console.log('外部Promise1')//微任务})console.log('end')
script
是宏任务,开始执行代码,打印start
。
遇到setTimeout
宏任务,入任务队列,等待下一次事件循环。
遇到Promise
立即执行,打印初始化Promise
。
遇到new Promise().then
微任务,入script
宏任务的微任务队列,等待当前宏任务完成。
遇到p.then
微任务,入script
宏任务的微任务队列,等待当前宏任务完成。
打印end
,当前script
宏任务执行完成。
查看当前script
宏任务的微任务队列,队列不为空,取出当前队首new Promise().then
,执行打印内部Promise1
,再次碰到then
微任务,则继续执行打印内部Promise2
,执行完毕,出队。
script
宏任务下的微任务队列不为空,继续取出p.then
,执行打印外部Promise1
,出队。
script
宏任务下的微任务队列空了,开始执行下一个宏任务。
执行宏任务setTimeout
打印setTimeout
。检查任务队列已空,程序结束。
参考
JavaScript中的Event Loop(事件循环)机制 什么是Event Loop JavaScript 运行机制详解:再谈Event Loop
小工具
视频转GIF
作者:瑾行著作权归作者所有。
链接:https://juejin.cn/post/7000392227893542919