一次弄懂 Event Loop(彻底解决此类面试问题)
Event Loop(事件循环)是浏览器或 Node.js 宿主协调 JavaScript 代码、异步操作、事件和定时器的一套机制。它让一个 JavaScript agent 可以在等待 I/O 时继续处理其他工作,但它并不意味着 JavaScript 回调会并行执行,也不意味着浏览器和 Node.js 使用完全相同的循环。
本文保留原文“堆、栈、队列 → 浏览器 → Node.js → 面试题”的结构,并修正旧文章中把计算机数据结构 heap 当成 JavaScript 内存堆、把所有异步回调放入一个任务队列以及把 Node 版本行为写死的问题。原文配图来自 Juejin/ByteDance CDN,已下载到同目录 images 文件夹;旧图中的浏览器和 Node 流程仅作历史示意。
一、为什么要弄懂 Event Loop
- 了解同步代码、Promise、定时器和 I/O 的先后关系;
- 解释页面为什么会被长任务阻塞;
- 区分浏览器和 Node.js 的运行时;
- 面对事件循环面试题时先明确环境和规范,而不是死记一份输出。
二、堆、栈、队列
1. 数据结构中的堆
计算机科学中的 heap 是一种通常用数组表示的完全二叉树结构,可以实现最小堆或最大堆。它常用于优先队列和调度算法,与“JavaScript heap memory”只是同名,不是同一种概念。

2. 数据结构中的栈
栈按照后进先出(LIFO)工作,只能在栈顶附近进行压入和弹出。JavaScript 的调用栈借用了这一抽象来记录当前执行上下文:
function first() {
second()
}
function second() {
console.log('second')
}
first()
调用 second 时,它的执行上下文位于栈顶;返回后才回到 first。

3. 数据结构中的队列
队列通常按照先进先出(FIFO)处理元素。浏览器 HTML Standard 实际上允许多个 task queue,microtask queue 也不是 task queue;因此“一个宏任务队列 + 一个微任务队列”的说法只是便于入门的模型。

4. JavaScript 的 heap、stack 和 queue
ECMAScript 规范描述值、对象、执行上下文和 job,并不规定 primitive 一定在栈、对象一定在堆。可以这样理解:
- 调用栈记录当前执行上下文;
- 对象具有身份,多个变量可能共享同一个对象;
- 引擎使用 heap 等内存区域管理对象,但具体布局属于实现;
- 事件循环从宿主提供的任务和 job 中选择后续工作;
- 闭包可能使词法绑定在创建它的函数返回后继续存活。

三、Event Loop 中的 Task 和 Microtask
1. Task(宏任务的常用叫法)
浏览器中常见 task 来源包括:
- 经典脚本、模块脚本的执行;
setTimeout、setInterval的计时器回调;- 用户输入、消息和部分网络事件。
setImmediate 是 Node.js API,不是浏览器通用 API;Object.observe 已废弃并从现代 JavaScript 中移除,不能继续列为当前微任务来源。
2. Microtask
Promise reaction、queueMicrotask 和浏览器 MutationObserver 回调通常在 microtask checkpoint 中执行。Promise 对象本身不是微任务,注册在 Promise 上的回调才会被调度。
在浏览器中,一次简化的循环可以表示为:
- 选择并执行一个 task,运行至完成;
- 执行 microtask checkpoint,清空当前微任务队列;
- 在合适时机更新渲染;
- 继续选择下一个 task。
微任务中产生的新微任务会加入当前队列并继续执行,因此无界微任务链可能阻塞渲染和用户输入。
四、浏览器中的基础题
console.log('script start')
setTimeout(() => {
console.log('setTimeout')
}, 0)
Promise.resolve()
.then(() => {
console.log('promise1')
})
.then(() => {
console.log('promise2')
})
console.log('script end')
输出顺序:
script start
script end
promise1
promise2
setTimeout
解释:整个经典脚本通常是一个 task。同步日志先执行;脚本结束后进入 microtask checkpoint,先执行第一个 Promise reaction;第一个 reaction 完成后,第二个 then 的 reaction 才加入队列并继续执行;微任务清空后才轮到 timer task。


五、async/await 的执行顺序
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(() => {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(() => {
console.log('promise1')
})
.then(() => {
console.log('promise2')
})
console.log('script end')
在当前主流浏览器和 Node.js 中,通常输出:
script start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout
async2() 的函数体会同步运行到返回 Promise 的位置,因此 async2 end 先打印;await 之后的 async1 end 会异步恢复。调用 async1() 后,后续的 Promise 链也会排队,具体 reaction 的先后由它们注册的时间和规范算法决定。
关于 Chrome 73 和旧版 V8
原文重点介绍了 Chrome 73 前后的 await 优化:旧版 V8 为 await 生成更多中间 Promise 和 microtask,V8 后来依据 TC39 的规范调整减少了不必要的 tick。这个历史现象可以保留,但不能把“三个 tick”或 Chrome 73 的实现细节当作现在所有浏览器的语言规则。今天应以 ECMAScript 的异步函数和 Promise job 语义为准,并在目标浏览器或 Node.js 版本中运行最小示例验证。

六、Node.js 的 Event Loop
Node.js 的 Event Loop 基于 libuv。经典的阶段图如下:
┌─────────────┐
│ timers │ setTimeout / setInterval
└──────┬──────┘
↓
┌─────────────┐
│ pending │ 部分延后的 I/O 回调
│ callbacks │
└──────┬──────┘
↓
┌─────────────┐
│ idle,prepare│ 内部使用
└──────┬──────┘
↓
┌─────────────┐
│ poll │ I/O 事件与等待
└──────┬──────┘
↓
┌─────────────┐
│ check │ setImmediate
└──────┬──────┘
↓
┌─────────────┐
│ close │ close callbacks
│ callbacks │
└─────────────┘
Node.js 20 使用的 libuv 1.45.0 改变了 timers 与 poll 的相对调度:timers 在每轮中主要位于 poll 之后,旧版本在 poll 前后都有差异。阶段图是总体模型,不是保证每个平台和版本都按同一条直线执行。

setImmediate 与 setTimeout
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
从 Node.js 主模块直接安排时,顺序可能受启动时机和系统调度影响;不要写死输出。放在 I/O 回调中时通常是 setImmediate 先执行:
const fs = require('node:fs')
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
})
原因是 I/O 回调位于 poll 相关流程,setImmediate 会进入 check 阶段,而零延迟 timer 仍需等待 timers 阶段。0 也只是最小阈值,不是立即执行。

process.nextTick()
process.nextTick() 有自己的 nextTick 队列,不属于上面的六个 libuv 阶段。当前操作完成、调用栈展开后,Node 会先处理它;递归的 nextTick 可能饿死 I/O。当前 Node 文档已将它标为 Legacy;多数跨环境的微任务场景优先使用 queueMicrotask(),只有需要 Node 特有调度顺序时才使用 process.nextTick()。
let value
setTimeout(() => console.log('setTimeout'), 0)
setImmediate(() => console.log('setImmediate'))
process.nextTick(() => {
console.log('nextTick:', value)
})
value = 1
这里 nextTick: 1 会先于 timer 和 immediate。若只需要跨浏览器和 Node 的 Promise 级微任务,使用 queueMicrotask 更通用;process.nextTick 只在需要 Node 特定顺序时使用。
Node 版本还可能影响 Promise microtask 在不同回调之间的观察位置。Node 11 以后对每次回调后的 microtask 处理更接近浏览器,但不能把 Node 10 的历史输出当作今天的通用规则。



七、综合练习:Promise、timer 和 nextTick
下面按 Node.js CommonJS 主模块运行;浏览器中没有 process.nextTick,Node.js ESM 顶层评估也可能改变初始 nextTick 与 Promise reaction 的相对顺序。
console.log('1')
setTimeout(() => {
console.log('2')
process.nextTick(() => console.log('3'))
new Promise(resolve => {
console.log('4')
resolve()
}).then(() => console.log('5'))
}, 0)
process.nextTick(() => console.log('6'))
new Promise(resolve => {
console.log('7')
resolve()
}).then(() => console.log('8'))
setTimeout(() => {
console.log('9')
process.nextTick(() => console.log('10'))
new Promise(resolve => {
console.log('11')
resolve()
}).then(() => console.log('12'))
}, 0)
当前 Node.js 中通常输出:
1
7
6
8
2
4
3
5
9
11
10
12
分析方法:
1、7属于当前脚本的同步代码;- 脚本结束后先处理 nextTick
6,再处理 Promise reaction8; - 第一个 timer 中先同步输出
2、4,然后 nextTick3先于 Promise reaction5; - 第二个 timer 同理输出
9、11、10、12。
如果将 process.nextTick 放到浏览器代码中,代码会直接报错;如果要比较浏览器输出,应换成 queueMicrotask 或 Promise,并明确这是另一套宿主调度。

八、面试题的通用分析步骤
- 先写出当前 task 中的同步输出;
- 记录每个定时器、I/O、事件和 Promise reaction 的注册位置;
- 区分浏览器 microtask、Node nextTick 和 Promise job;
- 一个 task 或 callback 执行期间不能被同一 agent 的其他 JavaScript 回调抢占;
- 当前 task 完成后清空 microtask,微任务中新加入的微任务也要继续处理;
- 检查 timer 的最小阈值和 I/O 上下文,不把
0ms当成立即执行; - 最后再讨论渲染、poll、check 和不同 Node 版本的实现差异;
- 对复杂题目在目标运行时执行最小脚本,避免只依赖旧文章的图片或结论。
九、总结
- Event Loop 是宿主环境协调任务和异步完成事件的机制,不是 JavaScript 语言本身唯一的一张流程图;
- 同一个 JavaScript agent 的回调通常串行、运行至完成,但浏览器和 Node 可以使用其他线程、进程或 worker 并行处理工作;
- 浏览器通常在 task 后执行 microtask checkpoint,并可能在合适时机更新渲染;
- Node.js 还有 libuv 阶段、
setImmediate和独立的process.nextTick队列; setTimeout的参数表示最小阈值,setInterval不是精确时钟;async/await会产生异步恢复,不应按旧版 V8 的固定 tick 数量解释;- 确认运行环境、版本和代码上下文,比背“宏任务优先/微任务优先”更重要。