iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
JavaScript

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

Day 28|setTimeout(fn, 0) 都寫 0 了,為什麼還是晚點才執行?

  • 分享至 

  • xImage
  •  

Day 27 最後留了一個很常見的畫面:

setTimeout(() => {
  console.log('hello')
}, 1000)

以前我會把它理解成:

JavaScript 看到 setTimeout
↓
停下來等 1 秒
↓
再執行 callback

但如果真的是這樣,網頁只要設一個計時器,整段 JavaScript 不就卡住了?

更奇怪的是:

console.log('A')

setTimeout(() => {
  console.log('B')
}, 0)

console.log('C')

第一次看到時,我很容易猜:

A
B
C

畢竟都寫 0 毫秒了。

但實際結果通常是:

A
C
B

所以今天先不背 Event Loop 的定義,只追一個問題:

setTimeout(fn, 0) 的 0 到底代表什麼?為什麼 callback 還是不能插隊?

先看同步程式怎麼跑

function greet() {
  console.log('hello')
}

console.log('A')
greet()
console.log('C')

可以先把 JavaScript 執行想成有一個 Call Stack:

執行全域程式
↓
console.log('A')
↓
greet()
↓
console.log('hello')
↓
greet() 結束
↓
console.log('C')

今天不用先背 Stack 的每一個細節,只抓住一件事:

目前正在執行的同步工作沒結束前,下一段 JavaScript 不會憑空插進來。

那 setTimeout 在哪裡?

再放回:

console.log('A')

setTimeout(() => {
  console.log('B')
}, 1000)

console.log('C')

setTimeout 很容易讓我誤會成「JavaScript 自己停著數 1000 ms」。

但在瀏覽器環境裡,計時工作不是讓 Call Stack 卡在原地。

先粗略想成:

JavaScript 執行 setTimeout(...)
        ↓
把計時工作交給瀏覽器 Runtime
        ↓
JavaScript 繼續往下跑
        ↓
console.log('C')

所以 JavaScript 不需要在原地等那一秒。

這就是非同步第一次真正有感的地方:

等待可以交給執行環境處理,但 JavaScript 主執行緒仍然一次執行眼前的一段程式。

時間到了,callback 就立刻執行嗎?

也不是。

假設 Timer 到期,執行環境只能先知道:

這個 callback 現在可以準備回來了。

可以先把它想成:

Timer 到期
↓
callback 可以執行
↓
Task Queue
↓
等 Call Stack 有空

Event Loop 會持續關心:

Call Stack 現在空了嗎?

有空時,排隊中的工作才有機會進來。

所以「1000 ms 到了」不是:

第 1000 ms 的那一瞬間一定執行。

而比較接近:

至少要等到這個時間之後,callback 才有資格排隊;真正執行還要等主執行緒空下來。

這就能解釋 setTimeout(fn, 0)

回到:

console.log('A')

setTimeout(() => {
  console.log('B')
}, 0)

console.log('C')

流程可以拆成:

1. 印出 A
2. setTimeout 把 Timer 交給 Runtime
3. JavaScript 繼續往下
4. 印出 C
5. 目前這輪同步工作結束
6. callback 已經在等待
7. Event Loop 讓它回來執行
8. 印出 B

所以結果是:

A
C
B

那個 0 不是:

現在立刻執行。

而是:

沒有要求額外等待一個明顯的延遲時間,但 callback 仍然不能插進尚未結束的同步程式。

實際環境還可能有最小延遲與排程因素,因此「0」也不是精準保證零毫秒後開始執行。

如果主執行緒自己忙很久呢?

console.log('start')

setTimeout(() => {
  console.log('timer')
}, 100)

const start = Date.now()

while (Date.now() - start < 2000) {
  // 故意卡住主執行緒約 2 秒
}

console.log('end')

如果把 100 ms 理解成:

100 ms 後一定執行。

就會很困惑。

Timer 雖然早就到期,但 JavaScript 還在跑 while。

所以 callback 只能等。

結果會比較接近:

start
(主執行緒忙約 2 秒)
end
timer

這時我才真正分清楚:

Timer 到期
≠
callback 此刻一定開始執行

Event Loop 到底在 loop 什麼?

以前背:

Event Loop 監控 Call Stack 和 Queue。

很容易隔天忘掉。

現在把它放回剛才的流程:

Call Stack
正在跑同步 JavaScript
        ↓

Runtime
處理 Timer 等等待工作
        ↓

Task Queue
放已經可以回來的 callback
        ↓

Event Loop
確認 Stack 是否空了
        ↓
有空時安排下一個 task

所以 Event Loop 不是讓 JavaScript「同時跑很多段」。

比較像:

協調目前的同步執行,和那些已經準備好、正在排隊的非同步工作。

那 JavaScript 到底是不是單執行緒?

至少在今天這個一般瀏覽器主執行緒的模型裡,可以先這樣理解:

JavaScript 主執行緒
一次執行一段 JavaScript

但瀏覽器 Runtime 還能幫忙處理 Timer、網路等待、事件等待等事情。

所以畫面不是:

JavaScript 同時執行 A、B、C

而比較像:

JavaScript 執行 A
↓
把等待工作交出去
↓
繼續執行 C
↓
未來條件完成
↓
callback 排隊
↓
主執行緒有空
↓
再執行 B

最小實驗

直接在瀏覽器 Console 跑:

console.log('1')

setTimeout(() => {
  console.log('2')
}, 0)

console.log('3')

先猜,再執行。

接著再跑:

console.log('start')

setTimeout(() => {
  console.log('timer')
}, 0)

for (let i = 0; i < 1_000_000_000; i++) {
  // 故意做很多同步工作
}

console.log('end')

不用在意迴圈到底花幾秒,只觀察:

Timer callback 能不能穿過還沒結束的同步程式?

不會。

這個實驗比只背 Event Loop 的一句定義更有感。

先別往下看,猜一次

console.log('A')

setTimeout(() => {
  console.log('B')
}, 0)

function test() {
  console.log('C')
}

test()

console.log('D')

順序是什麼?

A
C
D
B

test() 是 function call,但它仍然是目前這輪同步程式的一部分。

Timer callback 要等同步工作結束,才有機會回到 Call Stack。

如果今天只記得一件事

setTimeout(fn, 0) 的 0 不是「立刻執行」;callback 仍然要等目前同步程式跑完,再由事件迴圈安排回來。

所以看到非同步程式,我現在會先問:

現在誰在 Stack 上?
↓
哪些等待工作交給 Runtime?
↓
完成後排到哪裡?
↓
什麼時候才有機會回到 Stack?

上一篇
Day 27|同一個 function,為什麼換個呼叫方式,this 就變了?
系列文
JavaScript為什麼筆記本 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言