JavaScript 事件循环机制详解
JavaScript 事件循环机制详解
一、Introduction
JavaScript 从诞生起就是一门单线程语言——同一时刻只能执行一段代码。这并非语言的设计缺陷,而是有意为之:浏览器环境中,如果多个脚本同时操作 DOM,后果将不堪设想。然而,单线程并不意味着浏览器只能傻等——事件循环(Event Loop)机制让 JavaScript 能够在等待(I/O、定时器、网络请求)期间处理其他任务,从而实现看似并发的用户体验。本文将彻底拆解事件循环的每一个组成部分:调用栈(Call Stack)、任务队列(Task Queue / Macrotask Queue)、微任务队列(Microtask Queue)、Node.js 的 libuv 事件循环,以及 requestAnimationFrame 的特殊调度逻辑。
二、基础概念
1. 调用栈(Call Stack)
调用栈是 JavaScript 引擎维护的执行上下文栈。每次调用一个函数,引擎就会创建一个帧(Frame)推入栈顶;函数返回时,帧从栈顶弹出。栈的特点是后进先出(LIFO),这意味着嵌套调用的函数会依次叠加在栈上,形成完整的执行链路。
function a() { b(); console.log('a'); }
function b() { c(); console.log('b'); }
function c() { console.log('c'); }
a();
// 调用顺序: a → b → c → c打印 → b打印 → a打印
// 栈的变化: [global] → [a] → [a,b] → [a,b,c] → [a,b] → [a] → []
// 输出: c → b → a
2. 任务队列(Macrotask Queue)
任务队列也称为宏任务队列,用于存放需要异步执行的"任务"。每个任务本质上是一个函数回调。每轮事件循环,引擎从队列中取出一个任务执行完毕后,才会进入下一轮循环。
常见的宏任务(Macrotask)来源:
- setTimeout / setInterval 的回调
- I/O 操作完成后的回调(readFile、fetch、XMLHttpRequest)
- setImmediate(Node.js 独有)
- UI 渲染(浏览器环境,非每次循环都会渲染)
- requestAnimationFrame(浏览器环境,归属稍特殊)
- Promise.then / catch / finally 的回调
- async/await(实质是 Promise + 生成器语法糖)
- queueMicrotask() 手动入队
- MutationObserver 回调(浏览器)
- process.nextTick(Node.js 独有,优先级高于普通微任务)
3. 微任务队列(Microtask Queue)
微任务队列是 ES6 引入的概念(Promise 规范),专门存放微任务(Microtask)。与宏任务不同,每次宏任务执行完毕后,引擎会倾空(drain)整个微任务队列——所有微任务会在下一个宏任务开始前全部执行完毕。
常见的微任务来源:
4. Node.js libuv 事件循环阶段(Phase)
Node.js 的事件循环比浏览器更复杂,libuv 将循环划分为多个阶段(Phase),每个阶段处理不同的任务源:
┌───────────────────────────┐
│ timers (setTimeout/setInterval callbacks) │
│ pending callbacks ── I/O callbacks (errors, etc.) │
│ idle, prepare ── 内部使用 │
│ poll ── 检索新的 I/O 事件;执行几乎所有 I/O 回调 │
│ check ── setImmediate 回调 │
│ close callbacks ── socket.on('close') 等 │
└───────────────────────────────────────────────────────┘
│ 每阶段之间:微任务队列 + process.nextTick │
三、代码示例
1. 基础事件循环演示
console.log('1 - script start');
setTimeout(() => console.log('2 - setTimeout'), 0);
Promise.resolve()
.then(() => console.log('3 - Promise.then 1'))
.then(() => console.log('4 - Promise.then 2'));
console.log('5 - script end');
// 经典浏览器输出顺序(Chrome/Firefox):
// 1 - script start
// 5 - script end
// 3 - Promise.then 1 ← 微任务
// 4 - Promise.then 2 ← 微任务
// 2 - setTimeout ← 宏任务(尽管延迟为 0)
2. async/await 与微任务
async function main() {
console.log('A - before await');
await Promise.resolve();
console.log('B - after await'); // 相当于在 Promise.then 里执行
}
console.log('C - before call');
main();
console.log('D - after call');
// 输出顺序:
// C - before call
// A - before await
// D - after call
// B - after await ← 微任务执行时机
// 微任务执行时机:在一个 Promise resolve 之后,.then() 的回调会进入微任务队列,
// 但不是立即执行,而是等待当前执行栈清空后统一执行。
3. 嵌套 setTimeout 与任务累积
setTimeout(() => {
console.log('macro 1');
Promise.resolve().then(() => console.log('micro during macro 1'));
}, 0);
setTimeout(() => {
console.log('macro 2');
}, 0);
Promise.resolve().then(() => console.log('micro before both macros'));
// 执行流程:
// 1. 两个 setTimeout 注册 → 两个宏任务进入队列
// 2. Promise.then 注册 → 微任务进入队列
// 3. 执行栈为空 → 清空微任务队列 → 输出 "micro before both macros"
// 4. 事件循环取第一个 setTimeout → 输出 "macro 1" → 其中的 Promise.then 进入微队列
// 5. 第一个 setTimeout 结束 → 清空微任务队列 → 输出 "micro during macro 1"
// 6. 事件循环取第二个 setTimeout → 输出 "macro 2"
4. 浏览器渲染时机
// 浏览器中,渲染发生在每轮事件循环的「开始」,
// 但不是每轮都会渲染——只有当达到合适的帧时间(~16.67ms)时才会实际渲染。
document.body.style.background = 'red';
// 注意:同步 JS 修改样式,渲染可能在下一个宏任务开始前不会发生
// 浏览器为了优化,可能将多个 DOM 变更合并到一次渲染中
// 如果需要强制同步渲染后执行某操作:
requestAnimationFrame(() => {
console.log('rendered!');
// 这里的代码在浏览器真正渲染之后执行
});
5. Node.js process.nextTick vs setImmediate
// Node.js 环境
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('microtask'));
setImmediate(() => console.log('setImmediate'));
// 输出:
// nextTick ← nextTick 优先级最高,在当前操作完成后立即执行
// microtask ← 普通微任务
// setImmediate ← 下一个事件循环 check 阶段才执行
// 注意:I/O 回调中两者顺序可能互换
四、运行效果
在 Node.js 环境(v18+)运行上述代码,典型输出:
# 示例 1(基础事件循环)
1 - script start
5 - script end
3 - Promise.then 1
4 - Promise.then 2
2 - setTimeout
示例 2(async/await)
C - before call
A - before await
D - after call
B - after await
示例 3(嵌套 setTimeout)
micro before both macros
macro 1
micro during macro 1
macro 2
示例 5(Node.js nextTick)
nextTick
microtask
setImmediate
在浏览器中,可以使用 Performance API 观察每个阶段的精确时序,或在 DevTools 的 Performance 面板中录制并放大查看 Call Tree 和 Event Log。
五、常见问题
Q1: setTimeout(fn, 0) 是否保证 fn 立即执行?
否。setTimeout(fn, 0) 只是告诉引擎"在最早可执行的下一个事件循环中执行 fn"。实际上,fn 会进入宏任务队列,需要等当前执行栈清空 + 微任务队列清空后,才会轮到它。如果此时有其他宏任务在队列前面,fn 仍需等待。
Q2: Promise 和 setTimeout 谁先执行?
Promise.then 的回调(微任务)总是在 setTimeout 的回调(宏任务)之前执行,即使 setTimeout 延迟为 0。因为微任务队列在每个宏任务结束后都会被倾空。参见上方代码示例 1。
Q3: async/await 是否会阻塞主线程?
async 函数本身的调用是同步的,不会阻塞。await 只在等待一个未决议的 Promise 时才会"暂停"当前 async 函数的执行,将后续代码注册为微任务,让出主线程。这使得 async/await 是非阻塞的异步语法。
Q4: 为什么在 for 循环中使用 setTimeout 时所有回调都打印相同的值?
// 经典闭包问题
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出: 3 3 3 —— 循环结束后 i=3,所有回调共享同一个 i
// 解决方案 1:使用 let(块级作用域)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出: 0 1 2 —— let 在每次迭代创建新绑定
// 解决方案 2:IIFE 捕获
for (var i = 0; i < 3; i++) {
((j) => setTimeout(() => console.log(j), 0))(i);
}
Q5: Node.js 和浏览器的事件循环有何区别?
主要区别在于 Node.js 使用 libuv 库实现了多阶段(Phase)的事件循环,支持文件 I/O、DNS 查询、子进程等,且有 process.nextTick 和 setImmediate 这些浏览器没有的 API。浏览器环境的事件循环更简单,主要围绕渲染和 UI 事件展开。两者在微任务(Promise)的处理上基本一致,但 Node.js 中 process.nextTick 优先级高于普通微任务,而浏览器没有这个概念。
Q6: requestAnimationFrame 在事件循环的哪个位置?
在浏览器中,requestAnimationFrame 回调在每轮事件循环结束后、渲染之前执行(如果需要渲染的话)。它不属于微任务也不属于普通宏任务,而是浏览器的特殊调度信号。如果屏幕不需要刷新(没有 DOM 变更),回调也可能根本不执行。Node.js 从 v14 开始支持 performance.nodeTiming 等 Performance API,但 requestAnimationFrame 并非 Node.js 标准 API。
六、延伸阅读
- Loupe - 事件循环可视化工具(Philip Roberts 制作):http://latentflip.com/loupe —— 实时可视化调用栈、回调队列和事件循环的交互工具,是理解事件循环的最佳入门。
- MDN - Event Loop:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop —— 浏览器事件循环的官方说明,配有队列示意图。
- Node.js 官方文档 - The Node.js Event Loop:https://nodejs.org/en/guides/event-loop-timers-and-nexttick —— 详解 libuv 的六个阶段、timers、check 和 nextTick。
- Jake Archibald - Tasks, microtasks, queues and schedules:https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/ —— 事件循环最经典的技术博客,以图文并茂的方式深入讲解宏任务与微任务的执行时机。
- 《How JavaScript Engines Run Code》(V8 官方博客):https://v8.dev/blog —— 了解 V8 引擎如何实现调用栈、编译器优化和事件循环底层。