先从单线程说起

JavaScript 主线程同一时刻只能执行一段代码。函数被调用时进入调用栈,执行结束后离开。网络请求和定时器如果一直占住主线程,页面就无法响应,因此浏览器会把这些工作交给 Web API,完成后再把对应回调放进队列。

宏任务与微任务

常见的宏任务包括脚本整体、setTimeout 和用户交互事件;Promise 的 thencatch 以及 queueMicrotask 属于微任务。每轮事件循环会执行一个宏任务,清空当轮产生的全部微任务,然后浏览器才有机会更新页面。

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");

// 输出:A、D、C、B

主脚本先输出 A 和 D。定时器回调进入宏任务队列,Promise 回调进入微任务队列。主脚本结束后先清空微任务,所以 C 早于 B。这里的 0 毫秒只代表“最早可以排队”,不代表立即执行。

async / await 并没有创造新规则

await 可以理解为暂停当前 async 函数,并把它后面的部分安排为 Promise 完成后的微任务。它改善了异步代码的可读性,但底层仍遵循同一套队列规则。

async function load() {
  console.log("开始");
  await Promise.resolve();
  console.log("继续");
}

load();
console.log("脚本结束");
// 开始、脚本结束、继续

实践中的三个提醒

  1. 不要用 setTimeout(..., 0) 猜测业务执行顺序,应显式使用 Promise 或 await 表达依赖。
  2. 微任务中不断创建新微任务会推迟页面渲染,长计算仍应拆分或交给 Web Worker。
  3. 排查问题时先写出同步代码,再列微任务,最后列下一轮宏任务,通常就能得到答案。

事件循环不是需要死记的面试题,而是一张解释页面为何卡顿、回调为何晚到的运行地图。