閉包

**閉包**是將函式與其周圍狀態(**詞法環境**)的引用捆綁在一起(封閉)的組合。換句話說,閉包使函式能夠訪問其外部作用域。在 JavaScript 中,每次建立函式時,在函式建立時都會建立閉包。

詞法作用域

考慮以下示例程式碼

js
function init() {
  var name = "Mozilla"; // name is a local variable created by init
  function displayName() {
    // displayName() is the inner function, that forms a closure
    console.log(name); // use variable declared in the parent function
  }
  displayName();
}
init();

init() 建立一個名為 name 的區域性變數和一個名為 displayName() 的函式。displayName() 函式是定義在 init() 內部的一個內部函式,僅在 init() 函式體內部可用。請注意,displayName() 函式本身沒有區域性變數。但是,由於內部函式可以訪問外部作用域的變數,因此 displayName() 可以訪問在父函式 init() 中宣告的變數 name

使用 此 JSFiddle 連結 執行程式碼,並注意 displayName() 函式中的 console.log() 語句成功顯示了 name 變數的值,該變數在其父函式中宣告。這是一個 *詞法作用域* 的示例,它描述瞭解析器在函式巢狀時如何解析變數名。單詞 *詞法* 指的是詞法作用域使用變數在原始碼中宣告的位置來確定該變數在何處可用。巢狀函式可以訪問在其外部作用域中宣告的變數。

使用 let 和 const 進行作用域

傳統上(在 ES6 之前),JavaScript 變數只有兩種作用域:*函式作用域* 和 *全域性作用域*。使用 var 宣告的變數要麼是函式作用域,要麼是全域性作用域,具體取決於它們是在函式內部還是函式外部宣告。這可能很棘手,因為帶花括號的塊不會建立作用域

js
if (Math.random() > 0.5) {
  var x = 1;
} else {
  var x = 2;
}
console.log(x);

對於來自其他語言(例如 C、Java)的人員,在這些語言中塊會建立作用域,上述程式碼應該在 console.log 行丟擲錯誤,因為我們在任何一個塊中都位於 x 的作用域之外。但是,由於塊不會為 var 建立作用域,因此此處的 var 語句實際上建立了一個全域性變數。下面還介紹了一個 實際示例,說明了當與閉包結合時,這如何會導致實際錯誤。

在 ES6 中,JavaScript 引入了 letconst 宣告,除了其他功能(例如 時間死區)之外,它們還允許您建立塊作用域變數。

js
if (Math.random() > 0.5) {
  const x = 1;
} else {
  const x = 2;
}
console.log(x); // ReferenceError: x is not defined

從本質上講,塊在 ES6 中最終被視為作用域,但前提是您使用 letconst 宣告變數。此外,ES6 引入了 模組,它引入了另一種作用域。閉包能夠捕獲所有這些作用域中的變數,我們將在後面介紹。

閉包

考慮以下程式碼示例

js
function makeFunc() {
  const name = "Mozilla";
  function displayName() {
    console.log(name);
  }
  return displayName;
}

const myFunc = makeFunc();
myFunc();

執行此程式碼與上面 init() 函式的先前示例具有完全相同的效果。不同之處(且有趣的是)在於,在執行之前,內部函式 displayName() 從外部函式返回。

乍一看,這段程式碼仍然有效似乎是不直觀的。在某些程式語言中,函式內的區域性變數僅在該函式執行期間存在。一旦 makeFunc() 完成執行,您可能會預期 name 變數將不再可訪問。但是,由於程式碼仍然按預期工作,因此在 JavaScript 中顯然並非如此。

原因是 JavaScript 中的函式形成了閉包。閉包是函式與其宣告所在的詞法環境的組合。此環境包含在建立閉包時處於作用域內的任何變數。在本例中,myFunc 是對在執行 makeFunc 時建立的函式 displayName 的例項的引用。displayName 的例項維護對其詞法環境的引用,變數 name 存在於該環境中。因此,當呼叫 myFunc 時,變數 name 仍然可用,並且“Mozilla”被傳遞給 console.log

這是一個稍微有趣一點的例子——一個 makeAdder 函式

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

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

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

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

從本質上講,makeAdder 是一個函式工廠。它建立可以將其引數加到特定值的函式。在上面的示例中,函式工廠建立了兩個新函式——一個將其引數加 5,另一個將其引數加 10。

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

實用閉包

閉包很有用,因為它們允許您將資料(詞法環境)與操作該資料的函式相關聯。這與面向物件程式設計有明顯的相似之處,在面向物件程式設計中,物件允許您將資料(物件的屬性)與一個或多個方法相關聯。

因此,您可以在任何可能通常使用僅具有單個方法的物件的地方使用閉包。

在 Web 上,您可能希望執行此操作的情況尤其常見。在前端 JavaScript 中編寫的許多程式碼都是基於事件的。您定義一些行為,然後將其附加到由使用者觸發的事件(例如單擊或按鍵)。程式碼作為回撥(響應事件執行的單個函式)附加。

例如,假設我們想向頁面新增按鈕以調整文字大小。一種方法是指定 body 元素的字型大小(以畫素為單位),然後使用相對的 em 單位設定頁面上其他元素(例如標題)的大小

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

h1 {
  font-size: 1.5em;
}

h2 {
  font-size: 1.2em;
}

此類互動式文字大小按鈕可以更改 body 元素的 font-size 屬性,並且由於使用了相對單位,頁面上的其他元素會獲取這些調整。

這是 JavaScript 程式碼

js
function makeSizer(size) {
  return function () {
    document.body.style.fontSize = `${size}px`;
  };
}

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

size12size14size16 現在是分別將正文文字大小調整為 12、14 和 16 畫素的函式。您可以將它們附加到按鈕,如下面的程式碼示例中所示。

js
document.getElementById("size-12").onclick = size12;
document.getElementById("size-14").onclick = size14;
document.getElementById("size-16").onclick = size16;
html
<button id="size-12">12</button>
<button id="size-14">14</button>
<button id="size-16">16</button>

使用 JSFiddle 執行程式碼。

使用閉包模擬私有方法

像 Java 這樣的語言允許你將方法宣告為私有,這意味著它們只能被同一類中的其他方法呼叫。

JavaScript 在 出現之前,沒有原生方法來宣告 私有方法,但可以使用閉包來模擬私有方法。私有方法不僅對限制程式碼訪問有用,它們還提供了一種強大的方式來管理全域性名稱空間。

以下程式碼演示瞭如何使用閉包來定義可以訪問私有函式和變數的公共函式。請注意,這些閉包遵循 模組設計模式

js
const counter = (function () {
  let privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }

  return {
    increment() {
      changeBy(1);
    },

    decrement() {
      changeBy(-1);
    },

    value() {
      return privateCounter;
    },
  };
})();

console.log(counter.value()); // 0.

counter.increment();
counter.increment();
console.log(counter.value()); // 2.

counter.decrement();
console.log(counter.value()); // 1.

在之前的示例中,每個閉包都有自己的詞法環境。但是在這裡,三個函式共享一個詞法環境:counter.incrementcounter.decrementcounter.value

共享的詞法環境是在一個匿名函式的函式體中建立的,該函式在定義後立即執行(也稱為 IIFE)。詞法環境包含兩個私有項:一個名為 privateCounter 的變數和一個名為 changeBy 的函式。你無法從匿名函式外部訪問這些私有成員。相反,你可以透過從匿名包裝器返回的三個公共函式間接訪問它們。

這三個公共函式形成了共享相同詞法環境的閉包。由於 JavaScript 的詞法作用域,它們都可以訪問 privateCounter 變數和 changeBy 函式。

js
const makeCounter = function () {
  let privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment() {
      changeBy(1);
    },

    decrement() {
      changeBy(-1);
    },

    value() {
      return privateCounter;
    },
  };
};

const counter1 = makeCounter();
const counter2 = makeCounter();

console.log(counter1.value()); // 0.

counter1.increment();
counter1.increment();
console.log(counter1.value()); // 2.

counter1.decrement();
console.log(counter1.value()); // 1.
console.log(counter2.value()); // 0.

請注意兩個計數器如何保持彼此獨立。每個閉包都透過自己的閉包引用 privateCounter 變數的不同版本。每次呼叫其中一個計數器時,它的詞法環境都會透過更改此變數的值而發生更改。一個閉包中變數值的更改不會影響另一個閉包中的值。

注意: 以這種方式使用閉包提供了通常與面向物件程式設計相關的優勢。特別是,資料隱藏封裝

閉包作用域鏈

巢狀函式對外部函式作用域的訪問包括外部函式的封閉作用域——有效地建立了一個函式作用域鏈。為了演示,請考慮以下示例程式碼。

js
// global scope
const e = 10;
function sum(a) {
  return function (b) {
    return function (c) {
      // outer functions scope
      return function (d) {
        // local scope
        return a + b + c + d + e;
      };
    };
  };
}

console.log(sum(1)(2)(3)(4)); // 20

你也可以不用匿名函式編寫

js
// global scope
const e = 10;
function sum(a) {
  return function sum2(b) {
    return function sum3(c) {
      // outer functions scope
      return function sum4(d) {
        // local scope
        return a + b + c + d + e;
      };
    };
  };
}

const sum2 = sum(1);
const sum3 = sum2(2);
const sum4 = sum3(3);
const result = sum4(4);
console.log(result); // 20

在上面的示例中,有一系列巢狀函式,它們都能夠訪問外部函式的作用域。在這種情況下,我們可以說閉包可以訪問所有外部作用域。

閉包也可以捕獲塊作用域和模組作用域中的變數。例如,以下程式碼建立了一個閉包,它覆蓋了塊作用域變數 y

js
function outer() {
  let getY;
  {
    const y = 6;
    getY = () => y;
  }
  console.log(typeof y); // undefined
  console.log(getY()); // 6
}

outer();

模組上的閉包可能更有趣。

js
// myModule.js
let x = 5;
export const getX = () => x;
export const setX = (val) => {
  x = val;
};

在這裡,模組匯出了一對 getter-setter 函式,它們覆蓋了模組作用域變數 x。即使 x 無法從其他模組直接訪問,也可以使用這些函式讀取和寫入它。

js
import { getX, setX } from "./myModule.js";

console.log(getX()); // 5
setX(6);
console.log(getX()); // 6

閉包也可以覆蓋匯入的值,這些值被視為動態 繫結,因為當原始值更改時,匯入的值也會相應更改。

js
// myModule.js
export let x = 1;
export const setX = (val) => {
  x = val;
};
js
// closureCreator.js
import { x } from "./myModule.js";

export const getX = () => x; // Close over an imported live binding
js
import { getX } from "./closureCreator.js";
import { setX } from "./myModule.js";

console.log(getX()); // 1
setX(2);
console.log(getX()); // 2

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

在引入 let 關鍵字之前,閉包的一個常見問題出現在你迴圈內部建立它們時。為了演示,請考慮以下示例程式碼。

html
<p id="help">Helpful notes will appear here</p>
<p>Email: <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>
js
function showHelp(help) {
  document.getElementById("help").textContent = help;
}

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your email 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++) {
    // Culprit is the use of `var` on this line
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function () {
      showHelp(item.help);
    };
  }
}

setupHelp();

嘗試在 JSFiddle 中執行程式碼。

helpText 陣列定義了三個有用的提示,每個提示都與文件中輸入欄位的 ID 相關聯。迴圈遍歷這些定義,將 onfocus 事件掛接到每個提示上,以顯示關聯的幫助方法。

如果你嘗試執行此程式碼,你會發現它沒有按預期工作。無論你聚焦哪個欄位,都會顯示有關你年齡的訊息。

出現這種情況的原因是分配給 onfocus 的函式形成了閉包;它們由函式定義和從 setupHelp 函式作用域捕獲的環境組成。迴圈建立了三個閉包,但每個閉包都共享同一個詞法環境,該環境包含一個具有變化值的變數 (item)。這是因為變數 item 使用 var 宣告,並且由於提升而具有函式作用域。item.help 的值是在執行 onfocus 回撥時確定的。因為此時迴圈已經結束,所以 item 變數物件(由所有三個閉包共享)被留在了指向 helpText 列表中最後一個條目的位置。

在這種情況下,一個解決方案是使用更多的閉包:特別是,使用前面描述的函式工廠。

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

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

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your email 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();

使用 此 JSFiddle 連結 執行程式碼。

這按預期工作。回撥不再共享單個詞法環境,而是 makeHelpCallback 函式為每個回撥建立一個新的詞法環境,其中 help 指向 helpText 陣列中相應的字串。

另一種使用匿名閉包編寫上述程式碼的方式是

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

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your email 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);
      };
    })(); // Immediate event listener attachment with the current value of item (preserved until iteration).
  }
}

setupHelp();

如果你不想使用更多閉包,可以使用 letconst 關鍵字

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

function setupHelp() {
  const helpText = [
    { id: "email", help: "Your email 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>,如下所示

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

function setupHelp() {
  var helpText = [
    { id: "email", help: "Your email 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();

效能注意事項

如前所述,每個函式例項都管理自己的作用域和閉包。因此,如果某個任務不需要閉包,則不應在其他函式中不必要地建立函式,因為這會對指令碼效能產生負面影響,包括處理速度和記憶體消耗。

例如,在建立新物件/類時,方法通常應與物件的原型關聯,而不是在物件建構函式中定義。原因是在每次呼叫建構函式時,方法都會重新分配(即,對於每個物件建立)。

考慮以下情況

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

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

由於前面的程式碼在此特定例項中沒有利用使用閉包的優勢,因此我們可以將其重寫為避免使用閉包,如下所示

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

但是,重新定義原型是不推薦的。以下示例改為追加到現有原型

js
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;
};

在前面兩個示例中,繼承的原型可以由所有物件共享,並且方法定義不需要在每次物件建立時都發生。有關詳細資訊,請參閱 繼承和原型鏈