事件循环
深入理解 JavaScript 事件循环与任务队列
1. 什么是事件循环?
JavaScript 是单线程语言,但通过 事件循环(Event Loop) 实现了非阻塞的异步行为。事件循环的核心职责是协调调用栈(Call Stack)、任务队列(Task Queues)和宿主环境(如浏览器或 Node.js)之间的交互,确保代码高效执行。
2. 核心概念与运行机制
2.1 调用栈(Call Stack)
- 定义:一个后进先出(LIFO)的数据结构,用于追踪当前执行的函数调用。
- 行为:
- 执行函数时,将其推入栈顶。
- 函数返回时,将其弹出栈。
- 示例:
function a() { b(); }
function b() { c(); }
function c() { console.log('Done'); }
a();
调用栈顺序:a() → b() → c() → console.log()
。
2.2 任务队列(Task Queues)
JavaScript 通过 任务队列 管理异步操作的回调。任务队列分为两类:
2.2.1 宏任务队列(MacroTask Queue)
- 包含任务:
setTimeout
/setInterval
- I/O 操作(如文件读写、网络请求)
- DOM 事件回调(如
click
、load
) requestAnimationFrame
(浏览器)- UI 渲染(浏览器)
- 执行规则:每次事件循环迭代处理一个宏任务。
2.2.2 微任务队列(MicroTask Queue)
- 包含任务:
Promise.then()
/Promise.catch()
/Promise.finally()
MutationObserver
(浏览器)queueMicrotask()
process.nextTick()
(Node.js,优先级高于微任务)- 执行规则:
每个宏任务执行后,清空微任务队列中的所有任务。
2.3 事件循环流程
以下是事件循环的简化流程(以浏览器为例):
- 执行全局同步代码,初始化调用栈。
- 处理微任务队列:
- 执行所有微任务,直到队列为空。
- 执行渲染(如需要):
- 浏览器会进行布局(Layout)、绘制(Paint)。
- 从宏任务队列中取出一个任务执行,回到步骤 2。
3. 代码执行顺序示例
console.log('Start');
setTimeout(() => console.log('Timeout'), 0);
Promise.resolve()
.then(() => console.log('Promise 1'))
.then(() => console.log('Promise 2'));
console.log('End');
输出顺序:
Start → End → Promise 1 → Promise 2 → Timeout
解释:
- 同步代码先执行(
Start
,End
)。 - 微任务(Promise)优先于宏任务(
setTimeout
)。
4. 浏览器与 Node.js 的事件循环差异
4.1 浏览器环境
- 宏任务队列:由宿主环境(浏览器)管理,与渲染流程耦合。
- 微任务执行时机:在每一个宏任务之后、渲染之前执行。
4.2 Node.js 环境
Node.js 使用 libuv 实现事件循环,分为多个阶段:
┌───────────────────────┐
│ Timers │ // setTimeout/setInterval
├───────────────────────┤
│ Pending I/O Callbacks │ // 上一轮未执行的 I/O 回调
├───────────────────────┤
│ Idle, Prepare │ // 内部使用
├───────────────────────┤
│ Poll Phase │ // 检索新的 I/O 事件
├───────────────────────┤
│ Check Phase │ // setImmediate
├───────────────────────┤
│ Close Callbacks │ // 关闭事件(如 socket.on('close'))
└───────────────────────┘
process.nextTick()
:独立于事件循环阶段,优先级最高。setImmediate
vssetTimeout
:setImmediate
在 Check 阶段执行,setTimeout
在 Timers 阶段执行。
5. 常见问题与优化
5.1 长任务(Long Tasks)
- 问题:执行时间超过 50ms 的任务会阻塞渲染,导致页面卡顿。
- 解决方案:
- 拆分任务:使用
setTimeout
或requestIdleCallback
分片执行。 - Web Workers:将计算密集型任务移至子线程。
5.2 微任务滥用
- 问题:微任务队列在单次循环中会全部执行,若递归添加微任务会导致死循环。
- 示例:
function loop() {
Promise.resolve().then(loop);
}
loop(); // 页面卡死!
5.3 优先级控制
- 策略:
- 紧急任务用微任务(如
Promise
)。 - 非紧急任务用宏任务(如
setTimeout
)。
6. 面试常见问题
setTimeout(fn, 0)
是否真的 0ms 后执行?
- 否,最短延迟为 4ms(HTML5 规范),且受事件循环状态影响。
process.nextTick()
与 Promise
的执行顺序?
- Node.js 中:
process.nextTick
优先级高于微任务。
如何保证某函数在渲染后执行?
- 使用
requestAnimationFrame
(浏览器)或setTimeout
。
7. 总结
- 事件循环本质:单线程下通过任务队列实现异步非阻塞。
- 核心规则:
- 同步代码优先执行。
- 微任务在渲染前清空。
- 宏任务在每次循环中处理一个。
- 最佳实践:避免长任务、合理选择任务类型、善用 Web Workers。
参考资料:
希望这篇文档能帮助你深入理解 JavaScript 的事件循环机制!