学完这篇,你能准确判断任何一段 JavaScript 代码在事件循环里的执行顺序,不再靠猜。
第一步:先认清三块东西
事件循环(Event Loop)本质是一个调度器,它只在三块地方之间倒腾任务:
- 调用栈(Call Stack):同步代码在这里执行,后进先出。
- 微任务队列(Microtask Queue):
Promise.then、queueMicrotask、MutationObserver 的回调排这里。
- 宏任务队列(Macrotask / Task Queue):
setTimeout、setInterval、setImmediate(Node)、I/O 回调、UI 事件回调排这里。
规则只有两条,背下来就够用:
- 同步代码先跑完,栈清空后立刻把微任务队列全部清空(清空过程中新产生的微任务也要在本轮清完)。
- 然后取一个宏任务执行,执行完再清空微任务队列,如此循环。
一句话口诀:同步 → 所有微任务 → 一个宏任务 → 所有微任务 → 下一个宏任务。
第二步:用最小例子验证
打开浏览器 DevTools 的 Console,或新建 demo.js 用 node demo.js 跑:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1 4 3 2
推演一遍:1、4 是同步代码直接输出;宏任务 setTimeout 和微任务 then 都被挂起;栈空后先清微任务,出 3;最后取那个宏任务,出 2。
注意:setTimeout(fn, 0) 不是"立刻执行",而是"最快下一轮宏任务"。浏览器对嵌套超过 5 层的定时器还会强制最小 4ms 延迟,所以它一定排在所有微任务之后。
第三步:搞定 async/await
await 是新手最容易翻车的地方。记住:await 后面的代码,等价于放进 .then 里,也就是变成微任务。
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve()
.then(() => console.log('promise1'))
.then(() => console.log('promise2'));
(async () => {
console.log('async1 start');
await Promise.resolve();
console.log('async1 end');
})();
console.log('script end');
输出顺序:
script start
async1 start
script end
promise1
async1 end
promise2
setTimeout
推导:async1 start 是同步的,await 之前的代码照常跑;await 把 async1 end 推入微任务队列。此时微任务队列是 [promise1 回调, async1 的续体]。清空过程中 promise1 又追加了 promise2,所以 async1 end 排在 promise2 前面。
注意:await 一个非 Promise 值(比如 await 1)也会让出一次执行权,后面的代码照样变成微任务。别以为只有等 Promise 才会切。
第四步:分清净与浏览器差异
Node.js 里多两个角色:process.nextTick 和 setImmediate。
process.nextTick 优先级高于 Promise 微任务,属于"微任务里的微任务"。
setImmediate 在 check 阶段执行,和 setTimeout(fn, 0) 的先后在 Node 里不保证,取决于当次循环启动耗时。
注意:Node 11 之后微任务的处理已经与浏览器对齐(每个宏任务后清空微任务),但网上大量老文章还在讲 Node 10 的旧行为。看到"setTimeout 在 setImmediate 前"的结论,先确认版本。
第五步:避开三个真实坑
坑一:微任务死循环会卡死页面。 下面这段会让浏览器永远没机会渲染:
function loop() {
Promise.resolve().then(loop);
}
loop(); // 页面直接无响应
因为微任务队列永远清不空,渲染和下一个宏任务都轮不上。要"让出"主线程,用 setTimeout(loop, 0) 而不是微任务递归。
坑二:以为 await 能并行。 for 循环里一个个 await 是串行的,想并行要用 Promise.all。
坑三:在微任务里改 DOM 后立刻读布局。 微任务阶段还没到渲染时机,读到的仍是旧布局,容易拿到错误的尺寸。需要读布局就放到 requestAnimationFrame 里。
小结
- 执行顺序:同步代码 → 清空所有微任务 → 一个宏任务 → 清空所有微任务 → 循环。
- 微任务:
Promise.then/catch/finally、queueMicrotask、MutationObserver;宏任务:setTimeout、setInterval、I/O、UI 事件。
await 之后的代码是微任务;await 非 Promise 值同样会切一次。
setTimeout(fn, 0) 永远排在当前所有微任务之后,且有 4ms 最小延迟限制。
- 微任务递归会饿死渲染,需要让出主线程时用宏任务。
- Node 里
process.nextTick 比 Promise 微任务更早,setImmediate 与 setTimeout(0) 顺序不保证。