事件循环

深入理解 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 事件回调(如 clickload
  • requestAnimationFrame(浏览器)
  • UI 渲染(浏览器)
  • 执行规则:每次事件循环迭代处理一个宏任务。

2.2.2 微任务队列(MicroTask Queue)

  • 包含任务
  • Promise.then() / Promise.catch() / Promise.finally()
  • MutationObserver(浏览器)
  • queueMicrotask()
  • process.nextTick()(Node.js,优先级高于微任务)
  • 执行规则
    每个宏任务执行后,清空微任务队列中的所有任务。

2.3 事件循环流程

以下是事件循环的简化流程(以浏览器为例):

  1. 执行全局同步代码,初始化调用栈。
  2. 处理微任务队列
  • 执行所有微任务,直到队列为空。
  1. 执行渲染(如需要)
  • 浏览器会进行布局(Layout)、绘制(Paint)。
  1. 从宏任务队列中取出一个任务执行,回到步骤 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 vs setTimeout
    setImmediate 在 Check 阶段执行,setTimeout 在 Timers 阶段执行。

5. 常见问题与优化

5.1 长任务(Long Tasks)

  • 问题:执行时间超过 50ms 的任务会阻塞渲染,导致页面卡顿。
  • 解决方案
  • 拆分任务:使用 setTimeoutrequestIdleCallback 分片执行。
  • 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 的事件循环机制!