Day 28 最後留下這段:
setTimeout(() => {
console.log('timeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise')
})
兩個看起來都是:
現在先不要執行
↓
晚一點再回來
如果只記得 Day 28 的:
callback 要等目前同步 JavaScript 跑完,才有機會回到 Call Stack。
那我很容易以為兩個既然都在等,誰先排到就誰先跑。
但實際上常常會看到:
promise
timeout
所以今天要追的問題是:
Promise.then() 和 setTimeout() 都不是同步執行,為什麼 Promise 的 callback 常常比較早回來?
先看完整一點:
console.log('A')
setTimeout(() => {
console.log('B')
}, 0)
Promise.resolve().then(() => {
console.log('C')
})
console.log('D')
先猜一次。
如果把所有「晚點執行」都當成同一條隊伍,很容易猜:
A
D
B
C
但一般瀏覽器環境裡,結果會是:
A
D
C
B
A 和 D 很好理解。
它們是目前這一輪同步 JavaScript。
真正要拆的是:
C 為什麼跑在 B 前面?
Day 28 為了先理解 Event Loop,把非同步 callback 粗略畫成:
Runtime
↓
Queue
↓
Call Stack 空了
↓
callback 回來
這個模型沒有錯,但還不夠細。
到了 Promise,要把「Queue」再拆一層:
目前的 JavaScript Task
↓
同步程式跑完
↓
Microtask Queue
↓
下一個 Task
先用最常見的情況理解:
所以前面的程式可以畫成:
目前 Task 開始
↓
console.log('A')
↓
setTimeout(..., 0)
→ Timer 交給 Runtime
↓
Promise.resolve().then(...)
→ then callback 排成 Microtask
↓
console.log('D')
↓
目前同步程式結束
↓
先清 Microtask Queue
→ console.log('C')
↓
再處理下一個 Task
→ console.log('B')
因此得到:
A
D
C
B
這裡很容易又背成:
Promise 比 setTimeout 快。
這句話太粗。
真正的差別不是 Promise 天生速度比較快,而是它們進入不同的排程階段。
比較接近:
Promise.then()
→ Microtask
setTimeout callback
→ 之後的 Task
當目前 Task 結束時:
先處理 Microtask
↓
Microtask 清空
↓
才有機會開始下一個 Task
所以今天真正要記的是「順序規則」,不是「誰效能比較快」。
假設 Promise 已經完成:
const p = Promise.resolve('done')
p.then(value => {
console.log(value)
})
如果 then callback 被當成一般下一輪 Task,它可能會被其他計時器、事件拖得更後面。
Microtask 提供一種:
目前這輪同步工作做完後,盡快把這些延續工作處理掉。
的排程方式。
Promise 的後續處理因此可以在下一個一般 Task 之前完成。
console.log('1')
setTimeout(() => {
console.log('2')
}, 0)
Promise.resolve()
.then(() => {
console.log('3')
})
.then(() => {
console.log('4')
})
console.log('5')
結果通常是:
1
5
3
4
2
為什麼第二個 then 也能在 timeout 前面?
第一個 then 執行後,會讓下一個 Promise reaction 再排入 Microtask Queue。
而 Microtask Queue 的規則不是:
只看一次,有東西就拿一個。
而是會持續處理,直到這一輪的 Microtask Queue 被清空。
所以:
同步程式結束
↓
跑第一個 then → 印 3
↓
下一個 then 成為新的 Microtask
↓
繼續跑 → 印 4
↓
Microtask 清空
↓
才輪到 timeout → 印 2
瀏覽器還有一個名字就很直接的 API:
queueMicrotask(() => {
console.log('microtask')
})
放在一起:
console.log('A')
setTimeout(() => {
console.log('timeout')
}, 0)
queueMicrotask(() => {
console.log('microtask')
})
console.log('B')
會得到:
A
B
microtask
timeout
這讓我比較不會把 Promise 當成某種「神秘特例」。
真正的概念是:
Promise 的後續 callback 使用 Microtask 這種排程機制。
如果 Microtask 在執行時又不停產生新的 Microtask:
function loop() {
queueMicrotask(loop)
}
loop()
那 Microtask Queue 可能一直清不完。
這時後面的 Task,甚至瀏覽器取得更新畫面的機會,都可能被延後。
所以:
Microtask 優先,不代表可以無限濫用。
它只是排程模型的一部分。
Day 28 的版本:
Call Stack
↓
Runtime
↓
Queue
↓
Event Loop
今天把 Queue 拆開後,可以先記成:
一個 Task 開始
↓
執行同步 JavaScript
↓
Call Stack 清空
↓
清 Microtask Queue
↓
瀏覽器可能有更新畫面的機會
↓
開始下一個 Task
↓
再重複
實際瀏覽器事件迴圈比這張圖還細,而且不同來源可能有不同 task source。
但現在這個模型已經足以解釋:
為什麼 Promise.then() 常常在 setTimeout(..., 0) 前面。
假設:
async function run() {
console.log('A')
await Promise.resolve()
console.log('B')
}
run()
console.log('C')
結果通常是:
A
C
B
以前看到 await,我會覺得:
都叫 await 了,JavaScript 應該停在那裡等吧?
但現在已經開始能猜到:
await 前面
→ 目前同步執行
await 後面
→ 之後再繼續
而這個「之後再繼續」跟 Promise / Microtask 有很深的關係。
今天先不展開,留給最後一篇。
在瀏覽器 Console 貼上:
console.log('sync 1')
setTimeout(() => {
console.log('task')
}, 0)
Promise.resolve().then(() => {
console.log('microtask 1')
})
queueMicrotask(() => {
console.log('microtask 2')
})
console.log('sync 2')
先不要執行,先寫下你猜的順序。
答案通常是:
sync 1
sync 2
microtask 1
microtask 2
task
如果把 Promise 和 queueMicrotask 的前後交換,它們在 Microtask Queue 裡的相對順序也會跟著改。
console.log('A')
Promise.resolve().then(() => {
console.log('B')
Promise.resolve().then(() => {
console.log('C')
})
})
setTimeout(() => {
console.log('D')
}, 0)
console.log('E')
順序是什麼?
A
E
B
C
D
A、E 是目前同步程式。
B 是第一個 Microtask。
B 執行時又排了 C,因此 Microtask Queue 還沒清空,會繼續執行 C。
等 Microtask 清空,才輪到下一個 Task 裡的 D。
目前同步 JavaScript 結束後,Promise.then() 這類 Microtask 會先被清掉,之後才會進入下一個像 setTimeout callback 這樣的 Task。
所以看到非同步程式時,我現在不只問:
它是不是晚點執行?
而會再多問兩句:
它排的是 Microtask?
還是之後的 Task?
這兩句常常就決定真正的執行順序。
現在終於能重新看這段:
async function loadData() {
const response = await fetch('/api/data')
return response.json()
}
它讀起來很像同步程式:
先等 fetch
↓
再往下
但 JavaScript 主執行緒並沒有因此卡在原地。
所以最後一篇要把前面的 Promise、Microtask、callback 全部收回來問:
async/await 看起來像同步,底下為什麼還是非同步?