浏览器和 Node.js 的 EventLoop 事件循环机制知多少?

系统 浏览器
本篇文章谈了EventLoop在浏览器和Node.js中的区别,EventLoop本身不是什么比较复杂的概念,只是我们需要根据JS的不同运行平台,理解它们之间的相同和差异。

[[439223]]

本文转载自微信公众号「前端万有引力」,作者一川 。转载本文请联系前端万有引力公众号。

1.写在前面

无论是浏览器端还是服务端Node.js,都在使用EventLoop事件循环机制,都是基于Javascript语言的单线程和非阻塞IO的特点。在EventLoop事件队列中有宏任务和微任务队列,分析宏任务和微任务的运行机制,有助于我们理解代码在浏览器中的执行逻辑。

那么,我们得思考几个问题:

  • 浏览器的EventLoop发挥着什么作用?
  • Node.js服务端的EventLoop发挥着什么作用?
  • 宏任务和微任务分别有哪些方法?
  • 宏任务和微任务互相嵌套,执行顺序是什么样的?
  • Node.js中的Process.nextick和其它微任务方法在一起的时候执行顺序是什么?
  • Vue也有个nextick,它的逻辑又是什么样的呢?

2.浏览器的EventLoop

EventLoop是Javascript引擎异步编程需要着重关注的知识点,也是在学习JS底层原理所必须学习的关键。我们知道JS在单线程上执行所有的操作,虽然是单线程的,但是总是能够高效地解决问题,并且会给我们带来一种『多线程』的错觉。这其实是通过一些高效合理的数据结构来达到这种效果的。

调用栈(Call Stack)

调用堆栈:负责追踪所有要执行的代码。每当调用堆栈中的函数执行完毕时,就会从栈中弹出此函数,如果有代码需要输入就会执行PUSH操作。

事件队列(Event Queue)

事件队列:负责将新的函数发送到队列中进行处理。事件执行队列符合数据结构中的队列,先进先出的特性,当先进入的事件先执行,执行完毕先弹出。

 

每当调用事件队列(Event Queue)中的异步函数时,都会将其发送到浏览器API。根据调用栈收到的命令,API开始自己的单线程操作。

比如,在事件执行队列操作setTimeout事件时,会现将其发送到浏览器对应的API,该API会一直等到约定的时间将其送回调用栈进行处理。即,它将操作发送到事件队列中,这样就形成了一个循环系统,用于Javascript中进行异步操作。

Javascript语言本身是单线程的,而浏览器的API充当独立的线程,事件循环促进了这一过程,它会不断检查调用栈的代码是否为空。如果为空,就从事件执行队列中添加到调用栈中;如果不为空,则优先执行当前调用栈中的代码。

在EventLoop中,每次循环称为一次tick。主要顺序是:

  • 执行栈选择最先进入队列的宏任务,执行其同步代码直到结束
  • 检查是否有微任务,如果有则执行知道微任务队列为空
  • 如果是在浏览器端,那么基本要渲染页面
  • 开始下一轮的循环tick,执行宏任务中的一些异步代码,如:setTimeout

注意:最先进行调用栈的宏任务,一般情况下都是最后返回执行的结果。

事实上,EventLoop通过内部两个队列来实现Event Queue放进来的异步任务。以setTimeout为代表的任务称为宏任务,放在宏任务队列(Macrotask Queue)中;以Promise为代表的任务称为微任务,放在微任务队列(Microtask Queue)中。

主要的宏任务和微任务有:

  • 宏任务(Macrotask Queue):
    • script整体代码
    • setTimeout、setInterval
    • setimmediate
    • I/O (网络请求完成、文件读写完毕事件)
    • UI 渲染(解析DOM、计算布局、绘制)
    • EventListner事件监听(鼠标点击、滚动页面、放大缩小等)
  • 微任务(Microtask Queue):
  • process.nextTick
  • Promise
  • Object.observe
  • MutationObserver

宏任务

页面进程中引入了消息队列和事件循环机制,我们把这些消息队列中的任务称为宏任务。JS代码中不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。例如:

  1. function func2(){ 
  2.  console.log(2); 
  3. function func(){ 
  4.  console.log(1); 
  5.   setTimeout(func2,0); 
  6.  
  7. setTimeout(func,0); 

你以为上面的代码会一次打印1和2吗,并不是。因为在JS事件循环机制中,当执行setTimeout时会将事件进行挂起,执行一些其它的系统任务,当其他的执行完毕之后才会执行,因此执行时间间隔是不可控。

微任务

微任务是一个需要异步执行的函数,执行时机是在主函数执行完毕后、当前宏任务结束前。JS执行一段脚本时,v8引擎会为其创建一个全局执行上下文,同时v8引擎会在其内部创建一个微任务队列,这个微任务队列就是用来存放微任务的。

那么微任务是如何产生的呢?

  • 使用MutationObserver监控某个DOM节点,或者为这个节点添加、删除部分子节点,当DOM节点发生变化时,就会产生DOM变化记录的微任务。
  • 使用Promise,当调用Promise.resolve()或者Promise.reject()时,也会产生微任务。

通过DOM节点变化产生的微任务或使用Promise产生的微任务会被JS引擎按照顺序保存到微任务队列中。

MutationObserver是用来监听DOM变化的一套方法,虽然监听DOM需求比较频繁,不过早期页面并没有提供对监听的支持,唯一能做的就是进行轮询检测。如果设置时间间隔过长,DOM变化响应不够及时;如果时间间隔过短,又会浪费很多无用的工作量去检查DOM。从DOM4开始,W3C推出了MutationObserver可以用于监视DOM变化,包括属性的变更、节点的增加、内容的改变等。在每次DOM节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加到当前的微任务队列中。

MutationObserver采用了"异步+微任务"策略,通过异步操作解决了同步操作的性能问题,通过微任务解决了实时性问题。

JS引擎在准备退出全局执行上下文并清空调用栈的时候,JS引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。在执行微任务过程中产生的新的微任务,并不会推迟到下一个循环中执行,而是在当前的循环中继续执行。

微任务和宏任务是绑定的,每个宏任务执行时,会创建自己的微任务队列。微任务的执行时长会影响当前宏任务的时长。在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论在什么情况下,微任务都早于宏任务执行。

浏览器EventLoop的原理是:

  • JS引擎首先从宏任务队列中取出第一个任务
  • 执行完毕后,再将微任务中的所有任务取出,按照顺序依次全部执行;如果在此过程中产生了新的微任务,也需要依次全部执行
  • 然后再从宏任务队列中取出下一个,执行完毕后,再将此宏任务事件中的微任务从微任务队列中全部取出依次执行,循环往复,知道宏任务和微任务队列中的事件全部执行完毕

注意:一次EventLoop循环会处理一个宏任务和所有此处循环中产生的微任务。

3.Node.js的EventLoop

Node.js官网的定义是:当 Node.js 启动后,它会初始化事件循环,处理已提供的输入脚本(或丢入 REPL,本文不涉及到),它可能会调用一些异步的 API、调度定时器,或者调用 process.nextTick(),然后开始处理事件循环。

Node.js中的事件循环机制

上图是Node.js的EventLoop流程图,我们依次进行分析得到:

  • Timers阶段:执行的是setTimeout和setInterval
  • I/O回调阶段:执行系统级别的回调函数,比如TCP执行失败的回调函数
  • Idle、Prepare阶段:Node内部的闲置和预备阶段
  • Poll阶段:检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
  • Check阶段:setImmediate() 回调函数在这里执行。
  • Close回调阶段:一些关闭的回调函数,如:socket.on('close', ...)。

浏览器端任务队列每轮事件循环仅出队一个回调函数,接着去执行微任务队列。而Node.js端只要轮到执行某个宏任务队列,就会执行完队列中的所有当前任务,但是每次轮询新添加到队尾的任务则会等待下一次轮询才会执行。

4.Process.nextTick()

  1. process.nextTick(callback,可选参数args); 

Process.nextTick会将callback添加到"nextTick queue"队列中,nextick queue会在当前Javascript stack执行完毕后,下一次EventLoop开始执行前按照FIFO出队。如果递归调用Process.nextTick可能会导致一个无限循环,需要去适当的时机终止递归。

Process.nextTick其实是微任务,同时也是异步API的一部分,但是从技术而言Process.nextTick并不是事件循环(EventLoop)的一部分。如果任何时刻在给定的阶段调用Process.nextick,则所有被传入Process.nextTick的回调,将会在事件循环继续往下执行前被执行,这可能导致事件循环永远无法到达轮询阶段。

为什么Process.nextTick这样的API会被允许存在于Nodejs中呢?部分原因是因为设计理念,在nodejs中api总是异步的,即使那些不需要异步的地方。

  1. function apiCall(args,callback){ 
  2.   if(typeof args !== "string"){ 
  3.    return process.nextTick(callback,new TypeError("atgument should be string")); 
  4.   } 

我们可以看到上面的代码,可以将一个错误传递给用户,但这只允许在用户代码被执行完毕后执行。使用process.nextTick可以保证apiCall()的回调总是在用户代码被执行后,且在事件循环继续工作前被执行。

那么Vue中nextTick又是做啥的呢?

vue异步执行DOM的更新,当数据发生变化时,vue会开启一个队列,用于缓冲在同一事件循环中发生的所有数据改变的情况。如果同一个watcher被多次触发,只会被推入队列中一次。这种在缓冲时去除重复数据,对于避免不必要的计算和DOM操作上非常重要。然后在下一个事件循环tick中。例如:当你设置vm.someData = "yichuan",该组件不会立即执行重新渲染。当刷新队列是,组件会在事件循环队列清空时的下一个"tick"更新。

process.nextTick的执行顺序是:每一次EventLoop执行前,如果有多个process.nextTick,会影响下一次时间循环的执行时间

Vue:nextick方法中每次数据更新将会在下一次作用到视图更新

5.EventLoop对渲染的影响

requestIdlecallback和requestAnimationFrame这两个方法不属于JS的原生方法,而是浏览器宿主环境提供的方法。浏览器作为一个复杂的应用是多线程工作的,JS线程可以读取并且修改DOM,而渲染线程也需要读取DOM,这是一个典型的多线程竞争资源的问题。所以浏览器把这两个线程设计为互斥的,即同时只能有一个线程进行运行。

JS线程和渲染线程本来是互斥的,但是requestAnimationFrame却让这对水火不相容的线程建立起了联系,即把EventLoop和渲染建立起了联系。通过调用requestAnimationFrame()方法,我们可以在浏览器下次渲染之前执行回调函数,那么下次渲染具体在什么时间节点呢?渲染和EventLoop又有着什么联系呢?

简而言之,就是在每次EventLoop结束前,判断当前是否有渲染时机即重新渲染,而渲染时机是有屏幕限制的,浏览器的刷新帧率是60Hz,即1s内刷新了60次。此时浏览器的渲染时间就没必要小于16.6ms,因为渲染了屏幕也不会进行展示,

当然浏览器也不能保证每16.6ms会渲染一次。此外,浏览器渲染还会收到处理器的性能以及js执行效率等因素的影响。

requestAnimationFrame保证在浏览器下次渲染前一定会被调用,实际上我们完全可以将其当成一个高级版的setInterval定时器。它们都是每隔一段时间执行一次回调函数,只不过requestAnimationFrame的时间间隔是浏览器不断进行调整的,而setInterval的时间间隔是用户进行指定的。因此,requestAnimationFrame更适合用于做每一帧动画的修改效果。

requestAnimationFrame不是EventLoop中的宏任务,或者说它并不在EventLoop的生命周期中,只是浏览器又开发的一个在渲染前发生的新hook。此时,我们对于微任务的认知也需要进行更新,在执行requestAnimationFrame的callback函数时,也有可能产生微任务会放在requestAnimationFrame处理完毕之后执行。因此,微任务并不像之前描述的在每一次EventLoop后执行处理,而是在JS函数调用栈清空后处理。

在EventLoop中并没有什么任务需要处理时,浏览器可能处于空闲状态,在这段空闲时间可以被requestIdlecallback利用,用于执行一些优先不高、不必立即执行的任务,如图所示:

同时,为了避免浏览器一直处于繁忙的状态,导致requestIdlecallback函数永远无法执行回调,浏览器提供了一个额外的setTimeout函数,为这个任务设置截止时间,浏览器就可以根据这个截止时间规划这个任务的执行。

6.参考文章

《Javascript核心原理精讲》

《深入浅出Node.js》

《Javascript高级程序设计》

7.写在最后 

本篇文章谈了EventLoop在浏览器和Node.js中的区别,EventLoop本身不是什么比较复杂的概念,只是我们需要根据JS的不同运行平台,理解它们之间的相同和差异。

 

责任编辑:武晓燕 来源: 前端万有引力
相关推荐

2022-01-04 21:36:33

JS浏览器设计

2021-05-27 09:00:00

Node.js开发线程

2024-01-05 08:49:15

Node.js异步编程

2012-03-09 09:11:29

Node.js

2021-06-10 07:51:07

Node.js循环机制

2021-12-18 07:42:15

Ebpf 监控 Node.js

2021-09-03 13:42:54

Node.js异步性能

2017-08-16 10:36:10

JavaScriptNode.js事件驱动

2021-12-09 07:54:19

浏览器引擎编译

2020-09-15 08:26:25

浏览器缓存

2012-02-03 09:25:39

Node.js

2023-01-31 16:43:31

​Node.js事件循环

2011-09-08 13:46:14

node.js

2021-10-15 09:56:10

JavaScript异步编程

2020-12-29 08:21:03

JavaScript微任务宏任务

2021-09-26 05:06:04

Node.js模块机制

2021-10-22 08:29:14

JavaScript事件循环

2017-02-09 15:15:54

Chrome浏览器

2019-12-17 14:45:17

浏览器事件循环前端

2020-12-23 07:37:17

浏览器HTML DOM0
点赞
收藏

51CTO技术栈公众号