本文转载自微信公众号《前端引力》,作者一川。转载本文请联系前端Gravity公众号。1、如上所写,无论是浏览器端还是服务端Node.js,都使用了EventLoop事件循环机制,它是基于Javascript语言的单线程、非阻塞IO的特点。EventLoop事件队列中有macrotask和microtask队列。分析宏任务和微任务的运行机制,有助于我们理解代码在浏览器中的执行逻辑。那么,我们要思考几个问题:浏览器的EventLoop有什么作用?Node.js服务器的EventLoop的作用是什么?macrotasks和microtasks的方法有哪些?它是什么样的?Node.js和其他微任务方法中Process.nextick的执行顺序是什么?Vue还有一个nextick,它的逻辑是怎样的?2.浏览器的EventLoopEventLoop是Javascript引擎异步编程需要关注的知识点,也是学习JS底层原理时必须学习的重点。我们知道JS是在一个线程上执行所有的操作。虽然是单线程,但总能高效解决问题,会给我们一种“多线程”的错觉。这其实是通过一些高效合理的数据结构来实现的。调用栈(CallStack):负责跟踪所有要执行的代码。每当执行调用栈中的函数时,就会将该函数从栈中弹出,如果有代码需要输入,则进行PUSH操作。事件队列(EventQueue)事件队列:负责将新函数发送到队列中进行处理。事件执行队列符合数据结构中的队列,先进先出的特点,先进入的事件先执行,执行完后弹出事件。每当调用事件队列中的异步函数时,它都会被发送到浏览器API。根据调用堆栈收到的命令,API开始自己的单线程操作。例如,当事件执行队列操作setTimeout事件时,会发送给浏览器相应的API,API会等到约定的时间再发回调用栈处理。即它向事件队列发送操作,从而形成了Javascript中异步操作的循环系统。Javascript语言本身是单线程的,而浏览器的API作为一个独立的线程,事件循环促进了这个过程,不断检查调用栈的代码是否为空。如果为空,则从事件执行队列中加入调用栈;如果不为空,则先执行当前调用栈中的代码。在EventLoop中,每个周期称为一个tick。主要顺序是:执行栈选择最先进入队列的macrotask,执行其同步代码直到结束检查是否有microtasks,如果有,则一直执行到microtask队列为空如果是在浏览器端,则基本开始渲染页面下一轮循环tick执行宏任务中的一些异步代码,如:setTimeout注意:先执行调用栈的宏任务,一般在最后返回执行结果。实际上,EventLoop内部使用了两个队列来实现EventQueue放入的异步任务。setTimeout代表的任务称为macrotasks,放在MacrotaskQueue中;Promise代表的任务称为微任务,放在微任务队列中。主要的macrotasks和microtasks有:MacrotaskQueue:脚本整体代码setTimeout,setIntervalsetimmediateI/O(网络请求完成,文件读写完成事件)UI渲染(解析DOM,计算布局,绘图)EventListner事件监听(鼠标点击,页面滚动、缩放等)微任务队列:process.nextTickPromiseObject.observeMutationObserver在宏任务页面流程中引入了消息队列和事件循环机制。我们将这些消息队列中的任务称为宏任务。JS代码无法准确控制任务加入队列的位置,也无法控制任务在消息队列中的位置,因此很难控制开始执行任务的时间。例如:functionfunc2(){console.log(2);}functionfunc(){console.log(1);setTimeout(func2,0);}setTimeout(func,0);你认为上面的代码会打印1和2?没有。因为在JS事件循环机制中,当执行到setTimeout时,事件会被挂起,去执行一些其他的系统任务,等其他执行完成后再执行,所以执行时间间隔是不可控的。微任务微任务是一个需要异步执行的函数。执行时机是main函数执行完之后,当前macrotask结束之前。当JS执行一个脚本时,v8引擎会为它创建一个全局的执行上下文,同时v8引擎会在里面创建一个microtask队列,这个microtask队列就是用来存放microtasks的。那么微任务是如何产生的呢?使用MutationObserver监控某个DOM节点,或者为这个节点添加或删除一些子节点。当DOM节点发生变化时,会产生一个DOM变化记录的微任务。使用Promise,微任务也会在调用Promise.resolve()或Promise.reject()时生成。DOM节点变化或使用Promise产生的微任务会被JS引擎按顺序保存在微任务队列中。MutationObserver是一组用于监视DOM变化的方法。虽然监控DOM的需求比较频繁,但是早期的页面并没有提供监控的支持。唯一能做的就是进行轮询检测。如果时间间隔设置的太长,对DOM变化的响应会不够及时;如果时间间隔太短,将浪费大量无用的工作来检查DOM。从DOM4开始,W3C引入了MutationObserver,可以用来监听DOM的变化,包括属性的变化、节点的增加、内容的变化等。每次DOM节点发生变化,渲染引擎都会将变化记录封装成一个microtask,并将微任务添加到当前微任务队列。MutationObserver采用“异步+microtask”策略,通过异步操作解决同步操作的性能问题,通过microtask解决实时性问题。当JS引擎即将退出全局执行上下文并清空调用栈时,JS引擎会检查全局执行上下文中的微任务队列,然后依次执行队列中的微任务。microtasks执行过程中产生的新的microtasks不会推迟到下一个周期,而是在当前周期继续执行。微任务和宏任务是绑定的。当执行每个宏任务时,它会创建自己的微任务队列。microtask的执行时长会影响当前macrotask的执行时长。在一个macrotask中,分别创建一个macrotask和一个microtask用于回调。在任何情况下,微任务都比宏任务早执行。浏览器EventLoop的原理是:JS引擎先从宏任务队列中取出第一个任务执行,然后取出微任务中的所有任务依次执行;如果这个过程中有新的microtask产生,也需要依次执行,然后从macrotask队列中取出下一个。执行完成后,将本次macrotask事件中的所有microtask从microtask队列中取出,依次执行。执行任务队列中的所有事件。注意:一个EventLoop循环会处理一个宏任务和这个循环中产生的所有微任务。3、Node.jsEventLoopNode.js官网的定义是:当Node.js启动时,会初始化事件循环,处理提供的输入脚本(或者扔到REPL中,本文不涉及),可能会调用一些异步API,调度定时器,或者调用process.nextTick(),开始处理事件循环。Node.js中的事件循环机制上图是Node.js的EventLoop流程图。我们依次分析得到:Timers阶段:执行setTimeout和setIntervalI/O回调阶段:执行系统级的回调函数,比如TCP执行Failed回调函数新的I/O事件;执行I/O相关的回调(在几乎所有情况下,除了closedimplementer和setImmediate()scheduling),其他情况node会在合适的时候阻塞在这里。检查阶段:这里执行setImmediate()回调函数。关闭回调阶段:一些关闭回调函数,如:socket.on('close',...)。浏览器端任务队列每个事件周期只出队一个回调函数,然后执行微任务队列。在Node.js这边,只要轮到执行一个宏任务队列,队列中当前所有的任务都会被执行,但是新加入到队列尾部的任务会等待下一次poll再执行.4.Process.nextTick()process.nextTick(callback,可选参数args);Process.nextTick会将回调添加到“nextTick队列”队列中,nextTick队列会在当前Javascript栈执行完后开始执行下一个EventLoop前端按照FIFO出队。如果递归调用Process.nextTick,可能会造成死循环,需要适时终止递归。Process.nextTick实际上是一个微任务,是异步API的一部分,但从技术上讲Process.nextTick并不是事件循环(EventLoop)的一部分。如果在给定阶段的任何时间调用Process.nexttick,则传递给Process.nextTick的所有回调将在事件循环继续之前执行,这可能导致事件循环永远不会到达轮询阶段。为什么像Process.nextTick这样的API允许存在于Nodejs中?部分由于设计理念,nodejs中的API始终是异步的,即使是那些不需要异步的。functionapiCall(args,callback){if(typeofargs!=="string"){returnprocess.nextTick(callback,newTypeError("atgumentsshouldbestring"));}}我们可以看到上面的代码,我们可以传递一个错误给用户,但这只允许在用户代码执行完毕后执行。使用process.nextTick确保apiCall()回调始终在执行用户代码之后、事件循环继续工作之前执行。那么nextTick在Vue中做了什么?Vue异步执行DOM更新。当数据发生变化时,Vue会开启一个队列来缓冲同一事件循环中发生的所有数据变化。如果同一个观察者被多次触发,它只会被推入队列一次。这种缓冲期间的重复数据删除对于避免不必要的计算和DOM操作非常重要。然后在下一个事件循环中打勾。例如:当你设置vm.someData="yichuan"时,组件不会立即执行重新渲染。刷新队列时,组件将在事件循环队列清空时的下一个“滴答”更新。process.nextTick的执行顺序是:在每个EventLoop执行之前,如果有多个process.nextTicks,会影响下一次循环的执行时间。Vue:nextick方法中每次数据更新都会影响下一次视图更新5。EventLoop对渲染的影响。requestIdlecallback和requestAnimationFrame这两个方法不是JS的原生方法,而是浏览器宿主环境提供的方法。作为一个复杂的应用程序,浏览器与多个线程一起工作。JS线程可以读取和修改DOM,渲染线程也需要读取DOM。这是一个典型的多线程竞争资源的问题。因此,浏览器将这两个线程设计为互斥的,即同一时间只能有一个线程运行。JS线程和渲染线程本来是互斥的,但是requestAnimationFrame让这对不兼容的线程建立了联系,也就是在EventLoop和渲染之间建立了联系。通过调用requestAnimationFrame()方法,我们可以在浏览器下次渲染之前执行回调函数,那么下一次渲染是什么时候呢?渲染和EventLoop之间有什么联系?简而言之,就是在每个EventLoop结束之前,判断是否有渲染机会,然后重新渲染,而渲染机会是受屏幕限制的。浏览器的刷新帧率为60Hz,即1秒内刷新60次。这时候浏览器的渲染时间不需要小于16.6ms,因为渲染完就不会显示画面了。当然,浏览器不能保证每16.6ms渲染一次。另外,浏览器渲染也会受到处理器性能、js执行效率等因素的影响。requestAnimationFrame保证在浏览器下次渲染之前被调用。其实我们可以把它看成是setInterval定时器的进阶版。它们都是每隔一段时间执行一次回调函数,但是requestAnimationFrame的时间间隔是浏览器不断调整的,而setInterval的时间间隔是用户指定的。所以requestAnimationFrame更适合修改每一帧动画。requestAnimationFrame不是EventLoop中的一个宏任务,或者说它不在EventLoop的生命周期中,而是浏览器开发的一个新的钩子,发生在渲染之前。这时候,我们对微任务的理解也需要更新。在执行requestAnimationFrame的回调函数时,也有可能微任务会在requestAnimationFrame处理完后执行。因此,microtasks并不是像之前描述的在每次EventLoop之后处理,而是在JS函数调用栈清空后处理。当EventLoop中没有要处理的任务时,浏览器可能处于空闲状态。在这段空闲时间内,可以使用requestIdlecallback来执行一些优先级不高,不需要立即执行的任务,如图:同时,为了避免浏览器一直处于忙碌状态,导致requestIdlecallback函数永远无法执行回调,浏览器额外提供了一个setTimeout函数来为这个任务设置一个deadline,浏览器可以根据这个deadline来计划任务的执行。6.参考文章《Javascript核心原理精讲》《深入浅出Node.js》《Javascript高级程序设计》7。写在最后,这篇文章讲一下浏览器中的EventLoop和Node.js的区别。EventLoop本身并不是一个复杂的概念,但是我们需要根据不同运行平台的JS来理解它们之间的异同。
