转自:JS事件循环(EventLoop),面试必问,这篇文章就够了!理解JavaScript的事件循环往往伴随着宏任务和微任务、JavaScript单线程执行过程、浏览器异步机制等相关问题,而事件循环在浏览器和NodeJS中的实现也有很大的不同。熟悉事件循环,了解浏览器的运行机制,有助于我们理解JavaScript的执行过程,在排查代码运行问题时有很大帮助。本文将在理解浏览器异步执行原理和事件驱动的基础上,详细介绍JavaScript事件循环机制及其在浏览器和NodeJS中的不同表现。浏览器JS异步执行的原理JS是单线程的,即同一时间只能做一件事,所以想:为什么浏览器可以同时执行异步任务呢?因为浏览器是多线程的,当JS需要执行一个异步任务时,浏览器会启动另外一个线程来执行任务。也就是说,“JS是单线程的”就是只有一个线程在执行JS代码,就是浏览器提供的JS引擎线程(主线程)。浏览器中还有定时器线程和HTTP请求线程。这些线程主要不是用来运行JS代码的。比如在主线程中需要发送AJAX请求,这个任务就交给另一个浏览器线程(HTTP请求线程)去真正发送请求。请求返回后,将回调中需要执行的JS回调交给JS引擎线程执行。也就是说浏览器才是真正执行发送请求任务的角色,JS只负责执行最后的回调处理。所以这里的异步并不是JS自己实现的,实际上是浏览器提供的一种能力。以Chrome为例,浏览器不仅有多个线程,还有多个进程,比如渲染进程、GPU进程、插件进程等。而且每个标签页都是一个独立的渲染进程,所以一个标签页异常崩溃后,其他标签页基本不会受到影响。作为前端开发者,主要关注的是它的渲染过程,包括JS引擎线程、HTTP请求线程、定时器线程等,这些线程为JS在浏览器中完成异步任务提供了基础。事件驱动分析浏览器异步任务的执行原理背后其实是一套事件驱动机制。事件触发、任务选择和任务执行均由事件驱动机制完成。NodeJS和浏览器的设计都是基于事件驱动的。简而言之,特定任务由特定事件触发。这里的事件可以由用户操作触发,比如点击事件;它们也可以由程序自动触发,比如浏览器中的定时器线程会在计时结束后触发定时器事件。本文的主题,事件循环,实际上是事件驱动模式下管理和执行事件的一组过程。以一个简单的场景为例。假设游戏界面上有一个移动按钮和一个角色模型。每次点击右移后,需要重新渲染人物模型的位置,向右移动1个像素。根据渲染的时机不同,我们可以采用不同的方式来实现:实现方式一:事件驱动。点击按钮后,修改坐标positionX时,立即触发界面渲染事件,触发重新渲染。实现方式二:状态驱动或者数据驱动。点击按钮后,只修改坐标positionX,不触发界面渲染。在此之前,会启动一个定时器setInterval,或者使用requestAnimationFrame不断检测positionX是否发生变化。如果发生变化,立即重新渲染。浏览器中的点击事件处理通常也是事件驱动的。在事件驱动中,当一个事件被触发时,触发的事件会被暂时存储在一个队列中,并按顺序排列。JS同步任务执行完成后,会将待处理的事件从队列中取出进行处理。所以什么时候取任务,优先处理哪些任务,这是由事件循环过程控制的。浏览器JS中的事件循环执行栈和任务队列,在解析一段代码时,会将同步代码按顺序排列在某个地方,即执行栈,然后依次执行其中的函数。遇到异步任务,就会交给其他线程处理。当当前执行栈中的所有同步代码都执行完后,完成的异步任务的回调将从一个队列中取出,加入到执行栈中继续执行。移交给其他线程,...,等等。其他异步任务完成后,将回调放入待执行任务队列中待执行的栈上。JS按顺序执行执行栈中的方法。每执行一个方法,都会为这个方法生成一个唯一的执行环境(context上下文)。方法执行完毕后,当前执行环境会被销毁并出栈。本方法(即消费完成)再进行下一个方法。可以看出,在事件驱动模式下,至少包含了一个执行周期来检测任务队列中是否有新的任务。通过不断循环取出异步回调执行,这个过程就是一个事件循环,每一个循环就是一个事件循环或者一个tick。宏任务和微任务的任务队列不止一个。根据任务类型的不同,可以分为微任务队列和宏任务队列。在事件循环中,同步代码执行完成后,执行栈首先检查微任务队列中是否有待执行的任务。如果没有,就去宏任务队列中检查是否有任务要执行,等等。微任务一般在当前周期先执行,而宏任务会等到下一个周期。因此,microtasks一般先于macrotasks执行,并且只有一个microtasks队列,也可能有多个macrotasks队列。另外,我们常见的点击和键盘事件也属于宏任务。常用宏任务:setTimeout()setInterval()setImmediate()常用微任务:promise.then(),promise.catch()newMutaionObserver()process.nextTick()console.log('同步码1');setTimeout(()=>{console.log('setTimeout')},0)newPromise((resolve)=>{console.log('同步代码2')resolve()}).then(()=>{console.log('promise.then')})console.log('同步码3');上面的代码会按照以下顺序输出:“同步码1”、“同步码2”、“同步码3”、“promise.then”、“setTimeout”,具体分析如下:setTimeout回调和promise.then是异步执行的,会在所有同步代码之后执行;顺便说一句,如果在浏览器中将setTimeout的延迟设置为0,将默认为4ms,而NodeJS为1ms。具体值可能不固定,但不是0。promise.then虽然写在后面,但是执行顺序比setTimeout高,因为是microtask;newPromise是同步执行的,promise.then中的回调是异步的。也有人这样理解:微任务在当前事件循环结束时执行;宏任务在下一个事件循环开始时执行。我们来看看microtasks和macrotasks的本质区别是什么?我们已经知道,当JS遇到异步任务时,会将任务交给其他线程处理,其主线程后面会继续执行同步任务。例如,setTimeout的计时将由浏览器的定时器线程处理。当计时结束时,定时器回调任务会被放入任务队列,等待主线程取出来执行。前面我们提到,由于JS是单线程执行的,所以要执行异步任务,需要浏览器的其他线程来辅助,也就是多线程是JS异步任务的一个明显特征。下面分析一下promise.then(microtask)的处理过程。当promise.then执行时,V8引擎不会将异步任务交给浏览器的其他线程,而是将回调存放在自己的队列中。当前执行栈执行完成后,V8引擎会立即执行存储在promise.then中的队列,promise.then微任务没有多线程参与,甚至从某些角度来看,微任务也不能完全异步,它只是在写的时候修改了代码的执行顺序。setTimeout有“定时等待”的任务,需要定时器线程执行;ajax请求有“发送请求”的任务,需要HTTP线程执行,而promise.then没有任何需要其他线程执行的异步任务,它只有回调,即使有,也是嵌套在里面的另一个宏任务。简单总结一下microtasks和macrotasks的本质区别:macrotasks特点:有明确的异步任务需要执行和回调;需要其他异步线程支持。Microtask特点:没有明确的异步任务执行,只有回调;不需要其他异步线程支持。在定时器错误事件循环中,总是先执行同步代码,然后异步回调从任务队列中取出执行。执行setTimeout时,浏览器会启动一个新的线程对定时器进行计时。定时器结束后触发定时器事件并将回调存放在宏任务队列中,等待JS主线程取回执行。如果此时主线程还在执行同步任务,那么此时的宏任务就得先挂起,从而造成定时器不准的问题。同步代码花费的时间越长,定时器的误差就越大。不仅仅是同步代码,因为microtask会先执行,所以microtask也会影响时序。如果同步代码中存在无限循环或者微任务中的递归不断地启动其他微任务,那么宏任务中的代码可能永远无法执行。因此,提高主线程代码的执行效率非常重要。视图更新渲染微任务队列完成后,即一个事件循环结束后,浏览器会进行视图渲染。当然这里会有浏览器优化,可能会结合多次循环的结果重绘一次视图。因此,视图的更新是在事件循环之后进行的,所以并不是每次对Dom的操作都一定会立即刷新视图。requestAnimationFrame回调会在视图重绘之前执行,所以requestAnimationFrame是微任务还是宏任务是有争议的。从这个角度来看,它既不应该属于microtask,也不属于macrotask。NodeJS中的事件循环JS引擎本身并没有实现事件循环机制,是由它的宿主实现的。浏览器中的事件循环主要由浏览器实现,NodeJS也有自己的事件循环实现。在NodeJS中,也有一个循环+任务队列的过程,微任务优先于宏任务。总体性能与浏览器相同,但与浏览器存在一些差异,增加了一些新的任务类型和任务阶段。接下来介绍一下NodeJS中的事件循环过程。因为NodeJS中的异步方法是基于V8引擎的,所以浏览器中包含的异步方法在NodeJS中也是一样的。NodeJS中还有一些其他常见的异步形式。文件I/O:异步加载本地文件。setImmediate():类似于setTimeout设置0ms,在一些同步任务完成后立即执行。process.nextTick():在一些同步任务完成后立即执行。server.close、socket.on('close',...)等:关闭回调。如果上述形式与setTimeout、promise等同时存在,如何分析代码的执行顺序呢?只要了解了NodeJS的事件循环机制,就一目了然了。事件循环模型NodeJS的跨平台能力和事件循环机制是基于Libuv库实现的,你不需要关心这个库的具体内容。我们只需要知道Libuv库是事件驱动的,对不同平台的API实现进行了封装和统一。NodeJS中的V8引擎解析JS代码并调用NodeAPI,然后NodeAPI将任务分配给Libuv,最后将执行结果返回给V8引擎。在Libux中实现了一套事件循环流程来管理这些任务的执行,所以NodeJS的事件循环主要是在Libuv中完成的。事件循环各个阶段NodeJS中JS的执行,我们主要需要关心的流程分为以下几个阶段。以下每个阶段都有自己独立的任务队列。当相应的阶段执行时,判断当前阶段的任务队列。是否有任务需要处理。计时器阶段:执行所有setTimeout()和setInterval()回调。pendingcallbacks阶段:某些系统操作的回调,例如TCP链接错误。除了定时器、close、setImmediate之外的大部分回调都在这个阶段执行。poll阶段:轮询等待新的连接和请求等事件,执行I/O回调等。V8引擎解析完JS代码,传递给Libuv引擎后,首先进入这个阶段。如果该阶段的任务队列已经执行完毕,则进入check阶段执行setImmediate回调(如果有setImmediate),或者等待新的任务进来(如果没有setImmediate)。在等待新任务时,如果有定时器超时,则直接进入定时器阶段。这个阶段可能会阻塞等待。检查阶段:setImmediate回调函数执行。closecallbacks阶段:关闭回调执行,如socket.on('close',...)。以上每个stage都会执行当前stage的taskqueue,然后继续执行当前stage的microtaskqueue。只有当前阶段的所有微任务都执行完,才会进入下一阶段。这也是和浏览器中逻辑比较不同的地方,但是浏览器不需要区分这些阶段,而且异步操作的种类很多,所以不需要刻意区分两者。代码如下所示:constfs=require('fs');fs.readFile(__filename,(data)=>{//轮询(I/O回调)阶段console.log('readFile')Promise.resolve().then(()=>{console.error('promise1')})Promise.resolve().then(()=>{console.error('promise2')})});setTimeout(()=>{//定时器阶段console.log('timeout');Promise.resolve().then(()=>{console.error('promise3')})Promise.resolve().then(()=>{console.error('promise4')})},0);//下面的代码只是同步阻塞1秒,保证上面的异步任务就绪varstartTime=newDate().getTime();varendTime=startTime;while(endTime-startTime<1000){endTime=newDate().getTime();}//finaloutputtimeoutpromise3promise4readFilepromise1promise2与浏览器的另一个区别还体现在同阶段任务执行的不同,宏任务和微任务测试代码在timers阶段如下:setTimeout(()=>{console.log('timeout1')Promise.resolve().then(function(){console.log('promise1')})},0);setTimeout(()=>{console.log('timeout2')承诺se.resolve().then(function(){console.log('promise2')})},0);在浏览器中运行会在每个macrotask完成后优先执行microtasks,并输出"timeout1","promise1","timeout2","promise2"runinNodeJS因为输出timeout1时,当前处于timers阶段,所以所有定时器回调都会在执行微任务队列之前执行,即输出“timeout1”、“timeout2”、“promise1”、“promise2”。以上区别可以通过浏览器和NodeJS10的对比来验证,是不是有点反程序员的感觉?所以在NodeJS11版本之后,修改了这里的逻辑,让它尽可能和浏览器保持一致,即每次定时器执行完后,先检查microtask队列,所以NodeJS11之后的输出已经和浏览器保持一致了浏览器。nextTick、setImmediate和setTimeout在实际项目中,我们经常使用Promise或者setTimeout来做一些需要延时的任务,比如一些耗时的计算或者日志上传等,目的是不想让它的执行占用主线程还是要依赖整个同步代码执行后的结果。NodeJS中的process.nextTick()和setImmediate()具有类似的效果。其中setImmediate()我们之前说过check阶段执行,process.nextTick()的执行时机不同。它早于promise.then()的执行。在同步任务之后,所有其他异步任务之前,nextTick将首先执行。可以想象,nextTick任务是放在当前循环之后的,类似于promise.then(),但是比promise.then()早。意思是当前同步代码执行完毕后,不管其他异步任务,尽快执行nextTick。etTimeout(()=>{console.log('timeout');},0);Promise.resolve().then(()=>{console.error('promise')})process.nextTick(()=>{console.error('nextTick')})//Output:nextTick,promise,timeout接下来我们看一下setImmediate和setTimeout,它们分别属于不同的执行阶段,即timers阶段和check阶段。setTimeout(()=>{console.log('timeout');},0);setImmediate(()=>{console.log('setImmediate');});//输出:超时,setImmediate分析上面code,在第一个循环之后,setTimeout和setImmediate被添加到各自阶段的任务队列中。第二个循环先进入timers阶段,执行timer队列回调,然后pending回调和poll阶段都没有任务,所以进入check阶段执行setImmediate回调。所以最后的输出是“timeout”,“setImmediate”。当然,这里也有一种理论上的极端情况,就是第一轮循环结束后需要很短的时间,以至于setTimeout的计时还没有结束。这时第二轮循环会先执行setImmediate回调。再看下面这段代码,它只是把上一段代码放在一个I/O任务回调中,它的输出会和上一段代码相反。constfs=require('fs');飞秒。readFile(__filename,(data)=>{console.log('readFile');setTimeout(()=>{console.log('timeout');},0);setImmediate(()=>{console.log('setImmediate');});});//output:readFile,setImmediate,timeout如上代码所示:第一个周期没有需要执行的异步任务队列;定时器第二次循环等阶段没有任务,只有poll阶段有I/O回调任务,即输出“readFile”;参考前面事件阶段的描述,接下来poll阶段会检测是否有setImmediate任务队列,进入check阶段,否则重新判断,如果有timer任务回调,则返回timers阶段,所以你应进入校验阶段执行setImmediate,输出"setImmediate";然后进入最后的关闭回调阶段,本次循环结束;最后进行前三个循环,进入定时器阶段,输出“timeout”。所以最终输出“setImmediate”出现在“timeout”之前。可以看出,两者的执行顺序与当前执行阶段有关。总结本文详细讲解了事件循环在浏览器和NodeJS中的过程。虽然底层机制不同,但最终的表现基本相同。了解事件循环的原理,可以帮助我们准确地分析和使用各种异步形式,减少代码的不确定性,对一些执行效率的优化也有一个清晰的思路。
