讲解
JavaScript 是单线程的:同一时刻只执行一段代码。那异步回调(定时器、网络响应、用户点击)是怎么被调度的?答案是事件循环模型。引擎维护一个调用栈(正在执行的同步代码)和两类队列:宏任务队列(setTimeout、setInterval、I/O、事件回调)和微任务队列(Promise 的 then/catch/finally 回调、queueMicrotask)。规则一句话:同步代码全部执行完 → 清空所有微任务 → 取一个宏任务执行 → 再清空微任务 → 下一个宏任务……如此往复。
「微任务在每两个宏任务之间全部清空」是最关键的规则,它解释了经典输出顺序题:同步代码最先;Promise.then 永远先于 setTimeout(即使延时 0);then 里又产生的微任务插在本轮微任务队尾,仍然会先于下一个宏任务执行。await 后面的代码本质上就是一个微任务——await 之后的部分等价于写在 then 回调里。
这个模型的工程启示:长时间运行的同步代码会卡死整个页面(按钮点不动、动画停住),因为事件循环腾不出手处理其他任务;CPU 密集的大计算要切片(setTimeout 分批)或放进 Web Worker。想让一段代码「在当前任务后尽早执行」用 queueMicrotask,「下一轮再执行」用 setTimeout(fn, 0)。
示例
沙盒是同步窗口,异步调度用一个小型模拟器演示事件循环的执行规则:
// 用两个队列模拟事件循环的调度顺序
function simulateEventLoop() {
const log = [];
const microQueue = [];
const macroQueue = [];
// 「同步代码」先入栈执行
log.push('1. 同步代码');
macroQueue.push(() => log.push('4. setTimeout 回调(宏任务)'));
microQueue.push(() => {
log.push('3. Promise.then(微任务)');
microQueue.push(() => log.push('3.1 then 里又排的微任务,仍在本轮'));
});
log.push('2. 同步代码结束');
// 事件循环:先清空微任务,再取一个宏任务
while (microQueue.length > 0) microQueue.shift()();
while (macroQueue.length > 0) {
macroQueue.shift()();
while (microQueue.length > 0) microQueue.shift()(); // 每个宏任务后再清微任务
}
return log;
}
simulateEventLoop().forEach((line) => console.log(line));
真实环境的输出顺序题(浏览器控制台或 Node 中运行验证):
console.log('A:同步');
setTimeout(() => console.log('D:宏任务 setTimeout'), 0);
Promise.resolve()
.then(() => console.log('B:微任务 then'))
.then(() => console.log('C:微任务 then 的 then'));
queueMicrotask(() => console.log('B+:queueMicrotask 也是微任务'));
// 输出顺序:A → B → B+ → C → D
// 同步先行;微任务按入队顺序全部清空(含队尾新增的 C);宏任务最后
常见坑
- 以为 setTimeout(fn, 0) 立即执行:0 毫秒只是「尽快」,它仍要排到当前同步代码和所有微任务之后。
- 微任务里无限递归排微任务:Promise.resolve().then(loop) 循环排微任务会饿死宏任务,页面卡死但 CPU 占满——和死循环一样危险却更隐蔽。
- 期待 await 让出后顺序可控:await 之后是微任务,多个 await 链交错时执行顺序要靠推理而不是直觉,复杂编排画出任务队列再写。
- 长任务阻塞交互:几秒的同步计算让所有事件回调排队,用户以为页面死了。大计算分片或交给 Web Worker。
小结
单线程 + 调用栈 + 宏/微任务队列构成事件循环;每轮先清空微任务再取宏任务。Promise.then 先于 setTimeout,长同步任务会冻结页面。下一章进入浏览器 API:fetch 网络请求。