跳至主要内容

課程:JavaScript 與 React 底層原理 第 2 堂:Hoisting 到 Closure

06 追蹤執行脈絡練習

在學習了執行環境(EC)、Call Stack、Scope Chain 以及 Hoisting 之後,你可能會覺得大腦裝滿了破碎的零件。現在,是時候把它們組裝起來了。想像一下,如果你是 V8 引擎,當一段程式碼被丟進來時,你的大腦會如何跳轉?為什麼有些變數在還沒宣告時就能拿,有些卻會讓你的程式直接崩潰?

這一個部分不只是複習,更是要讓你建立起「直覺」。當你在開發 React 元件遇到奇怪的 undefinedReferenceError 時,你能瞬間在腦中畫出那張執行脈絡圖。

挑戰題目:這段程式碼會輸出什麼?

請先不要執行這段程式碼,試著用你的直覺(或剛剛學到的理論)預測一下控制台會印出什麼。這段程式碼故意混合了 varlet、函數宣告、區塊作用域以及同名變數的遮蔽(Shadowing)。

var user = "Global Guest";

function initializeApp() {
console.log("1:", user); // 會印出什麼?

if (true) {
var user = "Admin";
let version = "2.0.1";
console.log("2:", user);
}

console.log("3:", user);

function displayStatus() {
// 這裡試圖存取 version
console.log("4:", version);
}

// console.log("5:", version); // 如果把這行註解拿掉,會發生什麼事?
displayStatus();
}

initializeApp();
console.log("6:", user);

在你往下閱讀前,請在紙上寫下你的答案:

1: undefined

2: Admin

3: undefined 4: undefined 6: Global Guest


模擬 JS 引擎:逐步拆解執行脈絡

要精準預測輸出,我們必須像 JS 引擎一樣,把執行過程拆分為「建立階段(Creation Phase)」與「執行階段(Execution Phase)」。

Step 1:全域建立階段(Global Creation Phase)

當這段腳本載入時,引擎會先建立 Global Execution Context (GEC)。這時候程式碼一行都還沒跑,引擎只是在掃描:

  1. Variable Environment (VE): 掃描到 var userfunction initializeApp
  • user 被初始化為 undefined(這是 var 的 Hoisting)。
  • initializeApp 指向完整的函數物件(這是函數宣告的 Hoisting)。
  1. Lexical Environment (LE): 目前全域沒有 letconst,所以這裡是空的。
  2. Outer Reference: 為 null,因為這已經是最外層了。

此時的記憶體快照:

  • Global_VE: { user: undefined, initializeApp: <func> }
  • Global_LE: {}
  • Call Stack: [ GEC ]

Step 2:全域執行與函數呼叫

現在開始跑程式碼:

  • 第 1 行:user = "Global Guest",把 Global_VE 裡的 userundefined 改成 "Global Guest"
  • 第 21 行:initializeApp() 被呼叫。

這時候,新的 Function Execution Context (FEC) 被建立並推入 Call Stack。

Call Stack 現狀:

  1. initializeApp FEC (最頂端,正在執行)
  2. GEC

Step 3:initializeApp 的建立階段

在進入 initializeApp 內部執行前,引擎會再次進行掃描:

  1. Variable Environment (VE):
  • 它看到了 if 區塊裡的 var user = "Admin"
  • 關鍵點var 不具備區塊作用域,它會被提升到最近的「函數頂端」。所以 initializeApp 的 VE 裡會出現一個 user,初始值為 undefined
  • 這導致了遮蔽效應(Shadowing):當你在函數內存取 user 時,會先找到這裡的 undefined,而不會去全域找 "Global Guest"
  1. Lexical Environment (LE):
  • 它掃描到了 function displayStatus。函數宣告被提升並賦值。
  • 注意:let version 是在 if 區塊內。在 initializeApp 的建立階段,這個 version 不會出現在函數層級的 LE 中,而是屬於之後 if 區塊自己的 LE。
  1. Outer Reference: 指向 GEC

此時 **initializeApp** 的記憶體:

  • VE: { user: undefined }
  • LE: { displayStatus: <func> }

Step 4:逐行追蹤函數執行

現在我們一行行看 initializeApp 做了什麼:

  • 第 4 行console.log("1:", user);
    • 引擎查找目前的 VE,發現 userundefined
  • 輸出 1: undefined。(如果你猜 Global Guest,就掉入陷阱了!這是因為 var 的提升蓋過了全域變數)。
  • 第 6-10 行:**if (true)**** 區塊執行**
    • 當進入 if 區塊,JS 引擎會為這個區塊建立一個額外的 Block Lexical Environment
  • 裡面放入了 let version。此時 version 處於 TDZ(暫時性死區)
  • 第 7 行:user = "Admin"。注意,這個 user 是改動 initializeApp 的 VE 裡的那個 user
  • 第 8 行:let version = "2.0.1"version 離開 TDZ,被賦值。
  • 第 9 行:console.log("2:", user); -> 輸出 2: Admin
  • 第 12 行console.log("3:", user);
    • 因為 var 是函數作用域,if 結束後,user 的值依然是 "Admin"
  • 輸出 3: Admin
  • **第 14-17 行:執行 ****displayStatus()**
    • 呼叫 displayStatus,建立新的 FEC。
  • 關鍵點:**displayStatus**** 的 Outer Reference 是誰?**
  • 函數的 Outer Reference 是在「定義時」決定的(Lexical Scope)。它被定義在 initializeApp 裡面。
  • 當執行到 console.log("4:", version) 時,引擎會尋找 version
    1. displayStatus 的 LE/VE?(沒有)
  1. initializeApp 的 LE/VE?(沒有! 因為 version 是定義在 if 區塊的 LE 裡,不是函數層級的 LE)。
  2. 發生錯誤! ReferenceError: version is not defined

關鍵轉折點分析:魔鬼藏在細節裡

1. 為什麼 version 存取不到?

這是在練習中最容易出錯的地方。讓我們仔細看作用域的巢狀結構:

  • GEC
    • initializeApp FEC
    • if Block LE (這裡才有 version)
  • displayStatus FEC (它的外部參考是 initializeApp FEC)

displayStatus 執行時,它只能看到 initializeApp 函數層級的變數。它看不到 if 區塊內部的 let 變數。這就是 let 提供的「區塊保護機制」。

2. TDZ 的影響

如果在第 8 行之前就執行 console.log(version),會發生什麼事?

if (true) {
console.log(version); // ReferenceError: Cannot access 'version' before initialization
let version = "2.0.1";
}

雖然引擎在進入 if 區塊的建立階段就知道有 version 這個變數,但它被標記為「未初始化」。在 let 語句執行之前,任何存取動作都會觸發 TDZ 錯誤。這比 var 給你一個 undefined 要安全得多,因為它能幫你提早發現潛在的邏輯問題。


執行狀態總結表

讓我們整理一下當程式執行到特定行號時,記憶體中變數的真實狀態:

行號存取的變數所屬作用域狀態 / 值原因說明
4userinitializeApp (VE)undefinedvar 提升至函數頂端,遮蔽了全域變數
7userinitializeApp (VE)"Admin"賦值操作更新了函數作用域內的變數
8versionif Block (LE)"2.0.1"let 賦值完成,離開 TDZ
12userinitializeApp (VE)"Admin"var 的修改在區塊外依然有效
15versionScope ChainReferenceErrordisplayStatus 的外部鏈不包含 if 區塊作用域
22userGlobal (VE)"Global Guest"函數內部的 var 不會影響全域變數

檢查清單:當你面對一段複雜程式碼時...

  1. 找邊界:哪些是函數?哪些是 {} 區塊?
  2. 抓 Hoisting
  • var:丟到最近的函數頂端,設為 undefined
  • function:丟到最近的函數頂端,設為整個函數。
  • let/const:留在原地,但在宣告前都是 TDZ 禁區。
  1. 畫 Outer Reference:每個函數的外部參考,指向它「出生」的地方(Lexical Scope),而不是「呼叫」的地方。

重點回顧:從執行脈絡到 React

為什麼我們要花這麼多時間追蹤這些變數?在 React 開發中,這關乎到你對資料流的掌控:

  • Hoisting 與初期化:當你看到 React 元件報錯說某個值是 undefined 時,檢查一下是否因為你在 useEffect 裡存取了尚未被 Hoisting 邏輯正確賦值的變數。
  • 作用域遮蔽(Shadowing):在 React 元件內部,如果不小心宣告了跟 Props 同名的變數(例如 const { user } = props; 後又在裡面寫 var user = ...),就會發生跟上面練習一樣的慘劇,導致你永遠拿到 undefined
  • 區塊作用域letconst 讓我們的狀態更新更可預測,不會像 var 那樣隨意滲透出 iffor 迴圈。

掌握了執行脈絡後,你有沒有發現一個神奇的現象?有些函數(如 displayStatus)即使被呼叫時,它依然能「記得」它出生時周圍的變數。即使外部函數 initializeApp 執行完了,那些變數好像還被鎖在某個神祕的保險箱裡。

這不是魔法,這就是我們下一個主題要探討的 React Hooks 靈魂核心——Closure(閉包)。接下來,我們將解開函數如何「捕捉」環境的秘密。