深入解析 JS 事件循环:浏览器与 Node.js 的差异全解析

事件循环是 JavaScript 异步编程的核心机制。浏览器和 Node.js 都基于事件循环,但两者在实现原理、阶段划分、任务优先级上有显著差异。本文将从基础概念出发,深入剖析浏览器与 Node.js 事件循环的区别,并通过代码示例帮助你在面试和实际开发中游刃有余。

一、为什么需要事件循环?

JavaScript 是单线程 语言,一次只能执行一个任务。如果遇到网络请求、文件读取、定时器等耗时操作,若采用同步阻塞的方式,程序就会卡死,页面无法响应用户操作。为了解决这个问题,JavaScript 引入了异步编程模型,而事件循环正是这套模型的底层调度机制。

简单来说,事件循环就是:
主线程不断从任务队列中取出任务执行,执行过程中产生的新的异步任务会重新放入队列,如此反复。

二、浏览器中的事件循环

浏览器环境下,事件循环的核心是宏任务(MacroTask)微任务(MicroTask) 两套队列。

2.1 宏任务与微任务

类型 特点 常见 API
宏任务 由宿主环境(浏览器)发起的异步任务,通常较为 "重量级" setTimeoutsetIntervalI/OUI Renderingscript(整体代码)
微任务 由 JS 引擎发起的需要尽快执行的小任务,优先级高 Promise.then/catch/finallyMutationObserverprocess.nextTick(Node)

执行顺序

  1. 执行一个宏任务(首次执行整体 script 代码)
  2. 清空所有微任务队列
  3. 执行下一个宏任务(可能触发 UI 渲染)
  4. 重复上述过程

💡 微任务队列会在每个宏任务执行完后一次性全部清空 ,如果在微任务中继续添加微任务,它们会在同一轮清空中执行,可能导致 "无限微任务" 阻塞后续宏任务(如 setTimeout 无法按时执行)。

2.2 代码解析:经典面试题

javascript 复制代码
console.log('script start');

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

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2 end');
}

async1();

new Promise((resolve) => {
  console.log('Promise');
  resolve();
})
  .then(() => {
    console.log('promise1');
  })
  .then(() => {
    console.log('promise2');
  });

console.log('script end');

输出顺序及原理解析

  1. 执行所有同步代码(属于第一个宏任务):

    • script start
    • async1 start(async 函数体同步执行)
    • async2 end(async2 同步执行)
    • Promise(Promise 构造器同步执行)
    • script end
  2. 当前宏任务的同步代码执行完毕,开始清空微任务队列:

    • await async2() 后面的代码相当于 Promise.resolve().then(() => console.log('async1 end'))async1 end
    • 第一个 then 回调 → promise1
    • 第二个 then 回调 → promise2
  3. 微任务清空后,执行下一个宏任务:

    • setTimeout 回调 → setTimeout

最终输出

复制代码
script start
async1 start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout

注意:async/await 语法糖中,await 后面的代码会被包装成微任务。

三、Node.js 中的事件循环

Node.js 基于 libuv 库实现事件循环,其模型比浏览器复杂得多。它分为 6 个阶段 ,每个阶段都有自己的宏任务队列;微任务则会在每个阶段切换时 被清空,且 process.nextTick 拥有最高优先级。

3.1 六阶段概览

复制代码
   ┌───────────────────────────┐
┌─>│           timers          │  // 执行 setTimeout / setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  // 执行上一轮未完成的 I/O 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     idle, prepare         │  // 仅内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  // 获取新的 I/O 事件,执行相关回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  // 执行 setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  // 执行关闭回调 (socket.close 等)
│  └───────────────────────────┘

3.2 各阶段详解

1️⃣ timers(定时器阶段)

执行 setTimeoutsetInterval已经到期的回调。注意:即使延迟设为 0ms,也会在 timers 阶段检查执行,而非立即。

2️⃣ pending callbacks(待定回调阶段)

执行上一轮事件循环中被推迟到下一轮的 I/O 回调。例如某些系统操作(如 TCP 错误)的回调。

3️⃣ idle, prepare(空闲、准备阶段)

仅供 libuv 内部使用,开发者无需关注。

4️⃣ poll(轮询阶段)⭐ 核心阶段
  • 先执行队列中已到期的 I/O 回调(如文件读取完成、网络数据到达)。
  • 若队列为空:
    • 如果设置了 setImmediate,则结束 poll 阶段,进入 check 阶段。
    • 如果没有 setImmediate,则线程会阻塞在此处,等待新的 I/O 事件到来(减少 CPU 空转)。
  • 在此期间会检查 timers 队列中是否有到期的定时器,若有则立即跳回 timers 阶段执行。

为什么 poll 阶段要阻塞?

避免主线程无意义地空转消耗 CPU,提升性能。Node.js 宁愿让线程在这里等待,直到真有 I/O 事件或定时器到期,才继续工作。

5️⃣ check(检查阶段)

执行 setImmediate 的回调。setImmediate 专门用于在 poll 阶段结束后立即执行,它的优先级高于延迟为 0 的 setTimeout

6️⃣ close callbacks(关闭回调阶段)

执行关闭请求的回调,例如 socket.on('close', ...)server.close() 等。

3.3 Node.js 中的微任务

Node.js 同样存在微任务,但执行时机与浏览器不同:

  • 每个阶段执行完成后,会清空微任务队列。
  • 微任务内部还有优先级:process.nextTick 队列 > Promise.then 队列。
javascript 复制代码
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));

process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));

输出序列(典型情况)

复制代码
nextTick
Promise
setImmediate
setTimeout
  • nextTick 总是在当前阶段结束后立即 执行,甚至优先于 Promise
  • setImmediatesetTimeout(0) 的执行顺序在 poll 阶段为空时不确定(受性能影响),但若在 I/O 回调内部,setImmediate 永远先于 setTimeout

四、浏览器 vs Node.js:核心差异对比

维度 浏览器 Node.js
实现基础 Web API(HTML 标准) libuv 库
事件循环模型 宏任务 ↔ 微任务循环 6 个阶段 + 每个阶段后清空微任务
宏任务队列 单一宏任务队列(按时间排序) 每个阶段独立的队列(timers、poll、check 等)
微任务优先级 统一队列,无额外分层 process.nextTick 优先级 > Promise
微任务清空时机 每个宏任务执行结束后 每个阶段结束后(即阶段切换时)
I/O 调度 依赖浏览器实现 基于 epoll/kqueue 等高效异步 I/O
典型 API setTimeout, Promise, MessageChannel setTimeout, setImmediate, process.nextTick
UI 渲染时机 微任务执行后,下一次宏任务之前 无 UI 渲染概念

五、延伸思考:poll 阶段的阻塞机制(面试高频)

问题 :Node.js 事件循环中,poll 阶段为什么要阻塞?
答案

  • 当 poll 队列为空且没有 setImmediate 时,线程会阻塞等待新的 I/O 事件。
  • 这样做可以避免 CPU 空转(否则会疯狂空转检查 timer 是否到期),极大提高 CPU 利用率和性能。
  • 阻塞期间如果设定的 timer 到了,事件循环会立即跳出 poll 阶段,回到 timers 阶段执行回调。

六、总结

  • 浏览器 的事件循环更简单:一个宏任务 + 全部微任务 → 下一个宏任务。适合处理 UI 交互和渲染。
  • Node.js 的事件循环更精细:6 个阶段 + 多优先级微任务,专为高并发 I/O 场景优化。
相关推荐
fanged1 小时前
高通平台IMU的Bringup(TODO)
笔记
HYCS1 小时前
用pixijs实现fabricjs(二):对象的基础位置信息
前端·javascript·canvas
Alice-YUE1 小时前
【无标题】
开发语言·javascript·ecmascript
淸湫1 小时前
项目中使用了全局权限管理,请详细描述如何通过Vue Router的路由守卫来实现全局权限控制?
前端·vue.js
Twsit丶1 小时前
ECMAScript 常用特性整理(ES6 — ES13)
javascript
雪铃儿1 小时前
Shorebird 之外,Flutter Android 热更新还有什么选择
android·前端
李剑一1 小时前
前端必看 | Vue 刷新页面,生命周期钩子直接 "罢工",原来问题在这?90% 开发者都栽过!
前端·vue.js
minglie11 小时前
UG585Address Map
学习
远离UE41 小时前
Vulkan学习笔记
笔记·学习