输出顺序不是代码顺序:一条栈、两级队列
场景
一段代码里混着 setTimeout 和 Promise.then,猜不准 console.log 出来的先后。经典面试题的答案背下来了,换个写法照样错——因为背的是结论,不是引擎做的动作。
一句话
同步代码一口气跑到栈空,然后清空整个微任务队列,才取一个宏任务;跑完这一个,再清空微任务队列,如此循环。await 之后的代码是微任务,不是同步代码。
心智模型
栈没空之前,谁都插不进来
"JS 是单线程"的真实含义是:只有一条调用栈,一段同步代码开始跑,就会一路跑到栈空为止。这期间队列里排了什么都不重要——定时器到点了、Promise 早就 resolve 了,全都得等。
所以下面这段的 while 会把定时器整整堵死 3 秒,而不是"3 秒里它先响一下"——「到点了」排在同步代码后面出来:
setTimeout(() => console.log('到点了'), 0)
const end = Date.now() + 3000
while (Date.now() < end) {} // 栈被占死,队列全饿着
console.log('同步代码终于跑完')
两级队列,微任务清空到底
栈空之后,引擎去队列里取活干。队列有两级,优先级不同:
| 谁会进这个队列 | 一轮取多少 | |
|---|---|---|
| 微任务 microtask | Promise.then/catch/finally、await 之后的代码、queueMicrotask、MutationObserver | 全部,清到空为止 |
| 宏任务 macrotask | setTimeout、setInterval、MessageChannel、I/O、各类事件回调 | 一个 |
真正决定顺序的是右边那一列。微任务不是"排在后面等着",而是插在下一个宏任务之前,而且会连锁——清空过程中新产生的微任务,也算在这一次清空里,一并跑掉。
graph LR
S["同步代码<br/>跑到栈空"] --> M["清空微任务队列<br/>(含清空途中新产生的)"]
M --> T["取 1 个宏任务<br/>跑完"]
T --> M 这张图解释了本篇几乎所有现象:微任务和宏任务不是同一个量级的东西。宏任务是"下一轮",微任务是"这一轮的尾巴"。
await 就是 .then 的另一种写法
把 await 读成"这行之后的代码,包成一个 .then 回调",async 函数就不再特殊了:
async function f() {
console.log('B')
await null // 等价于:Promise.resolve(null).then(() => {
console.log('D') // console.log('D')
} // })
注意 await null —— 等的是个非 Promise 的普通值,照样要过一遍微任务队列。await 花不花时间跟等的是什么无关,它总是至少让出一个微任务 tick。
自己跑一遍
每段都先自己猜顺序,再点运行核对。猜错的地方才是真正学到东西的地方。
基础四步
console.log('1 同步')
setTimeout(() => console.log('5 宏任务'), 0)
Promise.resolve().then(() => console.log('3 微任务'))
queueMicrotask(() => console.log('4 微任务'))
console.log('2 同步')
两个同步的先出,两个微任务按注册顺序跟上,setTimeout 哪怕写 0 也垫底。把 setTimeout 挪到第一行,输出顺序也不会变——代码位置不影响它属于哪个队列。
await 让出的那一下
async function f() {
console.log('B')
await null
console.log('D')
}
console.log('A')
f()
console.log('C')
A B C D。f() 是同步调用的,所以 B 紧跟 A;await 一让出,控制权就回到调用处继续跑 C;D 要等这一轮同步代码跑完、进微任务清空阶段才出来。
把 await null 去掉,输出就变成 A B D C——那一下让出没了,整个函数变回同步。
微任务连锁,宏任务干等
setTimeout(() => console.log('timeout 1'), 0)
setTimeout(() => console.log('timeout 2'), 0)
Promise.resolve()
.then(() => console.log('micro 1'))
.then(() => console.log('micro 2'))
.then(() => console.log('micro 3'))
三个微任务是依次产生的:跑 micro 1 时 micro 2 还没进队列。但它们仍然全部排在 timeout 1 前面——因为"清空微任务队列"的清空过程会把途中新产生的一并算进去。这就是那句"清到空为止"的实际后果。
坑
微任务能饿死宏任务
上一条的连锁没有出口,就是一个不用 while 也能写出的死循环:
这段会跑到 5 秒保护把它砍掉,setTimeout 的回调一次都不会执行。栈是空的、CPU 也没闲着,但宏任务队列永远等不到"微任务清空"这个前提。递归 queueMicrotask 或在 .then 里无条件返回新 Promise 时要留个出口。
setTimeout(fn, 0) 不是 0
0 只是"尽快",实际有下限。HTML 规范规定:嵌套层数超过 5 层后,小于 4ms 的延迟一律钳到 4ms。
let depth = 0
const t0 = Date.now()
function tick() {
depth++
if (depth <= 10) {
console.log(`第 ${depth} 层,累计 ${Date.now() - t0}ms`)
setTimeout(tick, 0)
}
}
setTimeout(tick, 0)
看累计耗时的斜率(Chromium 实测):前 6 层挤在 1ms 内,第 7 层起每层稳定 +4ms。
钳制是按嵌套层数触发的,不是按经过的时间——所以"第一次 setTimeout(fn, 0) 很快"和"循环里的 setTimeout(fn, 0) 每次 4ms"可以同时成立。要"这一轮结束就跑",该用 queueMicrotask,别拿 setTimeout(fn, 0) 当"立刻"。
return 一个 Promise 要多吃 2 个 tick
.then 回调里返回普通值,下一环就是 1 个 tick;返回 Promise 则要多花 2 个 tick 去展开它。拿两条链赛跑就能看出来:
Promise.resolve()
.then(() => { console.log('A1'); return Promise.resolve() }) // 对照组:换成 return 1
.then(() => console.log('A2'))
Promise.resolve()
.then(() => console.log('B1'))
.then(() => console.log('B2'))
.then(() => console.log('B3'))
.then(() => console.log('B4'))
A2 落在 B3 之后。换成 return 1,A2 就提前到 B1 和 B2 之间——差出的正好是 2 个 tick。async 函数里 await 另一个 async 函数时同理,这是 await 链看起来"莫名慢半拍"的来源。
宏任务不止一个队列
规范里宏任务是多个任务源(timer、网络、用户交互……),每个源有自己的队列,事件循环每轮"挑一个源"——挑哪个由实现决定。所以 setTimeout 和 MessageChannel 的相对顺序不由规范保证,跨浏览器可能不同,别把某次实测的顺序当契约。微任务只有一个队列,所以微任务之间的顺序是确定的。
本篇看不到渲染
上面的代码跑在 Web Worker 里,那里没有渲染流水线,也没有 requestAnimationFrame。真实页面里,"清空微任务"和"下一个宏任务"之间还夹着浏览器决定要不要重排重绘的时机——那一层是渲染问题,不在这条队列模型里,得另开一篇。