iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
JavaScript

JavaScript為什麼筆記本系列 第 29 篇

Day 29|Promise 和 setTimeout 都是「晚點執行」,為什麼 Promise 常常先跑?

  • 分享至 

  • xImage
  •  

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

先用最常見的情況理解:

  • Promise.then() 的 reaction 會排進 Microtask Queue。
  • setTimeout() 到期後,它的 callback 會等待成為之後的 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

Microtask 不是「比較快的 setTimeout」

這裡很容易又背成:

Promise 比 setTimeout 快。

這句話太粗。

真正的差別不是 Promise 天生速度比較快,而是它們進入不同的排程階段。

比較接近:

Promise.then()
→ Microtask

setTimeout callback
→ 之後的 Task

當目前 Task 結束時:

先處理 Microtask
↓
Microtask 清空
↓
才有機會開始下一個 Task

所以今天真正要記的是「順序規則」,不是「誰效能比較快」。

為什麼要有 Microtask?

假設 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

queueMicrotask 讓規則更明顯

瀏覽器還有一個名字就很直接的 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 在執行時又不停產生新的 Microtask:

function loop() {
  queueMicrotask(loop)
}

loop()

那 Microtask Queue 可能一直清不完。

這時後面的 Task,甚至瀏覽器取得更新畫面的機會,都可能被延後。

所以:

Microtask 優先,不代表可以無限濫用。

它只是排程模型的一部分。

Event Loop 心智模型再升級一次

Day 28 的版本:

Call Stack
↓
Runtime
↓
Queue
↓
Event Loop

今天把 Queue 拆開後,可以先記成:

一個 Task 開始
↓
執行同步 JavaScript
↓
Call Stack 清空
↓
清 Microtask Queue
↓
瀏覽器可能有更新畫面的機會
↓
開始下一個 Task
↓
再重複

實際瀏覽器事件迴圈比這張圖還細,而且不同來源可能有不同 task source。

但現在這個模型已經足以解釋:

為什麼 Promise.then() 常常在 setTimeout(..., 0) 前面。

把 async/await 偷看一眼

假設:

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 看起來像同步,底下為什麼還是非同步?


上一篇
Day 28|setTimeout(fn, 0) 都寫 0 了,為什麼還是晚點才執行?
下一篇
Day 30|async/await 看起來像同步,底下為什麼還是非同步?
系列文
JavaScript為什麼筆記本 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言