課程:JavaScript 與 React 底層原理 第 2 堂:Hoisting 到 Closure
06 追蹤執行脈絡練習
在學習了執行環境(EC)、Call Stack、Scope Chain 以及 Hoisting 之後,你可能會覺得大腦裝滿了破碎的零件。現在,是時候把它們組裝起來了。想像一下,如果你是 V8 引擎,當一段程式碼被丟進來時,你的大腦會如何跳轉?為什麼有些變數在還沒宣告時就能拿,有些卻會讓你的程式直接崩潰?
這一個部分不只是複習,更是要讓你建立起「直覺」。當你在開發 React 元件遇到奇怪的 undefined 或 ReferenceError 時,你能瞬間在腦中畫出那張執行脈絡圖。
挑戰題目:這段程式碼會輸出什麼?
請先不要執行這段程式碼,試著用你的直覺(或剛剛學到的理論)預測一下控制台會印出什麼。這段程式碼故意混合了 var、let、函數宣告、區塊作用域以及同名變數的遮蔽(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)。這時候程式碼一行都還沒跑,引擎只是在掃描:
- Variable Environment (VE): 掃描到
var user和function initializeApp。
user被初始化為undefined(這是var的 Hoisting)。initializeApp指向完整的函數物件(這是函數宣告的 Hoisting)。
- Lexical Environment (LE): 目前全域沒有
let或const,所以這裡是空的。 - Outer Reference: 為
null,因為這已經是最外層了。
此時的記憶體快照:
Global_VE: { user: undefined, initializeApp: <func> }Global_LE: {}Call Stack: [ GEC ]
Step 2:全域執行與函數呼叫
現在開始跑程式碼:
- 第 1 行:
user = "Global Guest",把Global_VE裡的user從undefined改成"Global Guest"。 - 第 21 行:
initializeApp()被呼叫。
這時候,新的 Function Execution Context (FEC) 被建立並推入 Call Stack。
Call Stack 現狀:
initializeApp FEC(最頂端,正在執行)GEC
Step 3:initializeApp 的建立階段
在進入 initializeApp 內部執行前,引擎會再次進行掃描:
- Variable Environment (VE):
- 它看到了
if區塊裡的var user = "Admin"。 - 關鍵點:
var不具備區塊作用域,它會被提升到最近的「函數頂端」。所以initializeApp的 VE 裡會出現一個user,初始值為undefined。 - 這導致了遮蔽效應(Shadowing):當你在函數內存取
user時,會先找到這裡的undefined,而不會去全域找"Global Guest"。
- Lexical Environment (LE):
- 它掃描到了
function displayStatus。函數宣告被提升並賦值。 - 注意:
let version是在if區塊內。在initializeApp的建立階段,這個version不會出現在函數層級的 LE 中,而是屬於之後if區塊自己的 LE。
- Outer Reference: 指向
GEC。
此時 **initializeApp** 的記憶體:
VE: { user: undefined }LE: { displayStatus: <func> }
Step 4:逐行追蹤函數執行
現在我們一行行看 initializeApp 做了什麼:
- 第 4 行:
console.log("1:", user);- 引擎查找目前的 VE,發現
user是undefined。
- 引擎查找目前的 VE,發現
- 輸出 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:displayStatus的 LE/VE?(沒有)
initializeApp的 LE/VE?(沒有! 因為version是定義在if區塊的 LE 裡,不是函數層級的 LE)。- 發生錯誤!
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 要安全得多,因為它能幫你提早發現潛在的邏輯問題。
執行狀態總結表
讓我們整理一下當程式執行到特定行號時,記憶體中變數的真實狀態:
| 行號 | 存取的變數 | 所屬作用域 | 狀態 / 值 | 原因說明 |
|---|---|---|---|---|
| 4 | user | initializeApp (VE) | undefined | var 提升至函數頂端,遮蔽了全域變數 |
| 7 | user | initializeApp (VE) | "Admin" | 賦值操作更新了函數作用域內的變數 |
| 8 | version | if Block (LE) | "2.0.1" | let 賦值完成,離開 TDZ |
| 12 | user | initializeApp (VE) | "Admin" | var 的修改在區塊外依然有效 |
| 15 | version | Scope Chain | ReferenceError | displayStatus 的外部鏈不包含 if 區塊作用域 |
| 22 | user | Global (VE) | "Global Guest" | 函數內部的 var 不會影響全域變數 |
檢查清單:當你面對一段複雜程式碼時...
- 找邊界:哪些是函數?哪些是
{}區塊? - 抓 Hoisting:
var:丟到最近的函數頂端,設為undefined。function:丟到最近的函數頂端,設為整個函數。let/const:留在原地,但在宣告前都是 TDZ 禁區。
- 畫 Outer Reference:每個函數的外部參考,指向它「出生」的地方(Lexical Scope),而不是「呼叫」的地方。
重點回顧:從執行脈絡到 React
為什麼我們要花這麼多時間追蹤這些變數?在 React 開發中,這關乎到你對資料流的掌控:
- Hoisting 與初期化:當你看到 React 元件報錯說某個值是
undefined時,檢查一下是否因為你在useEffect裡存取了尚未被 Hoisting 邏輯正確賦值的變數。 - 作用域遮蔽(Shadowing):在 React 元件內部,如果不小心宣告了跟 Props 同名的變數(例如
const { user } = props;後又在裡面寫var user = ...),就會發生跟上面練習一樣的慘劇,導致你永遠拿到undefined。 - 區塊作用域:
let和const讓我們的狀態更新更可預測,不會像var那樣隨意滲透出if或for迴圈。
掌握了執行脈絡後,你有沒有發現一個神奇的現象?有些函數(如 displayStatus)即使被呼叫時,它依然能「記得」它出生時周圍的變數。即使外部函數 initializeApp 執行完了,那些變數好像還被鎖在某個神祕的保險箱裡。
這不是魔法,這就是我們下一個主題要探討的 React Hooks 靈魂核心——Closure(閉包)。接下來,我們將解開函數如何「捕捉」環境的秘密。