大家好,我是CUGGZ。事件循环是JavaScript中一个非常重要的概念,下面就来看看浏览器和Node.js中事件循环的原理,以及两者的区别吧!一、异步执行原理(一)单线程JavaScript我们知道JavaScript是一种单线程语言,主要用于与用户交互和操作DOM。JavaScript有同步和异步的概念,解决了代码阻塞的问题:同步:如果一个函数返回时调用者能得到预期的结果,那么这个函数就是同步的。异步:如果调用者在函数返回时不能得到预期的结果,但以后需要通过某种方式获得,那么这个函数就是异步的。单线程有什么好处?JS运行时UI渲染可能会阻塞,这意味着两个线程是互斥的。这是因为JS可以修改DOM。如果执行JS时UI线程还在工作,可能会导致UI渲染不安全。得益于JS的单线程运行,可以节省内存,节省上下文切换时间。(2)多线程浏览器JS是单线程的,同时只能做一件事,那么为什么浏览器可以同时执行异步任务呢?这是因为浏览器是多线程的。当JS需要执行异步任务时,浏览器会启动另一个线程来执行任务。也就是说,JavaScript是单线程的,也就是说只有一个线程执行JavaScript代码,就是浏览器提供的JavaScript引擎线程(主线程)。此外,浏览器中还有定时器线程、HTTP请求线程等线程。这些线程主要不是用来执行JS代码的。比如在主线程中需要发送一个数据请求,这个任务就会交给异步HTTP请求线程去执行。请求数据返回后,回调中需要执行的JS回调会交给JS引擎线程执行。也就是说,浏览器才是真正执行发送请求任务的角色,而JS只负责执行最后的回调处理。所以这里的异步不是JS自己实现的,而是浏览器提供的能力。下图是Chrome浏览器的架构图:可以看到,Chrome不仅有多进程,还有多线程。以渲染进程为例,包括GUI渲染线程、JS引擎线程、事件触发线程、定时器触发线程、异步HTTP请求线程。这些线程为JS在浏览器中完成异步任务提供了基础。2.浏览器事件循环中的JavaScript任务分为同步和异步两种:同步任务:在主线程中排队等待执行的任务,只有一个任务执行完才能执行下一个任务;异步任务:不进入主线程,而是放在任务队列中。如果有多个异步任务,则需要在任务队列中排队等待。任务队列类似于缓冲区。下一个任务会被移到执行栈,主线程会执行调用栈的任务。上面说了任务队列和执行栈,我们先来看看这两个概念。(1)执行栈和任务队列执行栈:从名字就可以看出,执行栈在数据结构中使用的是栈结构,是一种存放函数调用的栈结构,遵循先进后出的原则.它主要负责跟踪所有要执行的代码。每当一个函数被执行时,它就会从栈中弹出(pop);如果有代码需要执行,就会被推送。下图就是一个例子:执行这段代码的时候,会先执行一个main函数,然后再执行我们的代码。根据先进后出的原则,最后执行的函数会先出栈。从图中也可以发现,foo函数会最后执行,执行完就会出栈。当JavaScript按顺序执行执行栈中的方法时,每执行一个方法,都会为其生成一个唯一的执行环境(上下文)。弹出这个方法,继续下一个方法。任务队列:从名字就可以看出,任务队列在数据结构中使用了队列结构,用来存放异步任务,遵循先进先出的原则。它主要负责将新任务发送到队列中进行处理。JavaScript在执行代码时,会将执行栈中的同步代码按顺序排列,然后依次执行其中的函数。当遇到异步任务时,将其放入任务队列,当当前执行栈的所有同步代码执行完毕后,会从异步任务队列中取出完成的异步任务的回调,放入执行栈继续执行,以此类推,直到所有任务执行完毕。JavaScript任务的执行顺序如下:在事件驱动模式下,至少包含一个执行循环来检测任务队列中是否有新的任务。通过不断循环,将异步任务的回调取出来执行。这个过程就是事件循环,每一个循环就是一个事件循环。(2)宏任务和微任务队列其实不止一种。根据任务类型的不同,可以分为微任务队列和宏任务队列。常见任务如下:宏任务:script(整体代码)、setTimeout、setInterval、I/O、UI交互事件、setImmediate(Node.js环境)。微任务:Promise、MutaionObserver、process.nextTick(Node.js环境)。任务队列的执行顺序如下:可以看出,Eventloop在处理宏任务和微任务逻辑时的执行是这样的:JavaScript引擎先从宏任务队列中取出第一个任务;所有的任务都取出来依次执行(这里不仅包括开始执行时队列中的微任务),如果在这一步产生了新的微任务,也需要执行,也就是说在执行过程中微任务。新的微任务不会推迟到下一个周期,而是在当前周期继续执行。然后从宏任务队列中再取一个。执行完成后,再次取出microtask队列中的所有任务,如此循环,直到将两个队列中的任务全部取出。也就是说,一个Eventloop循环会处理一个macrotask以及这个循环中产生的所有microtask。下面用一个例子来体验一下事件循环: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"这段代码的执行过程是怎样的?遇到第一个console,是一段同步代码,加入执行栈,执行完出栈,打印出“同步代码1”。遇到setTimeout,是宏任务,加入宏任务队列。newPromise中遇到console时,是一段同步代码,加入执行栈,执行并出栈,打印出“同步代码2”。当遇到Promise时,它??是一个微任务并添加到微任务队列中。遇到第三个console,是一段同步代码,加入执行栈,执行出栈,打印出“同步代码3”。此时执行栈为空,执行完microtask队列中的所有任务,并打印出“promise.then”。执行完微任务队列中的任务后,再执行宏任务队列中的一个任务,打印出“setTimeout”。从上面的macrotasks和microtasks的工作流程可以得出以下结论:microtasks和macrotasks是绑定的,每个macrotasks在执行时都会创建自己的microtasks队列。microtask的执行时长会影响当前macrotask的执行时长。比如在执行一个宏任务的过程中,产生了10个微任务,执行每个微任务的时间为10ms,那么执行这10个微任务的时间就是100ms。也可以说这10个微任务让宏任务的执行时间增加了100ms。在一个macrotask中,分别创建一个macrotask和一个microtask用于回调。在任何情况下,微任务都比宏任务执行得早(优先级更高)。那么问题来了,任务队列为什么要分microtasks和macrotasks呢?它们之间的本质区别是什么?当JavaScript遇到异步任务时,会把任务交给其他线程去执行(比如遇到setTimeout任务,会交给定时器触发线程去执行,计时结束后会将定时器回调任务放入任务队列等待主线程取出执行),主线程会继续执行后续的同步任务。对于微任务,比如promise.then,在执行promise.then时,浏览器引擎不会将异步任务交给其他浏览器线程执行,而是会回调一个队列中的任务,当任务在执行栈中时执行完后,执行promise.then所在的微任务队列。因此,macrotasks和microtasks的本质区别如下:Microtasks:不需要特定的异步线程来执行,没有明确的异步任务可以执行,只有回调。宏任务:需要特定的异步线程执行,有明确的异步任务执行,有回调。3.Node.js的事件循环(1)事件循环的概念对于Node.js的事件循环,官网是这样描述的:Node.js启动时,初始化事件循环,处理提供的输入脚本(或放入REPL中,本文档未涵盖)可能会进行异步API调用、调度计时器或callprocess.nextTick(),然后开始处理事件循环。翻译过来就是:当Node.js启动的时候,会初始化一个事件循环,来处理输入的脚本,可能会进行异步API调用,调度一个定时器或者调用process.nextTick(),然后开始处理事件循环。JavaScript和Node.js都是基于V8引擎的,浏览器中包含的异步方法在NodeJS中也是一样的。此外,Node.js中还有一些其他形式的异步:文件I/O:异步加载本地文件。setImmediate():类似于setTimeout设置0ms,在一些同步任务完成后立即执行。process.nextTick():在一些同步任务完成后立即执行。server.close、socket.on('close',...)等:关闭回调。这些异步任务的执行需要依赖于Node.js的事件循环机制。Node.js中的事件循环与浏览器中的完全不同。Node.js使用V8作为js解析引擎,使用自己设计的libuv进行I/O处理。libuv是一个事件驱动的跨平台抽象层,封装了不同操作系统的一些底层特性,对外提供统一的API,其中也实现了事件循环机制,如下图所示:根据上图可以看出Node.js的运行机制是这样的:V8引擎负责解析JavaScript脚本。解析后的代码调用NodeAPI。libuv库负责执行NodeAPI。它将不同的任务分配给不同的线程,形成一个EventLoop(事件循环),并将任务的执行结果以异步的方式返回给V8引擎。V8引擎将结果返回给用户。(2)事件循环的过程libuv引擎中的事件循环分为6个阶段,它们会按顺序反复运行。每当进入某个阶段,就会从对应的回调队列中取出函数执行。当队列为空或回调函数执行次数达到系统设定的阈值时,进入下一阶段。下面是Eventloop事件循环的流程:整个流程分为六个阶段,当这六个阶段执行一次,就可以看成是Eventloop循环过程的执行。我们来看看这六个阶段都做了什么:timers阶段:执行timer的回调(setTimeout,setInterval),由poll阶段控制。I/O回调阶段:主要执行系统级的回调函数,比如TCP连接失败的回调。idle,preparestage:仅在Node.js内部使用,可以忽略。poll阶段:轮询等待新连接和请求等事件,执行I/O回调等。Check阶段:执行setImmediate()的回调。关闭回调阶段:执行关闭请求的回调函数,如socket.on('close',...)。注意:以上每个stage都会执行当前stage的taskqueue,然后继续执行当前stage的microtaskqueue。只有当前阶段的所有微任务都执行完了,才会进入下一阶段,这也和浏览器中的逻辑有关。哪里有很大的不同。其中,比较重要的是第四阶段:poll。在这个阶段,系统主要做了两件事:回到timer阶段执行回调。执行I/O回调。如果进入这个阶段时没有设置定时器,会出现以下情况:(1)如果poll队列不为空,则遍历回调队列同步执行,直到队列为空或达到系统限制。(2)如果poll队列为空,会出现以下情况:如果有setImmediate回调要执行,poll阶段会停止,进入check阶段执行回调。如果没有setImmediate回调执行,它会等待回调被添加到队列中并立即执行回调。还会有一个超时设置,防止它永远等待。当定时器设置好,poll队列为空时,会判断是否有定时器超时,有则返回定时器阶段执行回调。这个过程的具体执行流程如下图所示:(3)宏任务和微任务Node.js事件循环的异步队列也分为宏任务队列和微任务队列两种。常见宏任务:setTimeout、setInterval、setImmediate、script(整体代码)、I/O操作等常见微任务:process.nextTick、newPromise().then(callback)等(4)process.nextTick()上面说到process.nextTick(),它是node中新引入的一个任务队列,在上述阶段结束进入下一阶段之前会立即执行。Node.js官方文档解释如下:process.nextTick()在技术上不是事件循环的一部分。相反,nextTickQueue将在当前操作完成后被处理,而不管事件循环的当前阶段。在这里,操作被定义为从底层C/C++处理程序的转换,并处理需要执行的JavaScript。例如下面的代码:setTimeout((){console.log('timeout');},0);Promise.resolve()。then((){console.error('promise')})process.nextTick((){console.error('nextTick')})输出如下:nextTickpromisetimeout可以看出,process.nextTick()先于promise回调被执行。(5)setImmediate和setTimeout上面也提到了setImmediate和setTimeout,它们很相似,主要区别在于调用的时机:setImmediate:在poll阶段完成时执行,也就是check阶段。setTimeout:在poll阶段空闲,设置时间到了时执行,但是在timer阶段执行。例如下面的代码:setTimeout((){console.log('timeout');},0);setImmediate((){console.log('setImmediate');});输出结果如下:timeoutsetImmediate在上述代码执行过程中,在第一次循环后,将setTimeout和setImmediate添加到各自阶段的任务队列中。第二个循环先进入timers阶段,执行timer队列回调,然后pending回调和poll阶段都没有任务,所以进入check阶段执行setImmediate回调。所以最后输出的是timeout,setImmediate。###=4.Node和浏览器事件循环的区别Node.js和浏览器事件循环的区别如下:Node.js:microtask在事件循环的各个阶段之间执行。浏览器:microtask是在eventloop的macrotask执行完之后执行的。Nodejs和浏览器的eventloop流程对比如下:执行全局的Script代码(和浏览器没有区别)。清除微任务队列:请注意,Node有一种特殊的方法来清除微任务队列。在浏览器中,我们只有一个微任务队列需要处理;但是在Node中,有两种类型的微任务队列:next-tick队列和其他队列。其中next-tick队列专门用于收敛process.nextTick派发的异步任务。清空队列时,先清空next-tick队列中的任务,再清空其他微任务。开始执行宏任务。请注意,Node执行宏任务的方式与浏览器不同:在浏览器中,我们每次出列并执行一个宏任务;而在Node中,我们每次都会尝试清除当前阶段对应的宏任务队列中的所有任务(除非达到系统限制)。从第3步开始,会进入3->2->3->2…的循环。
