一文搞懂JS事件循环:从浏览器到Node.js的完整解析

作者:接口漫谈时间:2026-10-25 21:48:03浏览:634

本文深入解析JavaScript单线程模型下的异步执行原理,详细对比浏览器与Node.js中事件循环(Event Loop)的机制差异。重点阐述宏任务与微任务的执行顺序、执行栈与任务队列的工作方式,以及Node.js中libuv库的六个阶段流程,帮助开发者彻底掌握异步编程核心概念。

1. 异步执行原理

(1)单线程的JavaScript

JavaScript 被设计为单线程语言,其核心职责是与用户进行交互以及操作 DOM。

为了应对代码执行时的阻塞问题,JavaScript 引入了同步与异步的概念:

  • 同步:函数返回时,调用者能立即获得预期结果。
  • 异步:函数返回时,调用者无法立即获得结果,需通过特定机制在未来获取。

单线程模式带来了哪些优势?

  • JavaScript 执行时可能会阻塞 UI 渲染,这体现了两个线程的互斥性。因为 JS 可以修改 DOM,若 JS 执行期间 UI 线程同时工作,可能导致渲染不安全。
  • 得益于 JS 的单线程特性,系统可以节省内存空间,并减少上下文切换带来的时间损耗。

(2)多线程的浏览器

既然 JS 是单线程的,同一时间只能处理一件事,浏览器为何能同时执行异步任务?

原因在于浏览器本身是多线程的。当 JS 需要执行异步任务时,浏览器会启动额外的线程来协助。所谓“JavaScript 是单线程的”,仅指执行 JS 代码的线程只有一个,即浏览器提供的 JavaScript 引擎线程(主线程)。此外,浏览器还包含定时器线程、HTTP 请求线程等,这些线程并非用于执行 JS 代码本身。

例如,主线程发起数据请求时,会将任务交给异步 HTTP 请求线程处理。待数据返回后,再将回调函数中的 JS 代码交回 JavaScript 引擎线程执行。这表明,浏览器才是真正执行发送请求的角色,而 JS 仅负责处理最终的回调。因此,这种异步能力并非 JS 自身实现,而是由浏览器提供的支持。

下图展示了 Chrome 浏览器的架构:

1. 异步执行原理

从图中可见,Chrome 拥有多个进程和线程。以渲染进程为例,它包含 GUI 渲染线程、JS 引擎线程、事件触发线程、定时器触发线程以及异步 HTTP 请求线程。这些线程共同构成了 JS 在浏览器中完成异步任务的基础。

2. 浏览器事件循环

JavaScript 的任务主要分为同步和异步两类:

同步任务:在主线程上排队执行,只有前一个任务执行完毕,才会执行下一个。

异步任务:不进入主线程,而是放入任务队列。若存在多个异步任务,则需在队列中排队等待。任务队列类似缓冲区,任务下一步会被移至执行栈,由主线程调用栈中的任务执行。

前文提到了任务队列和执行栈,下面先对这两个概念进行解析。

(1)执行栈与任务队列

① 执行栈:顾名思义,执行栈基于数据结构中的栈结构,用于存储函数调用,遵循先进后出(LIFO)原则。它负责跟踪所有待执行的代码。每当一个函数执行完毕,该函数会从栈中弹出(pop);若有新代码需执行,则进行压入(push)操作。参考下图:

1. 异步执行原理

执行这段代码时,首先执行 main 函数,随后执行具体代码。根据先进后出原则,后执行的函数会先弹出栈。图中显示,foo 函数后执行,执行完毕后即从栈中弹出。

JavaScript 按顺序执行执行栈中的方法时,每次执行一个方法都会为其生成独立的执行环境(上下文)。方法执行完成后,当前执行环境被销毁,该方法从栈中弹出,随后继续执行下一个方法。

② 任务队列:基于数据结构中的队列结构,用于保存异步任务,遵循先进先出(FIFO)原则。它负责将新任务发送到队列中进行处理。

JavaScript 执行代码时,将同步代码按顺序排列在执行栈中并依次执行函数。遇到异步任务时,将其放入任务队列。待当前执行栈中所有同步代码执行完毕后,从异步任务队列中取出已完成的异步任务回调,放入执行栈继续执行,如此循环往复,直到所有任务执行完毕。

JavaScript 任务的执行顺序如下:

1. 异步执行原理

在事件驱动模式下,至少包含一个执行循环来检测任务队列中是否有新任务。通过不断循环,取出异步任务的回调来执行,这个过程即为事件循环(Event Loop),每一次循环称为一个事件周期。

(2)宏任务和微任务

任务队列并非只有一种,根据任务种类不同,可分为微任务(micro task)队列和宏任务(macro task)队列。常见任务如下:

  • 宏任务:script(整体代码)、setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 环境)。
  • 微任务:Promise、MutationObserver、process.nextTick(Node.js 环境)。

任务队列执行顺序如下:

1. 异步执行原理

可见,Event Loop 在处理宏任务和微任务时的逻辑执行情况如下:

  • JavaScript 引擎首先从宏任务队列中取出第一个任务;
  • 执行完毕后,再将微任务中的所有任务取出,按顺序全部执行(此处不仅指开始执行时队列里的微任务)。若在此过程中产生新的微任务,也需在当前循环中继续执行,而不会推迟到下一个循环。
  • 然后再从宏任务队列中取下一个,执行完毕后,再次将 microtask queue 中的全部取出,循环往复,直到两个队列中的任务都取完。

也就是说,一次 Event Loop 循环会处理一个宏任务和所有在该循环中产生的微任务。

下面通过一个例子来体会事件循环:

console.log('同步代码1');

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

new Promise((resolve) => {
  console.log('同步代码2')
  resolve()
}).then(() => {
    console.log('promise.then')
})

console.log('同步代码3');

代码输出结果如下:

"同步代码1" "同步代码2" "同步代码3" "promise.then" "setTimeout"

这段代码的执行过程是怎样的呢?

  • 遇到第一个 console,它是同步代码,加入执行栈,执行并出栈,打印出 "同步代码1";
  • 遇到 setTimeout,它是一个宏任务,加入宏任务队列;
  • 遇到 new Promise 中的 console,它是同步代码,加入执行栈,执行并出栈,打印出 "同步代码2";
  • 遇到 Promise then,它是一个微任务,加入微任务队列;
  • 遇到第三个 console,它是同步代码,加入执行栈,执行并出栈,打印出 "同步代码3";
  • 此时执行栈为空,去执行微任务队列中所有任务,打印出 "promise.then";
  • 执行完微任务队列中的任务,就去执行宏任务队列中的一个任务,打印出 "setTimeout"。

从上面的宏任务和微任务的工作流程中,可以得出以下结论:

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  • 微任务的执行时长会影响当前宏任务的时长。例如一个宏任务在执行过程中产生了 10 个微任务,每个微任务执行时间为 10ms,那么执行这 10 个微任务的时间就是 100ms,这也意味着这 10 个微任务让宏任务的执行时间延长了 100ms。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况,微任务都早于宏任务执行(优先级更高)。

那么问题来了,为什么要将任务队列分为微任务和宏任务,它们之间的本质区别是什么呢?

JavaScript 在遇到异步任务时,会将此任务交给其他线程来执行(例如遇到 setTimeout 任务,会交给定时器触发线程去执行,待计时结束,将定时器回调任务放入任务队列等待主线程取出执行),主线程会继续执行后面的同步任务。

对于微任务,例如 promise.then,当执行 promise.then 时,浏览器引擎不会将异步任务交给其他浏览器线程去执行,而是将任务回调存在一个队列中,当执行栈中的任务执行完之后,就去执行 promise.then 所在的微任务队列。

所以,宏任务和微任务的本质区别如下:

  • 微任务:不需要特定的异步线程去执行,没有明确的异步任务去执行,只有回调;
  • 宏任务:需要特定的异步线程去执行,有明确的异步任务去执行,有回调;

3. Node.js的事件循环

(1)事件循环的概念

对于 Node.js 的事件循环,官网描述如下:

When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.

翻译过来即:当 Node.js 启动时,它会初始化一个事件循环,处理输入的脚本。该脚本可能进行异步 API 调用、调度计时器或调用 ,随后开始处理事件循环。

JavaScript 和 Node.js 基于 V8 引擎,浏览器中包含的异步方式在 Node.js 中同样适用。此外,Node.js 中还有一些其他异步形式:

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

这些异步任务的执行依赖于 Node.js 的事件循环机制。

Node.js 中的 Event Loop 与浏览器中的完全不同。Node.js 使用 V8 作为 JS 解析引擎,而在 I/O 处理方面使用了自行设计的 libuv。libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统的底层特性,对外提供统一 API,事件循环机制也在其中实现,如下图所示:

1. 异步执行原理

根据上图,Node.js 的运行机制如下:

  • V8 引擎负责解析 JavaScript 脚本;
  • 解析后的代码,调用 Node API;
  • libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成 Event Loop(事件循环),以异步方式将任务的执行结果返回给 V8 引擎;
  • V8 引擎将结果返回给用户;

(2)事件循环的流程

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

1. 异步执行原理

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

  • 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 阶段执行回调。

这一过程的具体执行流程如下图所示:

1. 异步执行原理

(3)宏任务和微任务

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

  • 常见的宏任务:setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作等。
  • 常见的微任务:process.nextTick、new Promise().then(回调)等。

(4)process.nextTick()

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

Node.js 官方文档的解释如下:

process.nextTick()is not technically part of the event loop. Instead, thenextTickQueuewill be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.

例如下面的代码:

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

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

process.nextTick(() => {
    console.error('nextTick')
})

输出结果如下:

nextTick promise timeout

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

(5)setImmediate 和 setTimeout

前文还提到了 setImmediate 和 setTimeout,这两者很相似,主要区别在于调用时机的不同:

  • setImmediate:在 poll 阶段完成时执行,即 check 阶段;
  • setTimeout:在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行;

例如下面的代码:

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

setImmediate(() => {
  console.log('setImmediate');
});

输出结果如下:

timeout setImmediate

在上面代码的执行过程中,第一轮循环后,分别将 setTimeout 和 setImmediate 加入了各自阶段的任务队列。第二轮循环首先进入 timers 阶段,执行定时器队列回调,然后 pending callbacks 和 poll 阶段没有任务,因此进入 check 阶段执行 setImmediate 回调。所以最后输出为 timeout、setImmediate。###= 4. Node与浏览器事件循环的差异 Node.js与浏览器的事件循环的差异如下:

  • Node.js:microtask 在事件循环的各个阶段之间执行;
  • 浏览器:microtask 在事件循环的 macrotask 执行完之后执行;

1. 异步执行原理

Nodejs和浏览器的事件循环流程对比如下:

1.执行全局的 Script 代码(与浏览器无差);

2.把微任务队列清空:注意,Node 清空微任务队列的手法比较特别。在浏览器中,我们只有一个微任务队列需要接受处理;但在 Node 中,有两类微任务队列:next-tick 队列和其它队列。其中这个 next-tick 队列,专门用来收敛 process.nextTick 派发的异步任务。在清空队列时,优先清空 next-tick 队列中的任务,随后才会清空其它微任务;

3.开始执行 macro-task(宏任务)。注意,Node 执行宏任务的方式与浏览器不同:在浏览器中,我们每次出队并执行一个宏任务;而在 Node 中,我们每次会尝试清空当前阶段对应宏任务队列里的所有任务(除非达到系统限制);

4.步骤3开始,会进入 3 -> 2 -> 3 -> 2…的循环。

以上就是一文带你搞懂面试率超高的JS事件循环的详细内容,更多关于JS事件循环的资料请关注本站其它相关文章!

作者最新文章