聊聊事件循环

30 阅读5分钟

基本概念

事件循环机制(Event Loop) 本身是一个排序机制。

其中我们常见的事件循环本身其实是在 HTML 标准文档中定义的:

html.spec.whatwg.org/multipage/w…

根据标准中对定义的描述总结:事件循环本质上是浏览器用于协调用户交互、脚本执行、渲染、网络等事件的一个机制。

所以,”JS的事件循环“这个描述不太严谨。因为无论是从规范的定义还是从实现来讲,都不能说事件循环是”JS的“。

浏览器的事件循环

事件循环流程

在浏览器中基本按照HTML的规范来实现:

根据规范,每个线程都有一个事件循环(Event Loop)。 每个事件循环有至少一个任务队列(Task Queue,也可以称作Macrotask宏任务),各个任务队列中放置着不同来源(或者不同分类)的任务,可以让浏览器根据自己的实现来进行优先级排序。

以及一个微任务队列(Microtask Queue),主要用于处理一些状态的改变,UI渲染工作之前的一些必要操作(可以防止多次无意义的UI渲染)。

详细的执行步骤如下:

  1. 从任务队列中取出一个宏任务并执行
  2. 检查微任务队列,执行并清空微任务队列,如果在微任务的执行中又加入了新的微任务,也会在这一步一起执行
  3. 微任务执行完毕后继续执行下一个宏任务
  4. 主线程不断重复上面的步骤

宏任务、微任务

宏任务队列

  • DOM 操作响应
  • 用户交互 (鼠标、键盘)
  • 网络请求
  • History API 操作
  • 定时器 (setTimeout 等)

微任务队列(js语言内部的事件队列,没有明确的规定,通常有以下几种)

  • Promise 的成功 .then 与失败 .catch (以及其他事件)
  • MutationObserver
  • Object.observe (已废弃)

代码题

console.log(1);

new Promise(resolve => {
    resolve();
    console.log(2);
})
.then(() => {
    console.log(3);
});

setTimeout(() => {
    console.log(4);
}, 0);

console.log(5);

// 结果 1、 2、 5、 3、 4

这个题结合我们上面的描述就能很轻易的做出来,这里唯一的坑就是 new Promise 时候马上执行,.then 里面的内容才是微任务队列中的内容。

Node的事件循环

前面我们说过,事件循环是引擎根据规范去实现的。而Node本身不涉及HTML所以不会遵循HTML的规范,它有一套自己的排队机制。

Node.js也使用V8作为Js引擎,但I/O处理方面使用了libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现的。

对于Node.js的事件循环,官网的描述:当Node.js启动时,它会初始化一个事件循环,来处理输入的脚本,这个脚本可能进行异步API的调用、调度计时器或调用process.nextTick(),然后开始处理事件循环。

Node.js 内嵌的Js引擎是V8,所以一般浏览器中包含的异步方式在 NodeJS 中也是一样的。除此之外,Node.js中还有一些其他的异步形式:

  • 文件 I/O:异步加载本地文件。
  • setImmediate():与 setTimeout 设置 0ms 类似,在某些同步任务完成后立马执行。
  • process.nextTick():在某些同步任务完成后立马执行。
  • server.close、socket.on('close',...)等:关闭回调。

这些异步任务的执行就需要依靠Node.js的事件循环机制了。

事件循环的流程

libuv引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。下面是Node官方文档介绍的Eventloop 事件循环的流程:

整个流程分为六个阶段,当这六个阶段执行完一次之后,才可以算得上执行了一次 Eventloop 的循环过程。下面来看下这六个阶段都做了哪些事:

  • timers 阶段:执行timer(setTimeout、setInterval)的回调,由 poll 阶段控制;
  • I/O callbacks 阶段:主要执行系统级别的回调函数,比如 TCP 连接失败的回调;
  • idle, prepare 阶段:仅Node.js内部使用,可以忽略;
  • poll 阶段:轮询等待新的链接和请求等事件,执行 I/O 回调等;
  • check 阶段:执行 setImmediate() 的回调;
  • close callbacks 阶段:执行关闭请求的回调函数,比如socket.on('close', ...)

注意:上面每个阶段都会去执行完当前阶段的任务队列,然后继续执行当前阶段的微任务队列,只有当前阶段所有微任务都执行完了,才会进入下个阶段,这里也是与浏览器中逻辑差异较大的地方。

其中,这里面比较重要的就是第四阶段:poll,这一阶段中,系统主要做两件事:

回到 timer 阶段执行回调 执行 I/O 回调

在进入该阶段时如果没有设定了 timer 的话,会出现以下情况: (1)如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制; (2)如果 poll 队列为空时,会出现以下情况:

如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调; 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去;

当设定了 timer 且 poll 队列为空,则会判断是否有 timer 超时,如果有的就会回到 timer 阶段执行回调。 这一过程的具体执行流程如下图所示:

Node中的宏任务和微任务

Node中事件循环的异步队列也分为两种:宏任务队列和微任务队列。

常见的宏任务:setTimeoutsetIntervalsetImmediate、script(整体代码)、 I/O 操作 常见的微任务:process.nextTicknew Promise().then(回调)

process.nextTick() 

上面提到了process.nextTick(),它是node中新引入的一个任务队列,它会在上述各个阶段结束时立即执行。

举个例子:

setTimeout(() => {
    console.log('timeout')
}, 0);

Promise.resolve().then(() => {
    console.error('promise')
})

process.nextTick(() => {
    console.error('nextTick')
})
//  nextTick 、promise 、timeout

可以看到,process.nextTick()是优先于promise的回调执行。