JavaScript 事件循环深度解析

Created on

JavaScript 是单线程语言,但它能处理异步操作而不阻塞主线程——这一切都依赖于**事件循环(Event Loop)**机制。理解事件循环不仅能帮你预测代码的执行顺序,还能写出更高效的异步代码。

在开始前,先来做一道测试题:

热身 Quiz

请预测以下代码的输出顺序:

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

console.log("4");

答案是:1 → 4 → 3 → 2

为什么 32 前面?即使 setTimeout 的延迟是 0?这就是事件循环的核心问题。

核心概念

调用栈(Call Stack)

调用栈是 JavaScript 执行同步代码的地方。代码按照**后进先出(LIFO)**的顺序执行。

function a() {
  b();
  console.log("a");
}
function b() {
  console.log("b");
}
a();
// 输出:b → a
// 调用栈变化:[a] → [a, b] → [a](b 执行完出栈)→ [](a 执行完出栈)

任务队列的两种类型

JavaScript 的异步任务分为两大类,优先级不同:

微任务(Microtask)——优先级高,当前任务结束后立刻执行,清空队列才进行下一轮:

宏任务(Macrotask / Task)——优先级低,每次事件循环只取一个执行:

事件循环的执行流程

┌──────────────────────────────────────┐
│            Call Stack(调用栈)        │
│  执行同步代码,遇到异步任务交给 Web API  │
└──────────────┬───────────────────────┘
               │ 同步代码执行完毕

┌──────────────────────────────────────┐
│         Microtask Queue(微任务队列)   │
│  清空所有微任务(包括新产生的微任务)    │
└──────────────┬───────────────────────┘
               │ 微任务队列清空

┌──────────────────────────────────────┐
│         Macrotask Queue(宏任务队列)   │
│  取出一个宏任务执行                    │
└──────────────┬───────────────────────┘
               │ 宏任务执行完毕,再次检查微任务
               └──────────────────── (循环)

一句话记住核心规则:每个宏任务执行完后,都要清空所有微任务,然后才执行下一个宏任务。

逐步分析 Quiz

回到开头的例子,逐步拆解:

console.log("1"); // 同步,立刻入栈执行 → 输出 1
setTimeout(() => console.log("2"), 0); // 异步,注册宏任务
Promise.resolve().then(() => console.log("3")); // 异步,注册微任务
console.log("4"); // 同步,立刻入栈执行 → 输出 4
// 此时调用栈清空
// 检查微任务队列:有 console.log("3") → 输出 3,微任务队列清空
// 检查宏任务队列:有 console.log("2") → 输出 2

输出:1 → 4 → 3 → 2

进阶 Quiz:链式 Promise

Promise.resolve()
  .then(() => {
    console.log("A");
    return Promise.resolve("B");
  })
  .then((val) => console.log(val));

Promise.resolve().then(() => console.log("C"));

请先思考输出顺序……

答案是:A → C → B

关键点:return Promise.resolve("B") 会在微任务队列中多插入一层。等价于:

// 内部行为类似:
.then(() => {
  // 需要等待 Promise.resolve("B") resolve
  // 这会额外消耗一个微任务 tick
  return Promise.resolve("B");
})

所以 CB 更早执行,因为 C.then 回调是单独的微任务,而 B 需要额外等一个 tick 才能传递到下一个 .then

async/await 的本质

async/await 是 Promise 的语法糖,理解它的展开形式有助于预测执行顺序:

async function fetchData() {
  console.log("fetch start");
  const result = await Promise.resolve("data");
  console.log("fetch end:", result);
}

console.log("before");
fetchData();
console.log("after");

展开后等价于:

console.log("before");
// async 函数体开始同步执行
console.log("fetch start");
// await 相当于 .then,后面的代码注册为微任务
Promise.resolve("data").then((result) => {
  console.log("fetch end:", result);
});
// async 函数暂停,控制权返回调用处
console.log("after");
// 调用栈清空,执行微任务
// 输出:fetch end: data

输出:before → fetch start → after → fetch end: data

requestAnimationFrame 的位置

requestAnimationFrame(rAF)的执行时机比较特殊,它不是微任务也不是普通宏任务,而是在浏览器准备绘制下一帧时执行(通常约 16.6ms 一次)。

执行顺序:宏任务 → 微任务 → rAF → 浏览器渲染 → 下一个宏任务

setTimeout(() => console.log("setTimeout"), 0);

requestAnimationFrame(() => console.log("rAF"));

Promise.resolve().then(() => console.log("Promise"));

console.log("sync");

输出(通常):sync → Promise → setTimeout → rAF

注意:setTimeoutrAF 的相对顺序在不同场景下可能不固定,取决于浏览器的渲染调度,但 Promise 始终最先。

实际应用:避免长任务阻塞

理解事件循环后,可以用它来优化性能。如果一个计算任务很耗时,可以将它拆分成多个宏任务,避免阻塞渲染:

// ❌ 糟糕:10000 次循环直接阻塞页面
function processData(data) {
  for (let i = 0; i < data.length; i++) {
    heavyCompute(data[i]);
  }
}

// ✅ 好:用 setTimeout 把任务切片,让浏览器有机会渲染
function processDataInChunks(data, chunkSize = 100) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, data.length);
    for (let i = index; i < end; i++) {
      heavyCompute(data[i]);
    }
    index = end;

    if (index < data.length) {
      setTimeout(processChunk, 0); // 交还控制权给浏览器
    }
  }

  processChunk();
}

现代浏览器也提供了更优雅的 API:

// 使用 scheduler.postTask(Chrome 94+)按优先级调度任务
scheduler.postTask(() => heavyTask(), { priority: "background" });

// 使用 requestIdleCallback 在浏览器空闲时执行
requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0) {
    processNextItem();
  }
});

终极 Quiz

综合以上知识,预测输出:

console.log("start");

setTimeout(() => {
  console.log("timeout 1");
  Promise.resolve().then(() => console.log("promise in timeout"));
}, 0);

Promise.resolve()
  .then(() => {
    console.log("promise 1");
    setTimeout(() => console.log("timeout 2"), 0);
  })
  .then(() => console.log("promise 2"));

console.log("end");

逐步分析:

  1. 同步执行:startend
  2. 微任务队列有 promise 1 的回调:输出 promise 1,注册 timeout 2 到宏任务队列
  3. 链式 .then 产生微任务:输出 promise 2
  4. 微任务队列清空,取出第一个宏任务 timeout 1:输出 timeout 1,注册微任务 promise in timeout
  5. 宏任务执行完,清空微任务:输出 promise in timeout
  6. 取出下一个宏任务 timeout 2:输出 timeout 2

最终输出start → end → promise 1 → promise 2 → timeout 1 → promise in timeout → timeout 2

总结

类型代表 API执行时机优先级
同步代码普通语句立即最高
微任务Promise.then, queueMicrotask调用栈清空后立刻
requestAnimationFramerAF浏览器绘制前
宏任务setTimeout, setInterval每轮事件循环一个

掌握事件循环的核心规则——每个宏任务执行完都要清空所有微任务——就能准确预测几乎所有异步代码的执行顺序。这也是面试中的高频考点,更是写好异步代码的理论基础。