Day 6:JavaScript 程式碼執行排序:遞迴函數、Call Stack、Task Queue

Call Stack;遞迴函數與 call stack 的關係;Task Queue(非同步的概念再釐清)

Call Stack

試著理解遞迴函數運作方式的過程,感覺需要瞭解 JS 如何排序要執行的指令,聯想到 Day1-3 使用 Vitest readline 的時候讀到的非同步和任務排序的概念

查閱文件和科普說明,發現一樣是任務排序但是不同情境的東西:

Play Video
Video
What the heck is the event loop anyway? | Philip Roberts | JSConf EU

JavaScript's call stack/event loop/task queue 互動關係的視覺化操作界面: loupe

這個視覺化工具對於理解瀏覽器如何執行被 JavaScript 程式碼定義的行為很有幫助

規劃好程式佇列和任務執行順序,才能有流暢的使用者體驗(不卡頓)

  • JavaScript 一次只能做一件事(一個程式碼片段),瀏覽器有提供 WebAPI ,透過搭配 event loop 的方式同時處理多個任務

  • JavaScript 會在 call stack 中依序堆疊要執行的程式碼片段,越往後被呼叫的 function 被堆疊在越上方,會被優先執行並從堆疊中清除。在堆疊中的單一程式碼片段如果執行時間太久,整個流程會全部卡住,此時瀏覽器無法執行任何動作或反應/渲染,稱為 blocking 阻塞。

  • task queue 會依序排列 WebAPI 處理好的任務,等候排入 call stack 執行。排列順序會按照 Macro/micro task 判斷。

  • event loop 的作用是去監控堆疊(call stack)和工作佇列(task queue),當堆疊已經完全清空、沒有執行項目的時候,便把佇列中的內容拉到堆疊中去執行。

參考文件

遞迴函數和 Call Stack

利用 function 內自我呼叫的方式堆疊 call stack 後再回收,以 MDN 官方文件舉例的程式碼來看:

function foo(i) {
  if (i < 0) {
    return;
  }
  console.log(`begin: ${i}`);
  foo(i - 1);
  console.log(`end: ${i}`);
}
foo(3);

步驟

呼叫(目前執行的那一層)

call stack(頂端在上)

console.log 結果

遞迴呼叫動作

1

foo(3)

[foo(3)]<br>[global]

begin: 3

呼叫 foo(2)

2

foo(2)

[foo(2)]<br>[foo(3)]<br>[global]

begin: 2

呼叫 foo(1)

3

foo(1)

[foo(1)]<br>[foo(2)]<br>[foo(3)]<br>[global]

begin: 1

呼叫 foo(0)

4

foo(0)

[foo(0)]<br>[foo(1)]<br>[foo(2)]<br>[foo(3)]<br>[global]

begin: 0

呼叫 foo(-1)

5

foo(-1)ifreturn

[foo(-1)]<br>[foo(0)]<br>[foo(1)]<br>[foo(2)]<br>[foo(3)]<br>[global]foo(-1) 結束後彈出

無輸出

無(直接 return)

6

回到 foo(0),執行遞迴後面的程式

[foo(0)]<br>[foo(1)]<br>[foo(2)]<br>[foo(3)]<br>[global]

end: 0

無(這層遞迴已呼叫過)

7

foo(0) 執行完,return

foo(0) 從堆疊彈出 → 剩:<br>[foo(1)]<br>[foo(2)]<br>[foo(3)]<br>[global]

8

回到 foo(1),執行遞迴後面的程式

[foo(1)]<br>[foo(2)]<br>[foo(3)]<br>[global]

end: 1

9

foo(1) 執行完,return

foo(1) 彈出 → 剩:<br>[foo(2)]<br>[foo(3)]<br>[global]

10

回到 foo(2),執行遞迴後面的程式

[foo(2)]<br>[foo(3)]<br>[global]

end: 2

11

foo(2) 執行完,return

foo(2) 彈出 → 剩:<br>[foo(3)]<br>[global]

12

回到 foo(3),執行遞迴後面的程式

[foo(3)]<br>[global]

end: 3

13

foo(3) 執行完,return

foo(3) 彈出 → 剩:<br>[global]

最終輸出順序:

  • begin: 3

  • begin: 2

  • begin: 1

  • begin: 0

  • end: 0

  • end: 1

  • end: 2

  • end: 3

Task Queue

現代說法通常會分成「task(或 macrotask)queue」和「microtask queue」,是兩個不同的佇列。

整個 JavaScript 程式碼執行順序(就是 event loop 主要在做的事)大致是:

  1. 如果 call stack 不為空,就把目前的任務執行到結束(同步程式碼、或某個已經放進 stack 的 task/microtask)。

  2. 當 call stack 清空時:

    • 先把 microtask queue 清到空(途中新增的 microtask 也要優先處理完)。

    • 然後從 task(macrotask)queue 取出最舊的一個任務,放進 call stack 執行。

  3. 在一輪 task 執行完、microtask queue 清空後,瀏覽器才有機會做一次畫面渲染。

目前有練習到的 microtask:async/await (Promise 語法糖)

Day1-3 練習 Vitest 有用到 async/await 處理終端機輸入。當程式執行到 await somePromise 時:

async function main() {
  const input = await question('請輸入數字:');
  // ... 後面的處理
}
  • 先「暫停」目前這個 async 函式,讓它後面的程式碼不要繼續往下跑。

  • 等 somePromise 被 resolve/reject 之後(如使用者輸入數字後),恢復這個 async 函式的後半段執行;這個「恢復動作」會被排進 microtask queue。

其他 microtask handler 和原始的 Promise 寫法還沒練習到。

參考文件來源

簡單 Recap:從遞迴函數開始理解 JavaScript 執行規則

  • 起點:想理解遞迴函數怎麼執行(foo(3) → 2 → 1 → 0 → -1,再一層層往回印出結果)。

  • 發現:遞迴本身只用到 call stack 的同步行為(先進後出),跟非同步任務排序無關——整段遞迴從頭到尾都在同一個 task 裡一次跑完。

  • 延伸:這引導到理解「JS 單執行緒 + event loop」的完整機制:

    • call stack:存放正在執行的函式,一次只能處理一個任務。

    • task queue(macrotask):存放 setTimeout、DOM 事件等非同步任務。

    • microtask queue:存放 Promise handler、async/await 後半段等微任務。

    • event loop 規則:stack 清空後,先清空 microtask queue,再從 task queue 取下一個任務。

  • 結論:遞迴 = call stack 堆疊與彈出;非同步 = event loop 在不同佇列間排程。兩者分別對應「單一任務內的執行流程」與「任務之間的排程順序」。

這樣整個程式碼執行排序規則大致上就理解了。