JavaScript 事件循环机制详解

105 阅读4分钟

JavaScript 事件循环机制详解

JavaScript 的事件循环(Event Loop)是其并发模型的核心,它解决了单线程语言执行异步操作的难题。理解它对于掌握异步编程、性能优化至关重要。


一、核心基础:单线程与异步

1. 为什么需要事件循环?

JavaScript 是单线程语言,同一时间只能执行一个任务。为了不阻塞 UI 渲染和用户交互,异步操作(如网络请求、定时器)被设计为非阻塞的——异步任务的执行交给浏览器其他线程处理,回调由事件循环调度执行。

2. 关键组件

┌─────────────────────────────────────┐
│           JavaScript 运行时          │
├─────────────────────────────────────┤
│  ┌──────────────┐   ┌────────────┐ │
│  │   调用栈      │   │   Web APIs │ │
│  │ (Call Stack) │   │ (浏览器提供)│ │
│  └──────────────┘   └────────────┘ │
│         ↑              │            │
│         │              ↓            │
│  ┌──────────────┐  ┌────────────┐ │
│  │  任务队列     │  │ Event Loop │ │
│  │ (Task Queue) │  │   调度器    │ │
│  └──────────────┘  └────────────┘ │
└─────────────────────────────────────┘
  • 调用栈:后进先出(LIFO),存放同步执行的函数
  • Web APIs:浏览器提供的异步 API(DOM、Ajax、setTimeout 等)
  • 任务队列:先进先出(FIFO),存放异步回调
  • 事件循环:持续检查调用栈是否为空,若空则从任务队列取任务执行

二、事件循环工作流程

基本规则

  1. 执行所有同步代码,压入调用栈
  2. 遇到异步任务,交给 Web APIs 处理,主线程继续向下执行
  3. 异步任务完成后,其回调函数进入任务队列
  4. 调用栈清空后,事件循环从任务队列取出第一个任务执行
  5. 重复步骤 4
console.log('1'); // 同步,立即执行

setTimeout(() => { // 异步,交给定时器线程
  console.log('2');
}, 0);

console.log('3'); // 同步,立即执行

// 输出顺序:1 → 3 → 2

三、核心进阶:宏任务 vs 微任务

现代事件循环引入优先级队列,任务分为两类:

类型常见来源优先级
宏任务 (Macrotask)setTimeout, setInterval, I/O, UI 渲染, setImmediate (Node.js)
微任务 (Microtask)Promise.then/catch/finally, MutationObserver, process.nextTick (Node.js)

执行规则(关键!)

  1. 执行一个宏任务(从调用栈或宏任务队列)
  2. 执行所有微任务队列中的任务(直到微队列为空)
  3. 执行 UI 渲染(如果有需要)
  4. 进入下一轮,执行下一个宏任务
  5. 重复上述循环
console.log('script start'); // 1. 同步

setTimeout(() => { // 5. 宏任务
  console.log('setTimeout');
}, 0);

Promise.resolve().then(() => { // 3. 微任务
  console.log('promise1');
}).then(() => { // 4. 微任务(链式)
  console.log('promise2');
});

console.log('script end'); // 2. 同步

// 输出顺序:
// script start → script end → promise1 → promise2 → setTimeout

四、经典面试题解析

题目 1:综合异步场景

console.log('1'); // 同步

setTimeout(() => { // 宏任务1
  console.log('2');
  Promise.resolve().then(() => { // 宏1中的微任务
    console.log('3');
  });
}, 0);

Promise.resolve().then(() => { // 微任务1
  console.log('4');
  setTimeout(() => { // 微1中的宏任务(排到后面)
    console.log('5');
  }, 0);
}).then(() => { // 微任务2
  console.log('6');
});

setTimeout(() => { // 宏任务2
  console.log('7');
}, 0);

console.log('8'); // 同步

// 执行顺序分析:
// 1. 执行同步代码:1 → 8
// 2. 微任务队列:4 → 6 (微任务2在微1完成后添加)
// 3. 宏任务队列:宏1 → 宏2
//    - 执行宏1:2 → (产生微任务3) → 3
//    - 执行宏2:7
// 4. 下一个事件循环:检查是否有新宏任务(微1中setTimeout产生的)
//    - 执行宏3:5

// 最终输出:1 → 8 → 4 → 6 → 2 → 3 → 7 → 5

题目 2:async/await 本质

async function async1() {
  console.log('a');
  await async2(); // await 后面的代码相当于 Promise.then(微任务)
  console.log('b');
}

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

console.log('d');

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

async1();

new Promise(resolve => {
  console.log('f');
  resolve();
}).then(() => {
  console.log('g');
});

console.log('h');

// 执行顺序:
// d → a → c → f → h → b → g → e

关键点await 会暂停函数执行,但 await 之前的代码是同步的,之后的代码注册为微任务。


五、Node.js 与浏览器差异

浏览器 Event Loop

  • 宏任务队列:多个(定时器队列、I/O 队列等),按优先级取
  • 微任务队列:统一,全部清空后才继续

Node.js Event Loop(更复杂)

Node.js 将事件循环分为 6 个阶段

// 阶段顺序
┌───────────────────────────┐
│   timers (setTimeout)     │
│  pending callbacks (I/O)  │
│      idle, prepare       │
│          poll (核心)       │
│          check (setImmediate) │
│      close callbacks      │
└───────────────────────────┘

关键区别

  • process.nextTick 不属于任何阶段,优先级高于所有微任务
  • setImmediatepoll 阶段后执行,与 setTimeout 顺序不确定(取决于时机)
// Node.js 中
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 输出顺序不确定!取决于启动耗时

// 但如果在 I/O 回调内:
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
  // 一定输出:immediate → timeout
});

六、最佳实践与性能优化

✅ 应该做的

  1. 优先使用微任务处理高优先级逻辑(如数据状态更新)
  2. 拆分长任务:用 setTimeout(fn, 0) 将耗时任务拆分到多个宏任务,避免阻塞 UI
  3. 合理使用 async/await:代码更清晰,错误处理更方便

❌ 避免做的

  1. 微任务嵌套死循环:不断创建微任务会阻塞宏任务和 UI 渲染
// 危险!会卡死页面
function loop() {
  Promise.resolve().then(loop);
}
loop();
  1. 滥用 setTimeout 模拟异步:优先使用 Promise

  2. 忽略错误处理:Promise 和 async 都需要 .catch()try/catch


七、总结

事件循环的本质是任务调度器,通过单线程 + 任务队列实现非阻塞异步:

  • 宏任务:主流程,每轮执行一个
  • 微任务:高优先级,每轮全部清空
  • async/await:基于 Promise 的语法糖
  • 优化关键:合理拆分任务,避免长阻塞

理解事件循环能让你写出更高效、可预测的异步代码,也是攻克高级面试题的必备知识。