本章深入探討 JavaScript 中閉包(closure)與作用域(scope)的運作機制,從實際應用到底層原理,涵蓋執行上下文、詞法環境、變數類型,以及閉包如何在背後運作。
理解閉包#
閉包(closure)讓函式能夠存取定義時所在作用域中的所有變數和函式,即使該作用域已經不存在。

Figure 5.1: 函式可以存取定義時作用域內的變數
- 閉包會在函式定義時建立一個「安全氣泡」(safety bubble),包含函式本身以及定義時作用域中的所有變數
- 即使外部函式已經執行完畢、其作用域已消失,內部函式仍可透過閉包存取那些變數

Figure 5.2: 即使藏在函式內部,閉包仍能存取外部變數
var outerValue = "samurai";
var later;
function outerFunction() {
var innerValue = "ninja";
function innerFunction() {
assert(outerValue === "samurai", "I can see the samurai.");
assert(innerValue === "ninja", "I can see the ninja.");
}
later = innerFunction;
}
outerFunction();
later(); // innerFunction 仍可存取 innerValue
閉包並非沒有代價。每個透過閉包存取資訊的函式都帶著一個「球和鏈」,需要將相關資訊保留在記憶體中,直到 JavaScript 引擎確定不再需要(可以被垃圾回收)或頁面卸載。

Figure 5.3: 閉包像保護氣泡一樣保存函式定義時的變數環境
閉包的實際應用#
模擬私有變數(Mimicking private variables)#
JavaScript 原生不支援私有變數,但可以透過閉包實現近似效果。在建構函式中宣告區域變數,透過方法(閉包)來存取:
function Ninja() {
var feints = 0; // "私有"變數
this.getFeints = function () {
// 存取器方法
return feints;
};
this.feint = function () {
// 修改私有變數的方法
feints++;
};
}
var ninja1 = new Ninja();
ninja1.feint();
assert(ninja1.feints === undefined); // 無法直接存取
assert(ninja1.getFeints() === 1); // 只能透過方法存取

Figure 5.15: 私有變數透過建構函式中定義的方法所建立的閉包來實現
在 callback 中使用閉包#
閉包在處理 callback 時特別有用。以動畫為例,在 setInterval 的 callback 中透過閉包存取 elem、tick、timer 等變數:
function animateIt(elementId) {
var elem = document.getElementById(elementId);
var tick = 0;
var timer = setInterval(function () {
if (tick < 100) {
elem.style.left = elem.style.top = tick + "px";
tick++;
} else {
clearInterval(timer);
}
}, 10);
}- 每次呼叫
animateIt都會建立獨立的閉包,各自持有自己的elem、tick、timer變數 - 閉包不只是定義時的快照,而是可以持續更新的活性封裝
沒有閉包,同時處理多個事件處理器、動畫或伺服器請求會非常困難。閉包讓每個 callback 自動擁有獨立的變數「氣泡」。

Figure 5.19: 透過建立多個閉包,我們可以同時追蹤多個事件
以執行上下文追蹤程式執行#
JavaScript 有兩種主要的程式碼類型:全域程式碼(global code)和函式程式碼(function code),分別在不同的執行上下文(execution context)中執行。
- 全域執行上下文:只有一個,在程式啟動時建立
- 函式執行上下文:每次函式呼叫都會建立一個新的
JavaScript 引擎使用執行上下文堆疊(execution context stack,也稱為 call stack)來管理:
- 程式開始時,全域執行上下文被推入堆疊
- 呼叫函式時,建立新的函式執行上下文並推入堆疊頂端,暫停當前上下文
- 函式執行完畢後,其執行上下文從堆疊彈出,恢復先前的上下文
執行上下文(execution context)和第四章的函式上下文(function context,即
this)是完全不同的概念。執行上下文是 JavaScript 引擎內部用來追蹤函式執行的機制。

Figure 5.6: 執行上下文堆疊的運作行為

Figure 5.7: Chrome DevTools 中的執行上下文堆疊(call stack)
以詞法環境追蹤識別字#
詞法環境(lexical environment)是 JavaScript 引擎內部的結構,用於維護識別字到變數的對應(identifier-to-variable mapping)。
- 詞法環境可與函式、程式碼區塊或
try-catch的catch部分關聯 - 每個結構都有自己獨立的識別字對應
程式碼巢狀(Code nesting)#
程式碼結構可以互相巢狀,例如:
for迴圈巢狀在report函式中report函式巢狀在skulk函式中skulk函式巢狀在全域程式碼中
內部結構可以存取外部結構中定義的變數。

Figure 5.8: 程式碼巢狀的類型
詞法環境的外部參照#
- 每個詞法環境都持有對其外部(父)詞法環境的參照
- 若在當前環境找不到識別字,會沿著外部環境鏈向上搜尋,直到找到或到達全域環境
- 函式建立時,會在內部屬性
[[Environment]]中儲存建立時所在的詞法環境參照
識別字解析不是沿著執行上下文堆疊搜尋,而是沿著詞法環境的外部參照鏈搜尋。因為函式可以作為物件傳遞,呼叫位置和定義位置通常不同(這正是閉包的核心)。
JavaScript 變數類型#
JavaScript 有三個變數定義關鍵字:var、let、const,在可變性和與詞法環境的關係上有所不同。
變數的可變性#
const:不可變(immutable),值只能在宣告時設定一次,之後不能重新賦值- 但若
const指向物件或陣列,仍可修改其內容(新增屬性、push 元素等)
- 但若
var和let:可變(mutable),可任意多次重新賦值

Figure 5.10: const 變數的行為——嘗試重新賦值時會拋出例外
const secondConst = {};
secondConst.weapon = "wakizashi"; // 可以修改物件內容
// secondConst = {}; // 錯誤!不能重新賦值
變數定義關鍵字與詞法環境#
var:變數被註冊在最近的函式環境或全域環境中,忽略區塊(block)let和const:變數被註冊在最近的詞法環境中(可以是區塊、迴圈、函式或全域環境)
// 使用 var -- i 和 forMessage 在函式環境中
for (var i = 1; i < 3; i++) {
var forMessage = "Yoshi jumping";
}
assert(i === 3 && forMessage === "Yoshi jumping"); // 迴圈外仍可存取
// 使用 let -- i 和 forMessage 在區塊環境中
for (let i = 1; i < 3; i++) {
let forMessage = "Yoshi jumping";
}
assert(typeof i === "undefined" && typeof forMessage === "undefined"); // 迴圈外不可存取
var忽略區塊作用域是長期困擾從 C 系語言轉來的開發者的問題。ES6 引入的let和const終於讓 JavaScript 支援與其他 C 系語言相同的作用域規則。
識別字在詞法環境中的註冊過程#
JavaScript 程式碼的執行分為兩個階段:
- 第一階段:建立新的詞法環境時,引擎掃描並註冊所有宣告的變數和函式(程式碼尚未執行)
- 第二階段:開始逐行執行 JavaScript 程式碼
具體的註冊過程:
- 函式環境:建立
arguments識別字、具名參數及其值 - 函式和全域環境:掃描函式宣告(function declaration),為每個宣告建立函式並綁定識別字(若識別字已存在則覆蓋)
- 區塊環境不執行此步驟
- 掃描
var(在函式和全域環境中,含區塊內的)、let和const(在區塊環境中)的變數宣告,若識別字不存在則註冊為undefined
這解釋了為何可以在函式宣告之前呼叫該函式(hoisting),但不能提前存取函式表達式或箭頭函式。

Figure 5.13: 函式在執行到達其定義之前就已可見(hoisting)

Figure 5.14: 識別字註冊流程取決於環境類型
函式識別字覆蓋的問題:
assert(typeof fun === "function"); // fun 先被註冊為函式
var fun = 3; // 之後被覆蓋為數字
assert(typeof fun === "number");
function fun() {} // 宣告在執行階段被跳過
assert(typeof fun === "number"); // 仍然是數字
深入探索閉包的運作#
重新審視私有變數與閉包#
透過執行上下文和詞法環境的角度理解私有變數:
- 每次使用
new呼叫建構函式,都會建立一個新的詞法環境 - 在建構函式內建立的方法(如
getFeints、feint)透過[[Environment]]屬性保留對 Ninja 環境的參照 - 這些方法被指定為新物件的屬性,可以從建構函式外部存取,從而形成對
feints變數的閉包
私有變數的注意事項#
- JavaScript 中的「私有」變數並非物件的真正私有屬性,而是透過閉包保持存活的變數
- 若將物件的方法指定給另一個物件,該方法仍能透過閉包存取原物件的「私有」變數
var imposter = {};
imposter.getFeints = ninja1.getFeints;
assert(imposter.getFeints() === 1); // imposter 也能存取 feints!
透過閉包模擬的「私有」變數是透過函式而非物件來存取的。將方法複製到其他物件後,該方法仍保有對原始閉包的存取權。
重新審視閉包與 callback#
以動畫範例說明:每次呼叫 animateIt 都會建立新的函式詞法環境,各自追蹤 elementId、elem、tick、timer 等變數。當 setInterval 的 callback 被觸發時,會重新啟用建立 callback 時的詞法環境,透過閉包自動存取對應的變數集合。
本章重點整理#
- 閉包讓函式能存取定義時作用域中的所有變數,建立一個「安全氣泡」,即使作用域已消失,函式仍有所需的一切
- 閉包的進階應用:
- 透過建構函式中的方法閉包模擬私有物件變數
- 在 callback 中利用閉包簡化程式碼
- JavaScript 引擎透過執行上下文堆疊(call stack)追蹤函式執行,每次函式呼叫都建立新的執行上下文
- JavaScript 引擎透過詞法環境(作用域)追蹤識別字
- 變數定義關鍵字的作用域差異:
var:定義在最近的函式或全域作用域(忽略區塊)let和const:定義在最近的作用域(包含區塊),const只能賦值一次
- 閉包是 JavaScript 作用域規則的副產物,函式可以在其建立時的作用域消失後仍被呼叫