JavaScript 的非同步執行模型:call stack、web APIs、task queue、microtask queue 與 event loop

文字解析 · 1 支影片 · 產生於 2026-10-01 00:54

🎧 語音解析📝 筆記✍️ 練習

🎧 語音解析 21m21s · TTS 講解與作者原聲交錯;點章節可跳。
邊聽邊看逐句講稿,點任一句從那裡開始
章節(12)

1. Outline

  1. 起點 · JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue
    從 runtime 全景圖出發,先建立單一 call stack 的執行模型,再用長任務逼出問題,一路推出 web APIs、task queue、microtask queue 與 event loop 的分工,最後用一道 51342 的測驗驗收。

2. YouTuber 的思維推導

JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue

Lydia Hallie · 12m34s · 字幕 en · vision=on (整支影片是動畫講解,作者不斷指著畫面上的 call stack、queue、promise 物件說明搬移過程;程式碼與執行順序都畫在投影片上,不看圖無法對應說明。) · YouTube

預估 vs 實際耗時
階段預估實際
fetch 6s 2s
segment 55s –
shot 29s 6s
analyze 5m00s 9m15s
render 5s –

作者從一個定位動作出發:event loop 名氣很大,但它其實只是 JavaScript runtime 裡的一個小零件,要懂它必須先看整張圖。於是她先把 runtime 拆開(JS engine 裡的 call stack 與 memory heap、web APIs、task queue、microtask queue、event loop),再從最基本的 call stack 開始:JavaScript 單執行緒、一次只能跑一件事,執行順序完全由堆疊的推入與彈出決定。接著她故意讓這個模型撞牆——放一個重運算的長任務進去,整個頁面凍結;而真實應用又非用長任務不可(網路請求、使用者輸入、計時器)。

這個矛盾就是整支影片要解的題。她的答案分三步:第一步,長任務其實不由 JavaScript 自己跑,而是透過 web APIs 卸載給瀏覽器,發起呼叫雖然會上 call stack,但只是登記 callback 與啟動背景工作,登記完立刻彈出;第二步,工作完成後的 callback 不能直接插隊回 call stack(會打斷正在跑的任務),所以先進 task queue 排隊,由 event loop 在 call stack 空掉時搬過去——這就補上了 event loop 的角色;第三步,promise 型 API 走另一條佇列 microtask queue,而且 event loop 給它優先權,必須整個清空才輪到 task queue,且每處理完一個 task 又回頭再檢查一次。規則到齊後她用 setTimeout 拆掉一個常見誤解(延遲是到進入 task queue 為止,不是到執行為止),再用一題 51342 的小測驗把所有規則同時驗證一次,最後用一段 recap 把整條鏈重述,並提醒讀者自己動手玩 setTimeout 與 queueMicrotask 才會變成直覺。

推理鏈:每段留給下一段的線索

每一列是一段,箭頭後的字是這段留給下一段的線索。點段落標題跳到該段。

  1. Event Loop 只是 runtime 的一個小零件 → 全景圖上唯一「正在執行你的程式碼」的格子只有 call stack,其他格子都還是空的。所以下一步必須先搞清楚:這個唯一的堆疊,是怎麼決定一支程式的執行順序的?
  2. 單一 call stack 怎麼跑完一支程式 → 這個模型有一個沒被檢驗的前提:每個 execution context 都會「很快」彈出去。萬一有一個彈不掉呢?下一段就故意放一個彈不掉的東西進去。
  3. 單執行緒的代價:長任務會凍結畫面 → 作者自問「是不是就被鎖死了」,並且自答「不會,因為這些情況我們用的是 web APIs」。所以下一段必須先回答:web APIs 到底是什麼,它憑什麼能讓等待不占用 call stack?
  4. Web APIs:把長任務交給瀏覽器 → 作者在這一段結尾把有非同步能力的 web API 分成 callback 型與 promise 型兩類,並宣告先看 callback 型。所以下一段要用一個具體的 callback 型 API,把「登記 → 背景執行 → 結果回來」整條流程走一遍。
  5. Callback 型 API:為什麼不能直接放回堆疊 → 資料已經回來、callback 卻懸在半空,call stack 也空著,畫面上兩個佇列還是空的。下一段必須回答:這個 callback 到底先放到哪裡,又是誰決定什麼時候把它搬上 call stack?
  6. Task queue 與 event loop 的分工 → 這條回程路是用 geolocation 走通的,但 geolocation 只是眾多 callback 型 API 之一。下一段換上每個人天天在用的 setTimeout,用同一套規則檢驗它——而且會發現一個關於「延遲」的普遍誤解。
  7. setTimeout:延遲是到佇列,不是到執行 → 作者在這一段結尾自己收束了 callback 型的結論(web API 的 callback 在非同步任務完成時被推進 task queue),然後直接拋出問題:那 promise 型的呢?下一段要走的是另一條佇列。
  8. Microtask queue 有優先權 → 兩條路都走過了:callback 型進 task queue、promise 型進 microtask queue,而且後者有優先權。既然 promise 型明顯好用,下一段順手處理一個實務問題——手上那些舊的 callback 型 API 能不能改寫成 promise 型?
  9. 把 callback 型 API 包成 promise → 至此 call stack、web APIs、task queue、microtask queue、event loop 五個零件與它們的規則都齊了。下一段作者要用一道題,把這些規則放在同一支腳本裡同時檢驗。
  10. 小測驗:為什麼答案是 51342 → 推導在真實情境下成立了。剩下的只差把這一路走過的零件與規則,用一段話重新串成一條完整的鏈。
  11. 整條路線重講一次 → 規則與零件都收攏完了,唯一還沒解決的是最後一哩:怎麼把這套規則從「聽得懂」變成「用得出來」。
  12. 自己動手試才會真的懂

3. 逐段說明

JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue

1. Event Loop 只是 runtime 的一個小零件 0:00–0:32

作者先把鏡頭拉遠:event loop 名聲很大,但它只是 JavaScript runtime 裡的一個小元件。runtime 還有 call stack(嚴格說是 JS engine 裡的 call stack 與 memory heap)、web APIs、task queue、microtask queue。這些零件合起來,才讓 JavaScript 能以非阻塞的方式處理非同步工作。

runtime 全景圖:一次看到 call stack、web APIs、task queue、microtask queue、event loop 的相對位置,是整支影片的地圖
0:08 · runtime 全景圖:一次看到 call stack、web APIs、task queue、microtask queue、event loop 的相對位置,是整支影片的地圖
承上 用上前情提要裡「瀏覽器執行 JavaScript」這個背景:讀者已經知道程式碼跑在瀏覽器裡,但通常把整個環境當成一個黑盒子,只聽過 event loop 這個名字。作者要做的第一件事,就是把這個黑盒子拆開標名字。

推理因為讀者對 event loop 的印象是「一個難懂又神秘的大東西」,所以作者刻意先不解釋它,而是把鏡頭拉遠:先畫出整個 JavaScript runtime 的全景(call stack、web APIs、task queue、microtask queue,最後才是 event loop),讓讀者看見 event loop 只是其中一格。她甚至補了一句技術上更精確的說法——call stack 與 memory heap 其實在 JavaScript engine 裡面,只是為了投影片乾淨才只畫 call stack。她的目的是:先給地圖,後面每一段才知道自己站在哪一格。

AI 補充這裡有一個作者為了節奏而快速帶過、但影響後面全部推理的區分:**engine 不等於 runtime**。engine(V8、SpiderMonkey)只負責解析與執行 JavaScript,它裡面有 call stack 和 memory heap;而 web APIs、task queue、microtask queue、event loop 這幾格**都不在 engine 裡**,是宿主環境(瀏覽器,或 Node.js)額外提供的。這解釋了兩件後面會用到的事:第一,setTimeout、fetch 這些其實不是 JavaScript 語言的一部分,在 ECMAScript 規格裡找不到;第二,同一份 JavaScript 換到 Node.js 跑,engine 一樣但周邊零件換了一套,所以行為才會有差異。畫面上的全景圖也已經暗示了資料流向:web APIs 在右上、兩個 queue 在下方、event loop 站在 queue 左邊像個搬運工——東西是從右上繞一圈回到左上的 call stack,而不是直接回去。

JavaScript runtime

JavaScript 執行環境

讓 JavaScript 真正能做事的整套環境:engine 加上宿主提供的 web APIs、兩個佇列與 event loop。

runtime 是「engine + 周邊零件」的合稱。瀏覽器與 Node.js 都可以用同一個 V8 engine,但周邊零件不同,所以 runtime 不同:瀏覽器給你 DOM、fetch、geolocation,Node.js 給你檔案系統與 process。常見的誤解是把 runtime 和 engine 當同義詞,於是以為 setTimeout 是 JavaScript 語言的一部分——它不是。這張全景圖就是整支影片的座標系,後面每一個機制都會被放回某一格。

相關術語: JavaScript engine (包含)、non-blocking (目標)

出處:第 1 段「Event Loop 只是 runtime 的一個小零件」

JavaScript engine

JavaScript 引擎

負責解析並執行 JavaScript 程式碼的核心,內含 call stack 與 memory heap。

V8(Chrome、Node.js)、SpiderMonkey(Firefox)、JavaScriptCore(Safari)都是 engine。它只認得 ECMAScript 規格裡有的東西:語法、物件、Promise 本身。你在瀏覽器用的 document、fetch、setTimeout 都不在它管轄範圍內。作者說「為了讓投影片整齊,我只畫 call stack」,就是把 engine 這層邊界省略掉了。

相關術語: memory heap (包含)

出處:第 1 段「Event Loop 只是 runtime 的一個小零件」

memory heap

記憶體堆積

engine 裡存放物件與變數實體的記憶體區域,與負責執行順序的 call stack 是兩回事。

heap 管「東西放哪」,stack 管「現在跑到哪」。垃圾回收處理的是 heap,這支影片從頭到尾談的則是 stack 的推入與彈出。作者明講了這格存在但不畫出來,因為它跟執行順序無關;知道它存在,才不會把「記憶體滿了」和「堆疊被卡住」這兩種問題混為一談。

相關術語: JavaScript engine (位於其中)

出處:第 1 段「Event Loop 只是 runtime 的一個小零件」

non-blocking

非阻塞

發起一件耗時的工作後不必原地等待,程式可以繼續往下跑。

這是整支影片要達成的目標,也是評判後面每一個機制的標準:任何讓 call stack 空不下來的做法都是 blocking。作者在開場就把它講成「所有這些零件合起來,才讓我們能以非阻塞的方式處理非同步任務」——換句話說,後面出現的 web APIs、task queue、microtask queue、event loop,全都是為了同一件事服務。注意非阻塞不等於多執行緒:JavaScript 仍然只有一條線在跑,只是不在那條線上等。

相關術語: JavaScript runtime (由其達成)

出處:第 1 段「Event Loop 只是 runtime 的一個小零件」

留給下一段 全景圖上唯一「正在執行你的程式碼」的格子只有 call stack,其他格子都還是空的。所以下一步必須先搞清楚:這個唯一的堆疊,是怎麼決定一支程式的執行順序的?

2. 單一 call stack 怎麼跑完一支程式 0:32–1:18

因為 JavaScript 是單執行緒、只有一個 call stack,所以程式的執行順序完全由這個堆疊決定。作者用一支有巢狀函式呼叫的腳本示範:每次呼叫就建立 execution context 推上堆疊,執行完就彈出,巢狀呼叫則層層堆高再層層退回。

巢狀呼叫堆到最高的那一刻,看得到 log3and4 → log3 → console.log 疊了三層,是理解「彈出順序」的關鍵畫面
1:10 · 巢狀呼叫堆到最高的那一刻,看得到 log3and4 → log3 → console.log 疊了三層,是理解「彈出順序」的關鍵畫面
承上 承上:上一段留下的問題是「全景圖裡唯一在執行程式碼的格子是 call stack,它怎麼決定順序?」這一段就只放大那一格,其他格子在畫面上全部暗著。

推理因為上一段已經指出 call stack 是唯一的執行現場,所以作者接著給出限制條件——JavaScript 是單執行緒,只有一個 call stack——再用一支最單純、完全沒有非同步的腳本示範:每次函式呼叫建立一個 execution context 推上堆疊,執行完彈出;巢狀呼叫就層層堆高、再層層退回。她要先讓讀者建立「執行順序 = 堆疊的推入彈出順序」這條直覺,之後才有東西可以被打破。

AI 補充這一段的畫面比旁白多說了一件事,值得停下來看:截圖停在第 11 行(console.log("Four!"))時,call stack 上只剩 logThreeAndFour() 一層,console 已經印出 One、Two、Three。也就是說 logThree 與它裡面的 console.log 都已經彈掉了——巢狀呼叫堆得再高,也是「先進後出」一路退回來,堆疊的深度隨時在變。這裡補一步作者跳過的:最底下其實還有一個沒畫出來的全域 execution context(規格上是最外層的 script 執行),你的 console.log("One!") 是在它裡面跑的。理解這點,下一段「整個程式凍結」才說得通——凍結的意思正是:這個最底層的 context 遲遲彈不掉,堆疊永遠不會空。

single-threaded

單執行緒

JavaScript 只有一條執行線,同一時間只能處理一個任務。

單執行緒是整支影片所有麻煩的源頭,也是所有機制的設計前提。它換來的好處是簡單:你永遠不必擔心兩段 JavaScript 同時改到同一個變數,不需要鎖。代價則是任何一件慢的事都會擋住其他所有事。注意「JavaScript 單執行緒」講的是執行你程式碼的那條線;瀏覽器本身是多執行緒的(網路、算繪、計時都在別條線上),這正是後面把工作卸載出去的物理基礎。

相關術語: call stack (因此只有一個)

出處:第 2 段「單一 call stack 怎麼跑完一支程式」

call stack

呼叫堆疊

記錄目前執行到哪裡的堆疊,函式被呼叫就推上去、執行完就彈出來。

它是「先進後出」的:最後被推上去的最先彈出,所以巢狀呼叫會層層堆高再層層退回。整支影片之後所有的規則,最後都可以化約成一句話——某個東西什麼時候可以被推上 call stack。堆疊也有上限,遞迴太深就會看到 Maximum call stack size exceeded。它跟前一段的 memory heap 是分工關係:heap 放資料,stack 記順序。

相關術語: execution context (存放)、memory heap (對照組)

出處:第 2 段「單一 call stack 怎麼跑完一支程式」

execution context

執行環境

一次函式呼叫所需的一整包資訊(參數、區域變數、回到哪裡),被推上 call stack 的就是它。

被推上堆疊的不是「函式」而是「這一次呼叫」,所以同一個函式被呼叫兩次就有兩個 execution context。除了函式呼叫,最外層的整支腳本本身也有一個全域 execution context,作者為了畫面乾淨沒有畫出來。函式執行完,它的 context 就整個彈掉、區域變數一起消失——這也是為什麼後面那些「稍後才執行」的 callback 不能簡單地回到原本的位置繼續跑。

相關術語: call stack (被推入其中)

出處:第 2 段「單一 call stack 怎麼跑完一支程式」

留給下一段 這個模型有一個沒被檢驗的前提:每個 execution context 都會「很快」彈出去。萬一有一個彈不掉呢?下一段就故意放一個彈不掉的東西進去。

3. 單執行緒的代價:長任務會凍結畫面 1:18–2:01

既然一次只能處理一件事,一個很重的運算就會卡住後面所有程式碼,整個頁面在那幾秒鐘完全凍結。但現實應用一定會遇到長任務——網路請求、等使用者輸入、計時器。作者在這裡丟出問題:那 call stack 是不是就被鎖死到資料回來為止?

長任務佔住 call stack、後面的 console.log 全部等在原地的畫面,是「凍結」最直接的視覺證據
1:32 · 長任務佔住 call stack、後面的 console.log 全部等在原地的畫面,是「凍結」最直接的視覺證據
承上 承上:上一段留下的問題是「萬一有個 execution context 彈不掉呢?」作者這一段就直接做給你看——放一個跑 10 億次迴圈的 longRunningTask 進去。

推理因為上一段建立的模型是「順序完全由堆疊決定、一次一件」,所以作者接著推出它的必然後果:堆疊上那一格只要待得夠久,後面所有程式碼都得排隊,整個頁面在那幾秒完全沒反應。但她沒有停在「所以不要寫長任務」——她立刻補上現實:網路請求、等使用者輸入、計時器,這些長任務躲不掉。於是她把矛盾攤開,並用一個提問結束這一段:那 call stack 是不是就被鎖死到資料回來為止?這是整支影片真正的題目。

AI 補充截圖把代價畫得比旁白更清楚:call stack 上只有 longRunningTask() 一格、右上角轉圈,console 區塊完全空白——注意連第 6 行自己的 console.log("Long task done!") 都還沒印出來,第 14 行的 importantTask() 更是連被呼叫的機會都沒有。這裡要補一個作者沒說、但決定嚴重性的關鍵:被擋住的不只是你的 JavaScript。瀏覽器的算繪、捲動、點擊回應,跟你的程式碼共用同一條主執行緒,所以「凍結」是字面意義上的整個分頁不動,不是只有 console 慢一拍。另外要分清楚兩種長任務:這裡的重運算是**真的在 call stack 上算**,卸載給誰都沒用(那要靠 Web Worker);而下一段要處理的網路請求、計時器則是**在等別人**,等待這件事根本不需要占著堆疊。作者接下來的解法只解後者,這條界線她沒有明講。

術語:blockinglong-running task

blocking

阻塞

某段程式長時間占住 call stack,導致後面所有工作都無法進行。

阻塞是上一段「單執行緒」的直接後果,也是第 1 段 non-blocking 的反面。判斷標準很單純:這件事有沒有占著 call stack。在瀏覽器裡阻塞的影響會外溢到畫面,因為算繪和你的程式碼共用主執行緒,所以一個幾秒的迴圈就是幾秒的白畫面。實務上會用「長任務超過 50 毫秒」當警戒線,開發者工具的效能面板會把它標成 long task。

相關術語: non-blocking (相反)、long-running task (由其造成)

出處:第 3 段「單執行緒的代價:長任務會凍結畫面」

long-running task

長時間執行的任務

需要好一段時間才能完成的工作,例如重運算、網路請求、計時器、等使用者輸入。

作者把兩種性質不同的東西都叫長任務,值得自己分開記:一種是 CPU 真的在算(例如影片裡的 1e9 次迴圈),它必須占著 call stack,只能靠 Web Worker 搬到別的執行緒;另一種是在等外部結果(網路、計時器、使用者),等待期間根本沒有程式碼要跑。整支影片接下來的機制解決的是第二種——把「等」這件事交出去,這也是為什麼非同步不等於多執行緒。

相關術語: blocking (造成)

出處:第 3 段「單執行緒的代價:長任務會凍結畫面」

留給下一段 作者自問「是不是就被鎖死了」,並且自答「不會,因為這些情況我們用的是 web APIs」。所以下一段必須先回答:web APIs 到底是什麼,它憑什麼能讓等待不占用 call stack?

4. Web APIs:把長任務交給瀏覽器 2:01–2:47

答案是不會,因為這些情境用的是 web APIs。web APIs 是瀏覽器提供的一組介面,讓我們使用瀏覽器的能力:DOM、fetch、setTimeout,還有感測器、相機、地理位置等等。其中一部分 web API 允許我們把長任務「卸載」給瀏覽器處理;呼叫它們時,我們只是啟動這個卸載動作。而具備非同步能力的 web API 分成兩類:callback 型與 promise 型。

瀏覽器能力清單:把 rendering engine、networking stack 這類必要功能與 sensors、camera 這類額外功能並列,說明 web API 的範圍
2:21 · 瀏覽器能力清單:把 rendering engine、networking stack 這類必要功能與 sensors、camera 這類額外功能並列,說明 web API 的範圍
承上 承上:上一段的問題是「call stack 會不會被鎖死」,作者已經先給了答案的方向——不會,因為這些情況用的是 web APIs。這一段就把這個名詞展開。

推理因為上一段留下的解法只是一個名詞,所以作者接著補上它的定義與範圍:web APIs 是瀏覽器提供的一組介面,讓 JavaScript 能使用瀏覽器的能力——常用的 DOM、fetch、setTimeout,還有感測器、相機、地理位置這些比較有趣的。鋪完範圍她才切回主線:其中一部分 web API 允許我們把長任務卸載給瀏覽器,而呼叫它們時,我們做的只是「發動這個卸載」。最後她把有非同步能力的 web API 分成兩類(callback 型與 promise 型),替後面兩條路線先分好岔口。

AI 補充這張圖是本段的重點,因為它把上一段的困境從物理層面解掉了:畫面把 JAVASCRIPT RUNTIME 整個框起來放在左邊,右邊另一個框是 BROWSER ENVIRONMENT,裡面是 Rendering Engines、Network Service、GPU、Sensors、Camera、Geolocation,中間用一對左右箭頭相連。也就是說——瀏覽器本身是多執行緒的,網路請求真的在另一條線上跑。web API 只是那條界線上的門把:你在 call stack 上轉一下門把(登記工作),實際的活由門後的瀏覽器去做,你的堆疊立刻恢復空閒。這正好接上前一段的分類:這套機制對「在等別人」的長任務有效,對「自己在算」的重運算無效,因為 1e9 次迴圈沒有任何門後的服務可以代勞。另外提醒一個容易混淆的邊界:像 URL、localStorage 這些也在 web API 清單裡,但它們是同步的,一樣會擋住 call stack——「是 web API」不等於「是非同步的」,只有一部分 web API 具備非同步能力。

Web API

瀏覽器 API

瀏覽器提供給 JavaScript 使用的一組介面,讓程式能用到瀏覽器的能力(DOM、網路、計時器、感測器等)。

它們不屬於 JavaScript 語言本身,在 ECMAScript 規格裡找不到,是第 1 段說的宿主環境那一層;規格由 WHATWG/W3C 制定,MDN 上查得到。要記住兩條界線:第一,web API 不全是非同步的,URL、localStorage 都是同步的,照樣會阻塞;第二,Node.js 沒有這些 web API,它有自己的一套(fs、http),所以同一段程式換環境不一定能跑。

相關術語: offloading (部分提供)、JavaScript engine (不屬於)

出處:第 4 段「Web APIs:把長任務交給瀏覽器」

offloading

卸載

把耗時的工作交給瀏覽器在背景處理,JavaScript 只負責發起,不占用 call stack 等待。

這是本段解決上一段困境的關鍵動作,也是「非同步」在實作上的真正意思:不是同時跑兩段 JavaScript,而是把等待這件事搬出 JavaScript 的執行線。發起的那次呼叫仍然會上 call stack,但只做交接(登記要跑什麼、完成後要通知誰),做完立刻彈出。也因為工作跑在門後,工作的結果回來時,勢必要有一條路把它送回 JavaScript——這條回程路就是後面兩個佇列存在的理由。

相關術語: long-running task (處理)、blocking (避免)

出處:第 4 段「Web APIs:把長任務交給瀏覽器」

留給下一段 作者在這一段結尾把有非同步能力的 web API 分成 callback 型與 promise 型兩類,並宣告先看 callback 型。所以下一段要用一個具體的 callback 型 API,把「登記 → 背景執行 → 結果回來」整條流程走一遍。

5. Callback 型 API:為什麼不能直接放回堆疊 2:47–4:04

作者用 geolocation 的 getCurrentPosition 示範:呼叫時它確實被推上 call stack,但那只是為了登記 success 與 error 兩個 callback、啟動背景工作,登記完立刻彈出,不等資料。瀏覽器在背景跑流程、跳出授權視窗,這期間網站照常有反應。等使用者按下允許、資料回來,callback 卻不能直接被推回 call stack——那會打斷正在執行的任務,行為變得無法預測。

getCurrentPosition 登記完 callback 後立刻從 call stack 彈出、背景才開始跑的瞬間,說明「非同步不佔堆疊」
3:23 · getCurrentPosition 登記完 callback 後立刻從 call stack 彈出、背景才開始跑的瞬間,說明「非同步不佔堆疊」
資料回來、callback 懸在半空不能直接插隊的畫面,直接引出下一段的 task queue
4:00 · 資料回來、callback 懸在半空不能直接插隊的畫面,直接引出下一段的 task queue
承上 承上:上一段把非同步 web API 分成 callback 型與 promise 型,並宣告先看 callback 型。這一段就用 geolocation 的 getCurrentPosition 把「卸載」這個動作具體演一次。

推理因為上一段說「呼叫非同步 web API 只是發動卸載」,所以作者接著逐格驗證這句話:getCurrentPosition 的呼叫確實被推上 call stack,但那只是為了登記 success 與 error 兩個 callback、啟動背景任務,登記完立刻彈出,不等任何資料。她刻意強調中間那段最長的空白——瀏覽器跳出授權視窗,使用者可能十秒後才按,但這件事不發生在 call stack 上,所以網站照常有反應。最後使用者按下允許、資料回來,她卻沒有讓 callback 直接回到堆疊,而是停在那裡指出:不能這樣做,那會打斷正在執行的任務,造成無法預測的行為。這個「停住不做」是刻意的,因為它就是下一段那個機制存在的理由。

AI 補充兩張截圖剛好是這段的前後兩個關鍵時刻。第一張(登記那一刻)值得細看:Web APIs 框裡的 Geolocation API 底下,getCurrentPosition 已經把 successCallback 與 errorCallback 兩個函式整個存了起來,而 call stack 上還留著 getCurrentPosition() ——這一格馬上就會消失,兩個 callback 卻會留在門後等。第二張(使用者按允許那一刻)則是 call stack 完全空、task queue 與 microtask queue 也都還空著。這裡補一個作者沒明講、但決定整個設計的理由:callback 之所以不能直接插隊,是因為 JavaScript 保證**執行到完為止**(run-to-completion)——一個函式一旦開始跑,就不會跑到一半被別的程式碼插進來,你不必為每個變數加鎖。如果讓 callback 想回來就回來,這個保證立刻破功:你讀完 obj.a、正要讀 obj.b 之間,值可能已經被改掉。所以非同步的結果只能等目前這一輪完全跑完,這個限制不是實作偷懶,而是整個語言不需要鎖的前提。另外提醒:登記的兩個 callback 只會有一個被呼叫,且要是使用者一直不理那個視窗,callback 就永遠不會回來——非同步不保證會發生。

術語:callback-based API

callback

回呼函式

交給別人保管、在事情完成時由對方呼叫的函式。

這裡的「別人」是瀏覽器,不是你的程式:你把函式交出去就結束了,什麼時候被呼叫、被呼叫幾次都不由你決定。它跟一般函式呼叫最大的差別是控制權反轉——你不去拿結果,結果來找你。因為它是稍後才執行的,執行時原本那次呼叫的 execution context 早就從 call stack 上彈掉了(見第 2 段),這也是為什麼它必須自帶所需的變數,以及為什麼多層巢狀的 callback 會讓程式難讀(俗稱 callback hell),後面才有把它改寫成 promise 的動機。

相關術語: execution context (執行時已彈出)

出處:第 5 段「Callback 型 API:為什麼不能直接放回堆疊」

callback-based API

callback 型 API

呼叫時把「完成後要跑的函式」當參數傳進去的非同步 API,例如 getCurrentPosition、setTimeout。

辨認方式很直接:你傳函式進去,而它不回傳結果(回傳 undefined 或一個 id)。它是兩類非同步 web API 中比較早期的一種,錯誤處理靠另外傳一個 error callback。這一類 API 完成後走的回程路,決定了它跟另一類的行為差異——這正是後面兩個佇列要分開講的原因。

相關術語: callback (以其為參數)、Web API (一種)

出處:第 5 段「Callback 型 API:為什麼不能直接放回堆疊」

Geolocation API

地理位置 API

瀏覽器提供的取得使用者位置的介面,getCurrentPosition 是它最常用的方法。

作者選它當範例是因為它把非同步的「不確定性」放到最大:中間卡著一個使用者授權視窗,可能三秒、可能三分鐘、也可能永遠不按。它同時示範了 callback 型 API 的雙 callback 慣例(成功一個、失敗一個)。實務上要注意它需要 HTTPS 才能使用,而且使用者拒絕時走的是 error callback,不是拋例外。

相關術語: callback-based API (一種)

出處:第 5 段「Callback 型 API:為什麼不能直接放回堆疊」

留給下一段 資料已經回來、callback 卻懸在半空,call stack 也空著,畫面上兩個佇列還是空的。下一段必須回答:這個 callback 到底先放到哪裡,又是誰決定什麼時候把它搬上 call stack?

6. Task queue 與 event loop 的分工 4:04–4:39

所以 callback 改成被推進 task queue(也叫 callback queue),這裡排隊的是 web API 的 callback 與事件處理函式,等著稍後被執行。event loop 的職責就是盯著 call stack:一旦它空了,就從 task queue 取出第一個任務搬上 call stack 執行。使用者的位置就是在這一刻才被印出來。

event loop 把 task queue 第一個任務搬上空的 call stack,這張圖是整支影片的核心機制
4:29 · event loop 把 task queue 第一個任務搬上空的 call stack,這張圖是整支影片的核心機制
承上 承上:上一段停在「callback 不能直接推回 call stack」,留下的問題是它先放哪、誰來搬。這一段給的答案是兩個名字:task queue 負責放,event loop 負責搬。

推理因為上一段已經論證「插隊會破壞正在執行的任務」,所以作者接著給出唯一不破壞的做法:讓 callback 先進 task queue 排隊——她順帶解釋了它為什麼也叫 callback queue,因為裡面放的正是 web API 的 callback 與事件處理函式。有了排隊的地方,還缺一個判斷「現在可以搬了嗎」的角色,於是 event loop 終於登場,而它的職責被壓縮成一句非常小的話:檢查 call stack 是不是空的;空了就從 task queue 取第一個任務搬上去。使用者的位置就在這一刻才被印出來——這是整支影片第一次完整跑完一條非同步回程路。

AI 補充截圖把這句定義畫成三格:call stack 空、Web APIs 空、task queue 裡躺著一個 Task。整段最容易被高估的就是 event loop 本身——看完這張圖應該建立的印象是:它不執行任何程式碼、不排程、不決定優先順序,只是一個不停問「堆疊空了嗎」的迴圈,空了就搬一個過去。名氣那麼大的東西其實只有這麼一條規則,這也回應了第 1 段「它只是一個小零件」的開場。這裡要補兩個作者沒說、但實務上常撞到的細節。第一,「call stack 空了」指的是目前這一整支同步程式碼全部跑完,不是某個函式回傳——所以你不可能在函式中間讓 callback 插進來,這正是上一段 run-to-completion 保證的另一面。第二,佇列是先進先出的,所以兩個先後完成的非同步工作,callback 也會照完成順序執行;但「完成順序」由瀏覽器背景決定,不等於你呼叫的順序,兩個 fetch 誰先回來並無保證。

task queue

任務佇列

存放 web API callback 與事件處理函式、等待稍後被執行的先進先出佇列。

它也叫 callback queue,因為裡面裝的就是 callback;規格上的正式名稱是 task queue,一個 event loop 其實可以有多個 task queue(例如計時器、使用者互動各一個),瀏覽器可以在其中選擇,所以嚴格說並非全域單一佇列。要記住它的定位:東西進到這裡代表「已經準備好可以跑了,只是還沒輪到」,而不是「還在等結果」——還在等的東西留在 web API 那一格。

相關術語: task (存放)、event loop (被其取用)

出處:第 6 段「Task queue 與 event loop 的分工」

task

任務

排進 task queue 的一個工作單位,event loop 一次搬一個上 call stack 執行到完。

一個 task 的典型內容是:一次 setTimeout 的 callback、一次事件處理函式、或最初那支腳本的執行。關鍵在於它是不可切割的單位——一旦開始就會跑到 call stack 再次清空為止,中途不會被另一個 task 打斷。瀏覽器通常會在兩個 task 之間才有機會重新算繪畫面,所以「單一 task 不要太久」是流暢度的實務準則,這也把第 3 段的凍結問題量化了。

相關術語: task queue (位於其中)

出處:第 6 段「Task queue 與 event loop 的分工」

event loop

事件迴圈

不斷檢查 call stack 是否為空、空了就從佇列取出下一個任務搬上去執行的迴圈。

它是搬運工,不是執行者:所有程式碼還是在 call stack 上跑,event loop 只決定「下一個輪到誰」。它的規則小到可以一句話講完,難的是它跟兩個佇列的優先順序——目前只看到 task queue 這一條路,另一條還沒出現。名氣與體積的落差正是作者在第 1 段刻意先拉遠鏡頭的原因。順帶一提,Node.js 的 event loop 分成好幾個 phase(timers、poll、check…),規則比瀏覽器複雜,但「堆疊空了才搬」這個核心是一樣的。

相關術語: call stack (監看是否為空)、task queue (取用)

出處:第 6 段「Task queue 與 event loop 的分工」

event handler

事件處理函式

註冊給某個事件(click、scroll…)、事件發生時被呼叫的函式,完成後同樣排進 task queue。

作者把它跟 web API callback 並列,是因為兩者走同一條回程路:使用者點一下按鈕,瀏覽器不會插隊執行你的處理函式,而是把它排進 task queue 等 call stack 空。這解釋了一個常見現象——頁面卡住時你狂點按鈕沒反應,事件其實都排在佇列裡,等長任務結束後會一次全部觸發。

相關術語: task queue (排入)、callback (一種)

出處:第 6 段「Task queue 與 event loop 的分工」

留給下一段 這條回程路是用 geolocation 走通的,但 geolocation 只是眾多 callback 型 API 之一。下一段換上每個人天天在用的 setTimeout,用同一套規則檢驗它——而且會發現一個關於「延遲」的普遍誤解。

7. setTimeout:延遲是到佇列,不是到執行 4:39–6:22

另一個常見的 callback 型 API 是 setTimeout。它被推上 call stack 也只是把 callback 與延遲登記給 timers API,計時交給瀏覽器。時間到了,callback 進 task queue,call stack 空了才被搬上去執行。所以最關鍵的一句是:你指定的延遲,是到「進入 task queue」為止的延遲,不是到執行為止;如果 call stack 還很滿,callback 得繼續排隊。

100 毫秒到期 → callback 進 task queue → 搬上 call stack 的三步連續畫面,是理解延遲語意的依據
5:20 · 100 毫秒到期 → callback 進 task queue → 搬上 call stack 的三步連續畫面,是理解延遲語意的依據
承上 承上:上一段用 geolocation 走通了「callback → task queue → event loop → call stack」這條回程路,並留下「換一個更常見的 API 檢驗同一套規則」。這一段換上 setTimeout。

推理因為上一段的規則是用一個大家不常寫的 API 建立的,所以作者接著用最常見的 setTimeout 再跑一次,證明規則通用:setTimeout 被推上 call stack 也只是把 callback 與延遲登記給 timers API,計時交給瀏覽器,自己立刻彈出;兩個計時器都登記完,同步的 console.log 照常先印。時間一到,瀏覽器把 callback 丟進 task queue,call stack 空了才被搬上去。跑完這一輪,她才點出真正想講的那句話——你指定的延遲,是到「進入 task queue」為止的延遲,不是到執行為止。這句話之所以能成立,完全是靠前一段建立的規則推出來的,不是額外的新規定。

AI 補充截圖抓在最能說明問題的一格:console 已經印出「End of script!」,call stack 上是 100ms 的 callback,而 Web APIs 框裡 setTimeout 底下還留著 2000 的 delay 在倒數。注意兩件事:第一,程式碼裡 2000 那個 setTimeout 寫在前面,卻是 100 那個先執行——順序由「誰先到期」決定,不是誰先寫;第二,同步的 console.log 永遠贏過任何 setTimeout,就算延遲寫 0 也一樣。這裡補上作者省略、但實務常撞到的三點:其一,setTimeout(fn, 0) 不是立刻執行,它仍要走完整條回程路,而且 HTML 規格規定巢狀超過 5 層時延遲會被夾到至少 4 毫秒;其二,背景分頁的計時器會被瀏覽器降頻到一秒一次以省電;其三,「延遲到期」與「開始執行」之間隔了多久,取決於 task queue 前面排了什麼、以及 call stack 上正在跑什麼——所以第 3 段那個重運算長任務,會讓你所有的計時器一起遲到。這也說明了為什麼 setTimeout 不能拿來做精準計時。

setTimeout

延時執行

把一個 callback 與延遲時間登記給瀏覽器的計時器 web API,時間到才把 callback 排進 task queue。

整支影片最有價值的一句話就落在它身上:延遲指的是「多久後進入 task queue」,不是「多久後執行」。所以指定的延遲是下限而非保證,前面排隊的任務、正在跑的長任務都會讓它更晚。它回傳一個 id 供 clearTimeout 取消;相近的 setInterval 是同一套機制但會重複排入,若 callback 本身很慢就會擠在一起。要精準的動畫節奏該用 requestAnimationFrame,不是 setTimeout。

相關術語: timers API (由其處理)、task queue (到期後排入)

出處:第 7 段「setTimeout:延遲是到佇列,不是到執行」

timers API

計時器 API

瀏覽器負責倒數計時的那個元件,setTimeout 把 callback 與延遲交給它之後就彈出 call stack。

它就是第 4 段那扇門後面的其中一個服務:倒數這件事真的發生在瀏覽器那一側,不占 JavaScript 的執行線,所以計時期間頁面完全正常。時間到了它並不會直接執行你的 callback,只會把它排進 task queue——這個交接動作正是「延遲不等於執行時間」的機制原因。順帶一提,背景分頁時瀏覽器會刻意放慢它以省電。

相關術語: offloading (實作於)

出處:第 7 段「setTimeout:延遲是到佇列,不是到執行」

留給下一段 作者在這一段結尾自己收束了 callback 型的結論(web API 的 callback 在非同步任務完成時被推進 task queue),然後直接拋出問題:那 promise 型的呢?下一段要走的是另一條佇列。

8. Microtask queue 有優先權 6:22–8:40

promise 型 API 走的是另一條路:microtask queue,專門收 then/catch/finally 的 callback、await 之後的函式主體、queueMicrotask 與 MutationObserver 的 callback。event loop 會優先清空 microtask queue,全部搬完才碰 task queue,而且處理完 task queue 的每一個任務後又回頭檢查一次。作者用 fetch 走一遍:建立 pending 的 promise、登記 reaction、同步的 log 先印,資料回來才把 handler 推進 microtask queue。附帶陷阱:microtask 可以再排 microtask,無限下去就永遠輪不到 task queue,程式一樣凍結。

event loop 先清空 microtask queue 再看 task queue 的優先順序圖,是後面解題的依據
6:54 · event loop 先清空 microtask queue 再看 task queue 的優先順序圖,是後面解題的依據
promise 從 pending 變成 fulfilled、reaction handler 被推進 microtask queue 的那一格,把 promise 內部狀態和佇列連起來
7:50 · promise 從 pending 變成 fulfilled、reaction handler 被推進 microtask queue 的那一格,把 promise 內部狀態和佇列連起來
承上 承上:上一段結尾作者自己問了「那 promise 型的 API 呢?」這一段的答案是:它們不走 task queue,走另一條有優先權的佇列。

推理因為上一段已經把 callback 型的回程路走到底,所以作者接著補上另一條路,並且先給規則、後給示範。規則有兩條:第一,microtask queue 是專用的,只收 then/catch/finally 的 callback、await 之後的函式主體、queueMicrotask 與 MutationObserver 的 callback;第二,event loop 對它有優先權——call stack 一空,先把 microtask queue **整個清空**,才碰 task queue,而且每處理完 task queue 的一個任務又回頭再檢查一次。接著她用 fetch 走一遍驗證:呼叫 fetch 建立一個 pending 的 promise 並發動背景請求、.then 建立 promise reaction record、同步的 console.log 先印、伺服器回來才把 handler 推進 microtask queue。最後她補上這條優先權的代價:microtask 可以再排 microtask,無限下去就永遠輪不到 task queue,程式一樣凍結。

AI 補充兩張截圖分別對應規則與示範。第一張把優先權畫得很清楚:call stack 上正在跑一個 Task,task queue 裡還排著兩個 Task,而 microtask queue 是空的——順序不是「誰先排隊誰先跑」,而是「先看 microtask queue 有沒有東西」。第二張則把 promise 的內部狀態攤開給你看:[[PromiseState]] 是 fulfilled、[[PromiseResult]] 是 Response、[[PromiseFulfillReactions]] 裡裝著 res => console.log(res)。這張圖回答了一個 promise 教學常跳過的問題——.then 到底做了什麼?它不是「等結果」,而是把 handler 登記進 promise 物件的 reactions 清單;promise 一旦落定,才把清單裡的 handler 排進 microtask queue。所以 .then 是同步執行的,handler 才是非同步的。這裡補一個作者沒點破、但能把兩條規則接起來的觀點:優先權的設計目的是讓「同一件事情的後續處理」不要被使用者的點擊或計時器插隊,也就是讓 promise 鏈看起來像是連續發生的。代價就是她提到的餓死問題——因為規則是清空而不是取一個,一個會自我複製的 microtask 可以讓 task queue 永遠等不到,畫面也永遠不更新。順帶補一個順序上的常見考題:同時有 setTimeout(fn, 0) 和 Promise.resolve().then(fn),永遠是 promise 的先跑,理由就在這條優先權,不在延遲時間。

術語:microtask starvation

microtask queue

微任務佇列

專門存放 promise handler、await 後續、queueMicrotask 與 MutationObserver callback 的佇列,優先權高於 task queue。

跟 task queue 最關鍵的差別是清空方式:task queue 一次只搬一個,microtask queue 則要**整個清空**才罷休,連清空過程中新加入的也算在內。event loop 在每個 task 之間都會檢查它,所以它的執行時機永遠早於下一個 task。這條規則是後面那題唯一需要的依據,也是「promise 比 setTimeout 先跑」的真正原因。

相關術語: task queue (優先於)、microtask starvation (可能導致)

出處:第 8 段「Microtask queue 有優先權」

promise-based API

promise 型 API

呼叫後回傳一個 promise、完成時把 handler 排進 microtask queue 的非同步 API,例如 fetch。

辨認方式:它回傳值而不是收 callback,錯誤用 .catch 或 try/catch 接,而不是另一個 error callback。它跟 callback 型是同一套卸載機制(都由瀏覽器在背景做事),差別只在回程走哪條佇列——所以行為差異全部集中在執行順序上,不在能力上。現代 web API 幾乎都是 promise 型。

相關術語: callback-based API (對照組)、microtask queue (回程走)

出處:第 8 段「Microtask queue 有優先權」

fetch

網路請求 API

發出網路請求的 promise 型 web API,呼叫時立刻回傳一個 pending 的 promise 並讓瀏覽器在背景送出請求。

它是 promise 型的代表,也是第 4 段那張圖裡 Network Service 的入口。呼叫它時 call stack 上發生的事只有兩件:建立 promise 物件、發動背景請求,之後立刻彈出。要注意它回傳的第一個 promise 只代表「收到回應標頭」,讀取內容還要再 .then(res => res.json()),那是第二個 promise;另外 404、500 並不會讓它 reject,只有網路層失敗才會。

相關術語: promise-based API (一種)、promise reaction record (產生)

出處:第 8 段「Microtask queue 有優先權」

promise reaction record

promise 反應記錄

promise 物件內部存放 then/catch handler 的清單項目,promise 落定時這些 handler 才被排進 microtask queue。

它解釋了 .then 的真面目:.then 本身是同步執行的,做的事是把 handler 登記到 promise 的 [[PromiseFulfillReactions]] 裡,而不是坐在那裡等。落定之後,登記過的 handler 才一個個排進佇列。這也說明了兩件事:對一個已經 resolved 的 promise 呼叫 .then,handler 會立刻被排進 microtask queue(不是立刻執行);以及同一個 promise 可以掛多個 handler,它們會依登記順序排隊。

相關術語: microtask queue (落定後排入)

出處:第 8 段「Microtask queue 有優先權」

queueMicrotask

手動排入微任務

直接把一個函式排進 microtask queue 的 API,不需要包一層 promise。

在它出現以前,大家用 Promise.resolve().then(fn) 達成同樣效果,語意不清楚也多建了一個 promise 物件。它的用途是「等目前這一輪同步程式碼跑完、但要早於任何 task」,例如批次收集多次變更後只處理一次。因為它排的是 microtask,遞迴呼叫它會直接造成餓死,這也是下一段那題的陷阱所在。

相關術語: microtask queue (直接排入)

出處:第 8 段「Microtask queue 有優先權」

MutationObserver

DOM 變動觀察器

觀察 DOM 變動的 API,它的 callback 也走 microtask queue 而不是 task queue。

作者只是把它列進清單,但它的分類很能說明設計意圖:DOM 變動的後續處理屬於「這一輪的收尾」,必須早於使用者下一次互動或計時器,所以放 microtask 而非 task。它會把一批變動累積起來一次回報,而不是每改一個節點呼叫一次。

相關術語: microtask queue (回程走)

出處:第 8 段「Microtask queue 有優先權」

microtask starvation

微任務餓死

microtask 不斷排入新的 microtask,導致佇列永遠清不空、task queue 永遠輪不到,程式凍結。

它是優先權規則的代價:因為規則是「清空」而不是「取一個」,一個會自我複製的 microtask 就能無限延後所有 task。症狀跟第 3 段的長任務凍結一樣(畫面不更新、事件沒反應),但原因不同——那裡是單一函式跑太久,這裡是 event loop 一直在忙 microtask 出不來。最容易寫出來的形式是遞迴呼叫 queueMicrotask,或在 .then 裡再 resolve 同一條鏈。

相關術語: blocking (另一種形式)、microtask queue (源於)

出處:第 8 段「Microtask queue 有優先權」

確定錯誤/已過時 8:27
「I believe in node you can set like Mex tick depth or something like that which prevents this exact thing from happening」
作者自己也用「我記得好像」的語氣說的,但這個設定在現代 Node.js 並不存在。她指的應該是舊版的 process.maxTickDepth,它在 Node 0.12 就被移除了,而且當年管的是 process.nextTick 的遞迴深度,並不是 promise/queueMicrotask 的 microtask。現在的 Node.js 沒有任何內建開關可以擋住無限 microtask 迴圈,行為和瀏覽器一樣會卡死,只能靠自己不要寫出會自我複製的 microtask。
依據: process.maxTickDepth 於 Node.js 0.12(2015)移除;Node.js 目前文件中無對應設定
見仁見智 6:37
「so only those callbacks or those function body parts get pushed onto the microtask CU so it's very specific」
「只有這些」是為了教學而收緊的說法。她列的四項(then/catch/finally、await 之後、queueMicrotask、MutationObserver)涵蓋了日常九成情況,但規格上還有其他來源也會排入 microtask,例如 custom element 的 reaction callbacks、動態 import() 的完成、Atomics.waitAsync。把它記成「promise 相關與少數瀏覽器內部機制」比記成一份封閉清單安全。
依據: HTML Standard「queue a microtask」與 WHATWG custom element reactions
留給下一段 兩條路都走過了:callback 型進 task queue、promise 型進 microtask queue,而且後者有優先權。既然 promise 型明顯好用,下一段順手處理一個實務問題——手上那些舊的 callback 型 API 能不能改寫成 promise 型?

9. 把 callback 型 API 包成 promise 8:40–8:57

既然兩條路都走過一遍,作者順手示範把 callback 型 API promisify:用 new Promise 包住 getCurrentPosition,把 resolve 與 reject 當成 success 與 error callback 傳進去,程式碼可讀性會好一些。

承上 承上:上一段的結論是 promise 型走的佇列有優先權、寫法也更順,留下的實務問題是手上那些舊的 callback 型 API 怎麼辦。這一段就是答案。

推理因為兩條路都走過、也看出 promise 型的優點,所以作者順手示範把 callback 型 API promisify:用 new Promise 包住 getCurrentPosition,把建構子給的 resolve 與 reject 直接當成 success callback 與 error callback 傳進去。她強調這只是可讀性的改善——這個說法很誠實,因為底層機制完全沒變。

AI 補充這段短到容易被跳過,但它其實驗證了前面兩段建立的模型。包裝之後,底下跑的還是同一個 getCurrentPosition、同一個瀏覽器背景流程;唯一改變的是**結果的回程路**:原本 success callback 由 web API 直接排進 task queue,現在被排進 task queue 的是 resolve 這個動作,resolve 讓 promise 落定,落定才把你的 .then handler 排進 microtask queue。也就是說 promisify 之後多繞了一層,實際執行時機反而比原本晚一點點(多一次佇列跳轉),只是慢的量級小到無關緊要。要補充的是為什麼「可讀性」值得這一層:包成 promise 之後才能用 await 串起來、才能用 try/catch 接錯誤、才能丟進 Promise.all 一起等,而這些都是 callback 型 API 做不到的。另外提醒兩個包裝時的陷阱:new Promise 的執行器函式是**同步**執行的,所以裡面的錯誤要記得走 reject 而不是 throw;以及 resolve 只有第一次呼叫算數,之後再呼叫沒有作用。

術語:Promise constructor

promisify

promise 化

把 callback 型 API 包一層 new Promise,讓它可以用 then/await 的方式使用。

做法固定:new Promise((resolve, reject) => 原本的API(resolve, reject)),把 resolve 當成功 callback、reject 當失敗 callback。它換來的不只是好看——包成 promise 之後才能 await、才能 try/catch、才能用 Promise.all 併發等待。Node.js 內建 util.promisify 做同樣的事,前提是那個 API 遵守 error-first callback 慣例。注意包裝只改變介面,底層仍是同一個 web API 與同一個瀏覽器背景流程。

相關術語: callback-based API (包裝對象)、promise-based API (轉換成)

出處:第 9 段「把 callback 型 API 包成 promise」

Promise constructor

Promise 建構子

new Promise((resolve, reject) => {...}),用來自己建立一個 promise 並決定它何時落定。

傳進去的那個函式叫 executor,它是**同步**立刻執行的,不是非同步的——這點常被誤解。resolve 與 reject 是兩個開關,先按到的那個決定結果,之後再按都無效。只有在包裝非 promise 的東西(callback 型 API、事件)時才需要它;如果手上已經是 promise,再包一層是多餘的(俗稱 promise constructor anti-pattern)。

相關術語: promisify (用於)

出處:第 9 段「把 callback 型 API 包成 promise」

留給下一段 至此 call stack、web APIs、task queue、microtask queue、event loop 五個零件與它們的規則都齊了。下一段作者要用一道題,把這些規則放在同一支腳本裡同時檢驗。

10. 小測驗:為什麼答案是 51342 8:57–10:52

作者出一題:resolved promise 加 then、setTimeout、巢狀的 queueMicrotask,最後一行同步 console.log 5,問印出順序。答案是 5、1、3、4、2。理由完全來自前面的規則:同步的 5 先印;call stack 空了先清 microtask queue,所以 1、3 依序出來;第三層 queueMicrotask 排進來時 microtask queue 還沒清空,所以 4 也要先處理完;最後才輪到 task queue 裡的 setTimeout callback 印出 2。

題目的完整程式碼,聽眾要對著它自己推一次順序
9:08 · 題目的完整程式碼,聽眾要對著它自己推一次順序
巢狀 queueMicrotask 排進來、event loop 又回頭清空的那一刻,是這題唯一的陷阱
10:36 · 巢狀 queueMicrotask 排進來、event loop 又回頭清空的那一刻,是這題唯一的陷阱
承上 承上:上一段留下「規則都齊了,用一題同時檢驗」。這一題把 Promise.resolve().then、setTimeout、巢狀的 queueMicrotask 與同步的 console.log 放進同一支腳本。

推理因為前面每條規則都是分開建立的,所以作者接著設計一個把它們疊在一起的情境,逼讀者自己排序。答案是 5、1、3、4、2,而每一位數字都對應一條已經講過的規則:5 先印,因為同步程式碼永遠先跑完(第 2 段);1 接著,因為 promise 已經 resolved,handler 早在 microtask queue 裡,而 call stack 一空就先清 microtask queue(第 8 段);3 是同一輪 microtask queue 裡的下一個;4 是陷阱所在——它是在清空過程中才被排進來的 microtask,而規則是「整個清空」,所以它也算這一輪;2 最後,因為 setTimeout 的 callback 在 task queue,必須等 microtask queue 完全清空才輪到(第 8 段)。整題沒有用到任何新規則。

AI 補充兩張截圖剛好夾住這題的陷阱。第一張是題目本身:注意第 4 行的 setTimeout 延遲寫 10 毫秒,而整支腳本跑完根本不用 1 毫秒——所以 2 排在最後,跟延遲長短其實沒有關係,就算寫 0 也一樣。第二張是關鍵那一格:console 已有 5、1、3,call stack 上是 console.log(4),而 task queue 裡那個 console.log(2) 還躺著沒動。這一格證明的正是「清空」與「取一個」的差別——第 8 行那個巢狀 queueMicrotask 是在 microtask queue 正在被清空的過程中才排進來的,如果規則是每輪取一個,4 就會排到 2 後面。這裡補一個作者沒有明說的觀念:event loop 在每個 task 之間執行的這次「把 microtask queue 徹底清空」的動作,規格上叫 microtask checkpoint,它才是這一題的判準。實務上推這類題目有個穩定的順序:先把同步碼一路跑完(5),再清空 microtask(1、3、4,包含中途新增的),最後才拿一個 task(2),拿完再回頭做一次 checkpoint。作者說「先暫停影片自己想」是有道理的——能自己推出 4 在 2 前面,代表你真的接受了優先權規則,而不是背下順序。

microtask checkpoint

微任務檢查點

event loop 在每個 task 前後執行的動作:把 microtask queue 徹底清空(含清空過程中新增的)才繼續。

它是規格對第 8 段那條優先權規則的正式名稱,也是這一題的判準:因為是 checkpoint 而不是「取一個」,巢狀排進來的 4 才會插在 2 前面。checkpoint 發生在每個 task 結束後、下一個 task 開始前,也發生在整支腳本跑完之後——所以同步碼結束就是第一個 checkpoint。理解它之後,任何執行順序題都能用同一個流程推:同步 → checkpoint → 一個 task → checkpoint → …

相關術語: microtask queue (清空對象)、event loop (執行於)

出處:第 10 段「小測驗:為什麼答案是 51342」

留給下一段 推導在真實情境下成立了。剩下的只差把這一路走過的零件與規則,用一段話重新串成一條完整的鏈。

11. 整條路線重講一次 10:52–11:58

收尾的複習把整條鏈串起來:JavaScript 單執行緒、一次一個任務;web APIs 讓我們用瀏覽器的能力,其中一些能把非同步任務丟到背景;發起呼叫仍會上 call stack,但只是交接,真正的工作在背景跑,不擋堆疊;callback 型 API 完成後把 callback 排進 task queue;promise 相關的則進 microtask queue,它有優先權,而且每處理完一個 task 就再檢查一次。

承上 承上:上一段的題目證明了規則在真實情境下成立,留下的最後一件事是把整條鏈收攏成一段話。這一段就是作者自己做的 recap。

推理因為前面是一段一段推上來的,所以作者接著把同一條鏈倒過來快速重述一次,讓讀者檢查自己的模型有沒有缺角:JavaScript 單執行緒、一次一個任務;web APIs 讓我們用瀏覽器的能力,其中一些能把非同步任務丟到背景;發起呼叫仍會上 call stack,但只是交接,真正的工作在背景跑、不擋堆疊;callback 型 API 完成後把 callback 排進 task queue;promise 相關的則進 microtask queue,它有優先權,而且每處理完一個 task 就再檢查一次。這五句話的順序就是第 2 段到第 8 段的順序——她刻意讓 recap 的結構和推導的結構一致。

AI 補充值得注意的是這段 recap 沒有新增任何規則,但它換了一個組織方式:前面是按「問題 → 解法」推導的,這裡是按「零件 → 職責」列的。這兩種視角都要有——推導的版本讓你知道為什麼需要這些機制,零件的版本讓你在除錯時能快速定位。實務上可以把它濃縮成一個判斷流程:先問這段程式碼是同步還是非同步(同步就現在跑完);非同步的話問它是誰完成的(web API 在背景做);完成後問它進哪個佇列(callback 型進 task queue、promise 相關進 microtask queue);最後問 call stack 空了沒、microtask queue 清空了沒。用這四問就能解釋幾乎所有「為什麼這行比那行晚印」的疑惑,也正是作者說「知道規則就能解釋程式為何在那個時間點執行」的意思。

留給下一段 規則與零件都收攏完了,唯一還沒解決的是最後一哩:怎麼把這套規則從「聽得懂」變成「用得出來」。

12. 自己動手試才會真的懂 11:58–12:34

作者最後鼓勵:非同步 JavaScript 常常讓人挫折,但只要知道每個佇列的規則,就能解釋程式為什麼在那個時間點執行。建議自己拿 setTimeout 和 queueMicrotask 玩玩看,親手驗證順序。

承上 承上:上一段把零件與職責收攏成一套可用的判斷流程,留下的最後一哩是怎麼讓它變成自己的直覺。作者的答案是動手。

推理因為前面所有內容都是靠「預測順序 → 驗證順序」建立起來的,所以作者最後給的建議也一致:自己拿 setTimeout 和 queueMicrotask 排列組合玩玩看,先猜再跑,看結果對不對。她把這件事講得很實際——非同步 JavaScript 常常讓人挫折,但那份挫折來自「不知道規則」,而不是「這件事很難」;規則就那幾條,知道之後每個執行時機都解釋得通。

AI 補充把這個建議變成可操作的練習,最有效的順序是照著推導的難度爬:先寫一支只有 console.log 和一個 setTimeout(fn, 0) 的腳本,確認同步永遠贏;再加一個 Promise.resolve().then,確認 microtask 贏過 task,且跟延遲寫多少無關;再把 .then 換成巢狀的 queueMicrotask,重現第 10 段那個「4 插在 2 前面」的結果;最後刻意寫一個遞迴的 queueMicrotask,親眼看到頁面卡死,就會記住那條優先權規則的代價。每次都先在紙上寫下預測再執行,錯的那一題才是你模型缺角的地方。瀏覽器開發者工具的 Performance 面板可以把這些 task 與 microtask 畫成時間軸,是驗證的好幫手。要留意的是 Node.js 多了 process.nextTick 與好幾個 phase,順序規則比瀏覽器複雜,練習時建議先固定在瀏覽器環境,模型穩了再看 Node.js 的差異。

留給下一段 總結收束。

4. 總結

整支影片是一條單線推導。作者先把 event loop 放回 runtime 全景裡,指出真正執行你的程式碼的只有 call stack;接著示範單一 call stack 如何用推入與彈出決定執行順序。這個模型有個沒說出口的前提:每個 execution context 都會很快彈出去。作者故意放一個重運算進去,整個程式凍結,於是問題成形:遇到網路請求、計時器這類必然很慢的工作怎麼辦。答案是 web APIs——瀏覽器提供的介面,其中一部分能把長任務卸載到背景,呼叫它只是登記與交接,登記完立刻彈出堆疊。但資料回來之後,callback 不能直接插回 call stack,否則會打斷正在跑的任務,於是需要一個緩衝區:task queue,以及一個負責搬運的角色:event loop,規則是 call stack 空了才搬。setTimeout 讓這條規則產生一個常見誤解——你指定的延遲,是到進入 task queue 為止的延遲,不是到執行為止。promise 型 API 則走另一條線:microtask queue,它有優先權,event loop 會先把它完全清空才碰 task queue,而且處理完每個 task 後再檢查一次;代價是 microtask 可以無限自我排隊,把程式餓死。最後那道測驗答案 51342,每一位數字都只由前面推導出的規則決定:同步先跑、microtask 全清、才輪到 task。

勘誤總整理

確定錯誤/已過時見仁見智(取決於版本或情境)

段落原話(transcript 逐字)說明
8. Microtask queue 有優先權
8:27
「I believe in node you can set like Mex tick depth or something like that which prevents this exact thing from happening」作者自己也用「我記得好像」的語氣說的,但這個設定在現代 Node.js 並不存在。她指的應該是舊版的 process.maxTickDepth,它在 Node 0.12 就被移除了,而且當年管的是 process.nextTick 的遞迴深度,並不是 promise/queueMicrotask 的 microtask。現在的 Node.js 沒有任何內建開關可以擋住無限 microtask 迴圈,行為和瀏覽器一樣會卡死,只能靠自己不要寫出會自我複製的 microtask。
依據: process.maxTickDepth 於 Node.js 0.12(2015)移除;Node.js 目前文件中無對應設定
8. Microtask queue 有優先權
6:37
「so only those callbacks or those function body parts get pushed onto the microtask CU so it's very specific」「只有這些」是為了教學而收緊的說法。她列的四項(then/catch/finally、await 之後、queueMicrotask、MutationObserver)涵蓋了日常九成情況,但規格上還有其他來源也會排入 microtask,例如 custom element 的 reaction callbacks、動態 import() 的完成、Atomics.waitAsync。把它記成「promise 相關與少數瀏覽器內部機制」比記成一份封閉清單安全。
依據: HTML Standard「queue a microtask」與 WHATWG custom element reactions

5. 推薦三個下一步

1. 往下挖深:event loop 在 Node.js 裡的樣子

影片講的是瀏覽器的模型,只順口提了一句 Node 的行為(而且那句已經過時)。Node 的 event loop 分成 timers、poll、check 等階段,還多了 process.nextTick 與 setImmediate,同一套規則在那裡有不同的細節。

YouTube 搜尋:Node.js event loop phases process.nextTick vs setImmediate libuv event loop explained

2. 往旁邊對照:渲染與 event loop 的關係

影片只講程式碼何時執行,沒講畫面何時更新。requestAnimationFrame 與瀏覽器的 rendering step 卡在 task 與 microtask 之間,補上這一塊才知道為什麼有些程式碼會造成掉幀。

YouTube 搜尋:requestAnimationFrame event loop browser rendering pipeline event loop Jake Archibald in the loop

3. 往上應用:用這套模型解真實的效能問題

知道規則之後,下一步是拿它去讀 Performance 面板上的 long task 與 INP,判斷該把工作切碎、丟進 web worker,還是改用 scheduler API。

YouTube 搜尋:long tasks main thread blocking web workers javascript performance scheduler.yield INP optimization

📄 全部影片 · 主題區: JavaScript 執行模型