閉包

生活的样子就该是那样發表於2024-10-10

閉包

閉包(closure)是一個函式以及捆綁的周邊環境狀態(lexical environment,詞法環境)的引用的組合。換而言之,閉包讓開發者可以從內部函式訪問外部函式的作用域。在JavaScript中,閉包會隨著函式的建立而被同時建立。

詞法作用域

請看下面程式碼:

function init() {
  var name = "Mozilla"; // name 是一個被 init 建立的區域性變數
  function displayName() {
    // displayName() 是內部函式,一個閉包
    alert(name); // 使用了父函式中宣告的變數
  }
  displayName();
}
init();

init() 建立了一個區域性變數 name 和一個名為 displayName() 的函式。displayName() 是定義在 init() 裡的內部函式,並且僅在 init() 函式體內可用。請注意,displayName() 沒有自己的區域性變數。然而,因為它可以訪問到外部函式的變數,所以 displayName() 可以使用父函式 init() 中宣告的變數 name

displayName() 函式內的 alert() 語句成功顯示出了變數 name 的值(該變數在其父函式中宣告)。這個詞法作用域的例子描述了分析器如何在函式巢狀的情況下解析變數名。詞法(lexical)一詞指的是,詞法作用域根據原始碼中宣告變數的位置來確定該變數在何處可用。巢狀函式可訪問宣告於它們外部作用域的變數。

閉包

function makeFunc() {
  var name = "Mozilla";
  function displayName() {
    alert(name);
  }
  return displayName;
}

var myFunc = makeFunc();
myFunc();

執行這段程式碼的效果和之前 init() 函式的示例完全一樣。其中不同的地方(也是有意思的地方)在於內部函式 displayName() 在執行前,從外部函式返回。

第一眼看上去,也許不能直觀地看出這段程式碼能夠正常執行。在一些程式語言中,一個函式中的區域性變數僅存在於此函式的執行期間。一旦 makeFunc() 執行完畢,你可能會認為 name 變數將不能再被訪問。然而,因為程式碼仍按預期執行,所以在 JavaScript 中情況顯然與此不同。

原因在於,JavaScript 中的函式會形成了閉包。 閉包是由函式以及宣告該函式的詞法環境組合而成的。該環境包含了這個閉包建立時作用域內的任何區域性變數。在本例子中,myFunc 是執行 makeFunc 時建立的 displayName 函式例項的引用。displayName 的例項維持了一個對它的詞法環境(變數 name 存在於其中)的引用。因此,當 myFunc 被呼叫時,變數 name 仍然可用,其值 Mozilla 就被傳遞到alert中。

下面是一個更有意思的示例 — 一個 makeAdder 函式:

function makeAdder(x) {
  return function (y) {
    return x + y;
  };
}

var add5 = makeAdder(5);
var add10 = makeAdder(10);

console.log(add5(2)); // 7
console.log(add10(2)); // 12

在這個示例中,我們定義了 makeAdder(x) 函式,它接受一個引數 x ,並返回一個新的函式。返回的函式接受一個引數 y,並返回x+y的值。

從本質上講,makeAdder 是一個函式工廠 — 他建立了將指定的值和它的引數相加求和的函式。在上面的示例中,我們使用函式工廠建立了兩個新函式 — 一個將其引數和 5 求和,另一個和 10 求和。

add5add10 都是閉包。它們共享相同的函式定義,但是儲存了不同的詞法環境。在 add5 的環境中,x 為 5。而在 add10 中,x 則為 10

實用的閉包

閉包很有用,因為它允許將函式與其所操作的某些資料(環境)關聯起來。這顯然類似於物件導向程式設計。在物件導向程式設計中,物件允許我們將某些資料(物件的屬性)與一個或者多個方法相關聯。

因此,通常你使用只有一個方法的物件的地方,都可以使用閉包。

Web 中,你想要這樣做的情況特別常見。大部分我們所寫的 JavaScript 程式碼都是基於事件的 — 定義某種行為,然後將其新增到使用者觸發的事件之上(比如點選或者按鍵)。我們的程式碼通常作為回撥:為響應事件而執行的函式。

假如,我們想在頁面上新增一些可以調整字號的按鈕。一種方法是以畫素為單位指定 body 元素的 font-size,然後透過相對的 em 單位設定頁面中其他元素(例如header)的字號:

body {
  font-family: Helvetica, Arial, sans-serif;
  font-size: 12px;
}

h1 {
  font-size: 1.5em;
}

h2 {
  font-size: 1.2em;
}

我們的文字尺寸調整按鈕可以修改 body 元素的 font-size 屬性,由於我們使用相對單位,頁面中的其他元素也會相應地調整。

以下是 JavaScript:

function makeSizer(size) {
  return function () {
    document.body.style.fontSize = size + "px";
  };
}

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);

size12size14size16 三個函式將分別把 body 文字調整為 12,14,16 畫素。我們可以將它們分別新增到按鈕的點選事件上。如下所示:

document.getElementById("size-12").onclick = size12;
document.getElementById("size-14").onclick = size14;
document.getElementById("size-16").onclick = size16;
<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>

用閉包模擬私有方法

程式語言中,比如 Java,是支援將方法宣告為私有的,即它們只能被同一個類中的其他方法所呼叫。

JavaScript 沒有這種原生支援,但我們可以使用閉包來模擬私有方法。私有方法不僅僅有利於限制對程式碼的訪問:還提供了管理全域性名稱空間的強大能力,避免非核心的方法弄亂了程式碼的公共介面部分。

下面的示例展現瞭如何使用閉包來定義公共函式,並令其可以訪問私有函式和變數。這個方式也稱為 模組模式(module pattern)

var Counter = (function () {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function () {
      changeBy(1);
    },
    decrement: function () {
      changeBy(-1);
    },
    value: function () {
      return privateCounter;
    },
  };
})();

console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */

在之前的示例中,每個閉包都有它自己的詞法環境;而這次我們只建立了一個詞法環境,為三個函式所共享:Counter.incrementCounter.decrementCounter.value

該共享環境建立於一個立即執行的匿名函式體內。這個環境中包含兩個私有項:名為 privateCounter 的變數和名為 changeBy 的函式。這兩項都無法在這個匿名函式外部直接訪問。必須透過匿名函式返回的三個公共函式訪問。

這三個公共函式是共享同一個環境的閉包。多虧 JavaScript 的詞法作用域,它們都可以訪問 privateCounter 變數和 changeBy 函式。

備註:你應該注意到我們定義了一個匿名函式,用於建立一個計數器。我們立即執行了這個匿名函式,並將他的值賦給了變數Counter。我們可以把這個函式儲存在另外一個變數makeCounter中,並用他來建立多個計數器。

var makeCounter = function () {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function () {
      changeBy(1);
    },
    decrement: function () {
      changeBy(-1);
    },
    value: function () {
      return privateCounter;
    },
  };
};

var Counter1 = makeCounter();
var Counter2 = makeCounter();
console.log(Counter1.value()); /* logs 0 */
Counter1.increment();
Counter1.increment();
console.log(Counter1.value()); /* logs 2 */
Counter1.decrement();
console.log(Counter1.value()); /* logs 1 */
console.log(Counter2.value()); /* logs 0 */

備註:以這種方式使用閉包,提供了許多與物件導向程式設計相關的好處——特別是資料隱藏和封裝。

在迴圈中建立閉包:一個常見錯誤

在 ECMAScript 2015 引入 let 關鍵字 之前,在迴圈中有一個常見的閉包建立問題。參考下面的示例:

<p id="help">Helpful notes will appear here</p>
<p>E-mail: <input type="text" id="email" name="email" /></p>
<p>Name: <input type="text" id="name" name="name" /></p>
<p>Age: <input type="text" id="age" name="age" /></p>
function showHelp(help) {
  document.getElementById("help").innerHTML = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your e-mail address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = showHelp(item.help);
  }
}

setupHelp();

陣列 helpText 中定義了三個有用的提示資訊,每一個都關聯於對應的文件中的 input 的 ID。透過迴圈這三項定義,依次為相應 input 新增了一個 onfocus 事件處理函式,以便顯示幫助資訊。

執行這段程式碼後,你會發現它沒有達到想要的效果。無論焦點在哪個 input 上,顯示的都是關於年齡的資訊。

原因是賦值給 onfocus 的是閉包。這些閉包是由他們的函式定義和在 setupHelp 作用域中捕獲的環境所組成的。這三個閉包在迴圈中被建立,但他們共享了同一個詞法作用域,在這個作用域中存在一個變數 item。這是因為變數 item 使用 var 進行宣告,由於變數提升,所以具有函式作用域。當 onfocus 的回撥執行時,item.help 的值被決定。由於迴圈在事件觸發之前早已執行完畢,變數物件 item(被三個閉包所共享)已經指向了 helpText 的最後一項。

解決這個問題的一種方案是使用更多的閉包:特別是使用前面所述的函式工廠:

function showHelp(help) {
  document.getElementById("help").innerHTML = help;
}

function makeHelpCallback(help) {
  return function () {
    showHelp(help);
  };
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your e-mail address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
  }
}

setupHelp();

這段程式碼可以如我們所期望的那樣工作。所有的回撥不再共享同一個環境, makeHelpCallback 函式為每一個回撥建立一個新的詞法環境。在這些環境中,help 指向 helpText 陣列中對應的字串。

個人看法(非官方,作者按照自己理解的一些想法):可能有部分人和我一樣認為兩種寫法差別不大。我感覺是可能你和我一樣,也接觸了其他語言(Java或其他語言)。在本文開始已經提出了詞法作用域的概念,我認為這個詞法作用域主要看的是你寫的程式碼是什麼,然後根據程式碼來解析。比如第二種寫法,你並沒有在for迴圈裡寫出一個函式,所以它就不會認為這幾次迴圈共享了一個詞法作用域(或者可以這樣理解,這幾次迴圈中沒有共享變數,即每次都用的不同的item)。而第一種寫法,在for迴圈中寫了一個函式。此時這個函式便是閉包,而這三個閉包共享了一個詞法作用域(即使用的是同一個共享變數item)當onfocus的回撥執行時,item的值才被確定。由於迴圈在事件觸發之前早已執行完畢,此時的item(被三個閉包所共享)已經執行了指向helpText的最後一項。

另一種方法使用了匿名閉包(IIFE 立即執行函式):

function showHelp(help) {
  document.getElementById("help").innerHTML = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your e-mail address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (var i = 0; i < helpText.length; i++) {
    (function () {
      var item = helpText[i];
      document.getElementById(item.id).onfocus = function () {
        showHelp(item.help);
      };
    })(); // 馬上把當前迴圈項的 item 與事件回撥相關聯起來
  }
}

setupHelp();

如果不想使用過多的閉包,你可以用 ES2015 引入的 let 或 const 關鍵詞:

function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  const helpText = [
    { id: "email", help: "Your e-mail address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  for (let i = 0; i < helpText.length; i++) {
    const item = helpText[i];
    document.getElementById(item.id).onfocus = () => {
      showHelp(item.help);
    };
  }
}

setupHelp();

這個例子使用 const 而不是 var,因此每個閉包都繫結了塊作用域的變數,這意味著不再需要額外的閉包。

另一個可選方案是使用 forEach() 來遍歷 helpText 陣列並給每一個 <input> 新增一個監聽器,如下所示:

function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your e-mail address" },
    { id: "name", help: "Your full name" },
    { id: "age", help: "Your age (you must be over 16)" },
  ];

  helpText.forEach(function (text) {
    document.getElementById(text.id).onfocus = function () {
      showHelp(text.help);
    };
  });
}

setupHelp();

效能考量

如果不是某些特定任務需要使用閉包,在其他函式中建立函式是不明智的,因為閉包在處理速度和記憶體消耗方面對指令碼效能具有負面影響。

例如,在建立新的物件或者類時,方法通常應該關聯於物件的原型,而不是定義到物件的構造器中。原因是這將導致每次構造器被呼叫時,方法都會被重新賦值一次(也就是說,對於每個物件的建立,方法都會被重新賦值)。

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
  this.getName = function () {
    return this.name;
  };

  this.getMessage = function () {
    return this.message;
  };
}

在上面的程式碼中,我們並沒有利用到閉包的好處,因此可以避免使用閉包。修改成如下:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype = {
  getName() {
    return this.name;
  },
  getMessage() {
    return this.message;
  },
};

但我們不建議重新定義原型。可改成如下例子:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype.getName = function () {
  return this.name;
};
MyObject.prototype.getMessage = function () {
  return this.message;
};

參考:MDN閉包