先从单线程说起
JavaScript 主线程同一时刻只能执行一段代码。函数被调用时进入调用栈,执行结束后离开。网络请求和定时器如果一直占住主线程,页面就无法响应,因此浏览器会把这些工作交给 Web API,完成后再把对应回调放进队列。
宏任务与微任务
常见的宏任务包括脚本整体、setTimeout 和用户交互事件;Promise 的 then、catch 以及 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("脚本结束");
// 开始、脚本结束、继续
实践中的三个提醒
- 不要用
setTimeout(..., 0)猜测业务执行顺序,应显式使用 Promise 或 await 表达依赖。 - 微任务中不断创建新微任务会推迟页面渲染,长计算仍应拆分或交给 Web Worker。
- 排查问题时先写出同步代码,再列微任务,最后列下一轮宏任务,通常就能得到答案。
事件循环不是需要死记的面试题,而是一张解释页面为何卡顿、回调为何晚到的运行地图。