本章深入探討 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 中透過閉包存取 elemticktimer 等變數:

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 都會建立獨立的閉包,各自持有自己的 elemticktimer 變數
  • 閉包不只是定義時的快照,而是可以持續更新的活性封裝

沒有閉包,同時處理多個事件處理器、動畫或伺服器請求會非常困難。閉包讓每個 callback 自動擁有獨立的變數「氣泡」。

Figure 5.19: 透過建立多個閉包,我們可以同時追蹤多個事件

以執行上下文追蹤程式執行#

JavaScript 有兩種主要的程式碼類型:全域程式碼(global code)和函式程式碼(function code),分別在不同的執行上下文(execution context)中執行。

  • 全域執行上下文:只有一個,在程式啟動時建立
  • 函式執行上下文:每次函式呼叫都會建立一個新的

JavaScript 引擎使用執行上下文堆疊(execution context stack,也稱為 call stack)來管理:

  1. 程式開始時,全域執行上下文被推入堆疊
  2. 呼叫函式時,建立新的函式執行上下文並推入堆疊頂端,暫停當前上下文
  3. 函式執行完畢後,其執行上下文從堆疊彈出,恢復先前的上下文

執行上下文(execution context)和第四章的函式上下文(function context,即 this)是完全不同的概念。執行上下文是 JavaScript 引擎內部用來追蹤函式執行的機制。

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

Figure 5.7: Chrome DevTools 中的執行上下文堆疊(call stack)

以詞法環境追蹤識別字#

詞法環境(lexical environment)是 JavaScript 引擎內部的結構,用於維護識別字到變數的對應(identifier-to-variable mapping)。

  • 詞法環境可與函式、程式碼區塊或 try-catchcatch 部分關聯
  • 每個結構都有自己獨立的識別字對應

程式碼巢狀(Code nesting)#

程式碼結構可以互相巢狀,例如:

  • for 迴圈巢狀在 report 函式中
  • report 函式巢狀在 skulk 函式中
  • skulk 函式巢狀在全域程式碼中

內部結構可以存取外部結構中定義的變數。

Figure 5.8: 程式碼巢狀的類型

詞法環境的外部參照#

  • 每個詞法環境都持有對其外部(父)詞法環境的參照
  • 若在當前環境找不到識別字,會沿著外部環境鏈向上搜尋,直到找到或到達全域環境
  • 函式建立時,會在內部屬性 [[Environment]] 中儲存建立時所在的詞法環境參照

識別字解析不是沿著執行上下文堆疊搜尋,而是沿著詞法環境的外部參照鏈搜尋。因為函式可以作為物件傳遞,呼叫位置和定義位置通常不同(這正是閉包的核心)。

JavaScript 變數類型#

JavaScript 有三個變數定義關鍵字:varletconst,在可變性與詞法環境的關係上有所不同。

變數的可變性#

  • const:不可變(immutable),值只能在宣告時設定一次,之後不能重新賦值
    • 但若 const 指向物件或陣列,仍可修改其內容(新增屬性、push 元素等)
  • varlet:可變(mutable),可任意多次重新賦值

Figure 5.10: const 變數的行為——嘗試重新賦值時會拋出例外

const secondConst = {};
secondConst.weapon = "wakizashi"; // 可以修改物件內容
// secondConst = {};               // 錯誤!不能重新賦值

變數定義關鍵字與詞法環境#

  • var:變數被註冊在最近的函式環境或全域環境中,忽略區塊(block)
  • letconst:變數被註冊在最近的詞法環境中(可以是區塊、迴圈、函式或全域環境)
// 使用 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 引入的 letconst 終於讓 JavaScript 支援與其他 C 系語言相同的作用域規則。

識別字在詞法環境中的註冊過程#

JavaScript 程式碼的執行分為兩個階段

  1. 第一階段:建立新的詞法環境時,引擎掃描並註冊所有宣告的變數和函式(程式碼尚未執行)
  2. 第二階段:開始逐行執行 JavaScript 程式碼

具體的註冊過程:

  • 函式環境:建立 arguments 識別字、具名參數及其值
  • 函式和全域環境:掃描函式宣告(function declaration),為每個宣告建立函式並綁定識別字(若識別字已存在則覆蓋)
  • 區塊環境不執行此步驟
  • 掃描 var(在函式和全域環境中,含區塊內的)、letconst(在區塊環境中)的變數宣告,若識別字不存在則註冊為 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 呼叫建構函式,都會建立一個新的詞法環境
  • 在建構函式內建立的方法(如 getFeintsfeint)透過 [[Environment]] 屬性保留對 Ninja 環境的參照
  • 這些方法被指定為新物件的屬性,可以從建構函式外部存取,從而形成對 feints 變數的閉包

私有變數的注意事項#

  • JavaScript 中的「私有」變數並非物件的真正私有屬性,而是透過閉包保持存活的變數
  • 若將物件的方法指定給另一個物件,該方法仍能透過閉包存取原物件的「私有」變數
var imposter = {};
imposter.getFeints = ninja1.getFeints;
assert(imposter.getFeints() === 1); // imposter 也能存取 feints!

透過閉包模擬的「私有」變數是透過函式而非物件來存取的。將方法複製到其他物件後,該方法仍保有對原始閉包的存取權。

重新審視閉包與 callback#

以動畫範例說明:每次呼叫 animateIt 都會建立新的函式詞法環境,各自追蹤 elementIdelemticktimer 等變數。當 setInterval 的 callback 被觸發時,會重新啟用建立 callback 時的詞法環境,透過閉包自動存取對應的變數集合。

本章重點整理#

  • 閉包讓函式能存取定義時作用域中的所有變數,建立一個「安全氣泡」,即使作用域已消失,函式仍有所需的一切
  • 閉包的進階應用:
    • 透過建構函式中的方法閉包模擬私有物件變數
    • 在 callback 中利用閉包簡化程式碼
  • JavaScript 引擎透過執行上下文堆疊(call stack)追蹤函式執行,每次函式呼叫都建立新的執行上下文
  • JavaScript 引擎透過詞法環境(作用域)追蹤識別字
  • 變數定義關鍵字的作用域差異:
    • var:定義在最近的函式或全域作用域(忽略區塊)
    • letconst:定義在最近的作用域(包含區塊),const 只能賦值一次
  • 閉包是 JavaScript 作用域規則的副產物,函式可以在其建立時的作用域消失後仍被呼叫