前言
拆解实现 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将近延迟了400ms,如下图所示。
如果定时器存在嵌套调用,系统会设置最短时间间隔为4ms
functionhelloWorld(){setTimeout(helloWorld,0)}setTimeout(helloWorld,0)Chrome中,定时器被嵌套调用5次以上,会判定当前方法阻塞,如果时间间隔小于4ms,会将每次间隔时间设置为4ms。如下图所示。
未激活页面,定时器执行最小间隔为1000ms 若标签页不是当前的激活标签,定时器最小时间间隔为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