課程:JavaScript 與 React 底層原理 第 1 堂:JS 執行模型基礎
03 Scope 與 Scope Chain
在上一節中,我們觀察到 JavaScript 引擎如何透過 Call Stack 來管理函式的執行順序。每當一個函式被呼叫,一個新的執行環境(Execution Context, EC)就會被推入堆疊。然而,這裡有一個隱藏的關鍵問題:當程式碼在執行環境中運行並遇到一個變數時,它如何知道這個變數代表什麼?
如果我們在不同的地方定義了同名的變數,JavaScript 會選擇哪一個?這種「尋找變數」的規則,就是我們這一節要探討的核心:Scope(作用域) 與 Scope Chain(作用域鏈)。
誰決定了變數的歸屬?
讓我們從一個簡單的「直覺測試」開始。請預測以下程式碼會印出什麼:
const name = "全域小強";
function second() {
console.log(name);
}
function first() {
const name = "區域小明";
second();
}
first();
你認為結果會是「區域小明」還是「全域小強」?
如果你直覺認為 second() 是在 first() 裡面被呼叫的,而 first() 裡面正好有一個 name 變數,所以應該印出「區域小明」,那麼你可能掉進了「動態作用域(Dynamic Scope)」的陷阱。
正確答案是:「全域小強」。
這個結果揭示了 JavaScript 最重要的一個特性:詞法作用域(Lexical Scope)。
詞法作用域:位置決定一切
「詞法(Lexical)」這個詞在程式語言中通常指「撰寫時」或「編譯時」。
詞法作用域(Lexical Scope) 指的是:變數的作用域是在你寫下程式碼的那一刻,根據函式定義在原始碼中的物理位置來決定的。它與函式「在哪裡被呼叫」完全無關,只與函式「在哪裡被定義」有關。
在上面的例子中:
second函式是在全域環境中被定義的。- 對於
second來說,它的「外面」就是全域環境。 - 即使它被
first呼叫,它的靈魂(作用域)依然與全域綁定。
這種設計讓程式碼的行為變得極其可預測。只要你看著原始碼,不需要執行它,就能判斷出某個變數會指向哪裡。這也是為什麼我們常說 JavaScript 的作用域是「靜態」的。
作用域的層級結構
在 JavaScript 中,作用域並不是扁平的,而是存在層級之分。主要可以分為以下三類:
全域作用域(Global Scope)
這是最外層的作用域。在瀏覽器環境下,全域作用域就是 window 物件(在 Node.js 中則是 global)。在全域作用域中宣告的變數,在程式碼的任何地方都可以存取。
函式作用域(Function Scope)
每當你定義一個函式,你就建立了一個新的函式作用域。在函式內部宣告的變數,外部是無法存取的。這就是為什麼函式常被用來封裝私有資料,避免污染全域空間。
區塊作用域(Block Scope)
這是 ES6(2015 年)引入的重要特性。在使用 let 與 const 時,任何一對花括號 { ... }(例如 if 判斷式、for 迴圈)都會建立一個獨立的區塊作用域。
重要區分: 傳統的
var並不具備區塊作用域,它只認函式。這也是為什麼現代 JavaScript 開發強烈建議使用let與const來避免意外的變數溢出。
作用域鏈(Scope Chain)的真實機制
我們已經知道每個執行環境(EC)都有一個 環境紀錄(Environment Record) 來儲存變數。但 EC 還有另一個關鍵欄位:外部環境參考(Outer Environment Reference)。
這就是 作用域鏈(Scope Chain) 的物理基礎。當 JavaScript 引擎建立一個執行環境時,它會去查看這個函式在原始碼中「被寫在哪裡」,然後將該位置的外部環境指派給這個 Outer 參考。
變數尋找的演算法
當程式執行到 console.log(x) 時,引擎會啟動一個尋找流程:
- 當前作用域: 引擎先檢查目前執行中的 EC,其環境紀錄中是否有
x?如果有,就回傳並結束尋找。 - 外部環境: 如果找不到,引擎會順著
Outer Reference往上一層作用域移動。 - 重複尋找: 在上一層的環境紀錄中繼續尋找
x。 - 抵達全域: 這個過程會一直持續,直到找到變數,或者抵達 全域執行環境(Global EC)。
- 報錯或失敗: 如果連全域環境都沒有
x:
- 若是「讀取」行為(RHS),會拋出
ReferenceError。 - 若是「賦值」行為(LHS)且不在嚴格模式下,則會在全域建立一個新變數(這是 JS 著名的雷點)。
圖解:鏈條是如何串聯的?
想像你有三層嵌套的函式:
const globalVar = "Global";
function outer() {
const outerVar = "Outer";
function inner() {
const innerVar = "Inner";
console.log(innerVar, outerVar, globalVar);
}
inner();
}
outer();
當執行到 inner() 內部時,Call Stack 與 Scope Chain 的狀態如下:
- Inner EC:
- 變數:
innerVar - Outer:指向
outer的 EC
- Outer EC:
- 變數:
outerVar - Outer:指向全域 EC
- Global EC:
- 變數:
globalVar - Outer:
null(鏈條終點)
當 inner 試圖讀取 outerVar 時,它在自己的環境找不到,於是透過 Outer 指針跳到 outer 的環境並成功獲取。這種由內向外的單向查找路徑,就是 Scope Chain。
遮蔽效應(Variable Shadowing)
在作用域鏈中,如果內層作用域宣告了與外層同名的變數,會發生什麼事?
const fruit = "蘋果";
function eat() {
const fruit = "香蕉";
console.log("正在吃:", fruit);
}
eat();
console.log("籃子裡還有:", fruit);
執行結果:
- 正在吃:香蕉
- 籃子裡還有:蘋果
這稱為 遮蔽效應(Shadowing)。因為引擎是由內向外找,一旦在內層找到了 fruit(香蕉),它就心滿意足地結束了尋找任務,永遠不會看到外層的「蘋果」。這並不會改變外層變數的值,只是在該作用域內暫時「遮住」了它。
為什麼外層看不到內層?
這是一個常見的學生問題:「為什麼我可以從裡面看外面,但不能從外面看裡面?」
這必須回到 執行環境的生命週期。 當一個內層函式執行時,外層函式必然還在 Call Stack 中(或者至少其作用域已經被保留,我們會在 Closure 章節細講)。但當程式碼執行在「外層」時,內層函式甚至還沒有被呼叫,它的執行環境根本不存在。
更本質的原因是:Scope Chain 是單向的鏈結串列(Singly Linked List)。每個環境只有一個 Outer 指針指向外面,並沒有 Inner 指針指向裡面。
React 中的 Scope Chain:實踐與連結
理解了 Scope Chain,你就能解開 React 開發中許多「理所當然」但細思極恐的行為。
為什麼 Event Handler 可以存取 Props?
看看這個標準的 React 元件:
const UserProfile = ({ name }) => {
const [age, setAge] = useState(20);
const handleClick = () => {
// 這裡為什麼可以存取到 name 和 age?
console.log(`Hello ${name}, you are ${age} years old.`);
};
return <button onClick={handleClick}>點我</button>;
};
這背後的 JS 原理就是 Scope Chain:
UserProfile是一個函式,當它被 React 呼叫(渲染)時,會建立一個執行環境(EC)。name(從 props 解構)和age(狀態)都存在於UserProfile的環境紀錄中。handleClick是在UserProfile內部定義的函式。- 根據詞法作用域,
handleClick的Outer Reference會指向UserProfile的環境。 - 因此,當按鈕被點擊、
handleClick執行時,它能透過 Scope Chain 輕易地找到外層的name與age。
這就是為什麼我們不需要透過參數傳遞,就能在元件內部的各種小函式中直接使用狀態。
預覽:閉包(Closure)的萌芽
如果你把 handleClick 傳遞給一個子元件,甚至是一個非同步的 setTimeout,過了 5 秒後執行,它依然能找到當初那個 name。
這聽起來很神奇,因為 5 秒後 UserProfile 函式早就執行完了(理論上它的 EC 應該從 Stack 中彈出了)。為什麼變數還在?
這就是我們下一個主題 Closure(閉包) 的核心:當一個函式「記住」了它的 Scope Chain,並且被帶到了它的出生地之外執行時,它會強行把那個 Scope Chain 留下來,不讓垃圾回收機制(Garbage Collection)把它清掉。
總結與回顧
掌握 Scope Chain 是從「寫程式碼」進化到「理解程式碼執行」的關鍵一步。
- Lexical Scope:決定作用域的是「定義位置」,不是「呼叫位置」。
- Outer Reference:每個環境都有一個指針指向它的物理外層,串聯成鏈。
- 單向查找:由內而外,找不到就去全域找,再找不到就報錯。
- 遮蔽效應:內層變數會遮住同名的外層變數。
透過這三個部分(執行環境、Call Stack、Scope Chain),我們已經建立了一個完整的 JavaScript 執行靜態模型。我們知道環境如何建立、呼叫如何堆疊,以及變數如何尋找。
在下一節中,我們將探討這個模型中一個有趣的現象:Hoisting(提升)。為什麼有些變數在宣告之前就能被存取?這背後的機制其實就在我們剛學過的執行環境「建立階段」中。最後,我們也會透過一個綜合練習,手動追蹤一段複雜的程式碼,將目前學到的所有概念融會貫通。
重點提醒: 請務必記住 Scope Chain 是在函式建立時就決定好的,這個觀念將會是你理解 React Hooks(特別是處理 Stale Closure 這種棘手 bug)時最重要的武器。我們下節課見。