閉包
閉包是函式與對其周圍狀態(詞法環境)的引用捆綁(或“封閉”)在一起的組合。換句話說,閉包讓函式可以訪問其外部作用域。在 JavaScript 中,每當函式被建立時,閉包都會在函式建立時生成。
詞法作用域
請看下面的示例程式碼
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。
如果你在控制檯中執行這段程式碼,你會看到 displayName() 函式中的 console.log() 語句成功顯示了在其父函式中宣告的 name 變數的值。這是一個“詞法作用域”的例子,它描述了當函式巢狀時,解析器如何解析變數名。詞法這個詞指的是詞法作用域使用變數在原始碼中宣告的位置來確定該變數的可用範圍。巢狀函式可以訪問在其外部作用域中宣告的變數。
使用 let 和 const 進行作用域劃分
傳統上(ES6 之前),JavaScript 變數只有兩種作用域:函式作用域和全域性作用域。用 var 宣告的變數要麼是函式作用域,要麼是全域性作用域,這取決於它們是在函式內部還是外部宣告。這可能很棘手,因為帶花括號的程式碼塊不會建立作用域。
if (Math.random() > 0.5) {
var x = 1;
} else {
var x = 2;
}
console.log(x);
對於來自其他語言(例如 C、Java)的人來說,這些語言中程式碼塊會建立作用域,上面的程式碼應該在 console.log 行丟擲錯誤,因為我們在兩個程式碼塊中都超出了 x 的作用域。然而,由於程式碼塊不會為 var 建立作用域,這裡的 var 語句實際上建立了一個全域性變數。下面還介紹了一個實際示例,說明了當它與閉包結合時如何導致實際的 bug。
在 ES6 中,JavaScript 引入了 let 和 const 宣告,除了暫時性死區等特性外,它們還允許你建立塊級作用域變數。
if (Math.random() > 0.5) {
const x = 1;
} else {
const x = 2;
}
console.log(x); // ReferenceError: x is not defined
實質上,ES6 中程式碼塊最終被視為作用域,但僅限於你使用 let 或 const 宣告變數的情況。此外,ES6 還引入了模組,它引入了另一種作用域。閉包能夠捕獲所有這些作用域中的變數,我們將在稍後介紹。
閉包
請看下面的程式碼示例
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 函式
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,並返回 x 和 y 的和。
本質上,makeAdder 是一個函式工廠。它建立能夠將其引數與特定值相加的函式。在上面的示例中,函式工廠建立了兩個新函式——一個將其引數與五相加,另一個將其引數與 10 相加。
add5 和 add10 都形成了閉包。它們共享相同的函式體定義,但儲存不同的詞法環境。在 add5 的詞法環境中,x 是 5,而在 add10 的詞法環境中,x 是 10。
實際應用中的閉包
閉包很有用,因為它們允許你將資料(詞法環境)與操作該資料的函式關聯起來。這與面向物件程式設計有明顯的相似之處,在面向物件程式設計中,物件允許你將資料(物件的屬性)與一個或多個方法關聯起來。
因此,你可以在任何通常只使用一個方法的物件的地方使用閉包。
你可能想在網路上這樣做的情況尤其常見。前端 JavaScript 中編寫的大部分程式碼都是事件驅動的。你定義一些行為,然後將其附加到由使用者觸發的事件(例如點選或按鍵)。程式碼作為回撥(一個響應事件執行的單一函式)附加。
例如,假設我們想在頁面上新增按鈕來調整文字大小。一種方法是指定 body 元素的字型大小(以畫素為單位),然後使用相對 em 單位設定頁面上其他元素(例如標題)的大小。
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 () => {
document.body.style.fontSize = `${size}px`;
};
}
const size12 = makeSizer(12);
const size14 = makeSizer(14);
const size16 = makeSizer(16);
size12、size14 和 size16 現在是分別將正文文字大小調整為 12、14 和 16 畫素的函式。你可以將它們附加到按鈕上,如以下程式碼示例所示。
document.getElementById("size-12").onclick = size12;
document.getElementById("size-14").onclick = size14;
document.getElementById("size-16").onclick = size16;
<button id="size-12">12</button>
<button id="size-14">14</button>
<button id="size-16">16</button>
<p>This is some text that will change size when you click the buttons above.</p>
使用閉包模擬私有方法
像 Java 這樣的語言允許你將方法宣告為私有的,這意味著它們只能由同一類中的其他方法呼叫。
在類出現之前,JavaScript 沒有一種原生的方式來宣告私有方法,但可以使用閉包來模擬私有方法。私有方法不僅對於限制對程式碼的訪問有用。它們還提供了一種管理全域性名稱空間的強大方式。
以下程式碼說明了如何使用閉包來定義可以訪問私有函式和變數的公共函式。請注意,這些閉包遵循模組設計模式。
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.increment、counter.decrement 和 counter.value。
共享的詞法環境是在一個匿名函式體內建立的,該函式一旦定義立即執行(也稱為IIFE)。詞法環境包含兩個私有項:一個名為 privateCounter 的變數和一個名為 changeBy 的函式。你無法從匿名函式外部訪問這些私有成員。相反,你透過從匿名包裝器返回的三個公共函式間接訪問它們。
這三個公共函式形成了共享相同詞法環境的閉包。由於 JavaScript 的詞法作用域,它們都可以訪問 privateCounter 變數和 changeBy 函式。
function makeCounter() {
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 變數。每次呼叫其中一個計數器時,其詞法環境都會透過更改此變數的值而改變。一個閉包中變數值的更改不會影響另一個閉包中的值。
注意:以這種方式使用閉包提供了通常與面向物件程式設計相關的好處。特別是,資料隱藏和封裝。
閉包作用域鏈
巢狀函式對外部函式作用域的訪問包括外部函式的封閉作用域——有效地建立了一個函式作用域鏈。為了演示,請考慮以下示例程式碼。
// 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
你也可以不使用匿名函式來編寫
// 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 上建立了一個閉包
function outer() {
let getY;
{
const y = 6;
getY = () => y;
}
console.log(typeof y); // undefined
console.log(getY()); // 6
}
outer();
模組上的閉包可能更有趣。
// myModule.js
let x = 5;
export const getX = () => x;
export const setX = (val) => {
x = val;
};
這裡,模組匯出一對 getter-setter 函式,這些函式閉包在模組作用域變數 x 上。即使 x 無法直接從其他模組訪問,也可以使用這些函式進行讀寫。
import { getX, setX } from "./myModule.js";
console.log(getX()); // 5
setX(6);
console.log(getX()); // 6
閉包也可以關閉匯入的值,這些值被視為即時繫結,因為當原始值改變時,匯入的值也會相應改變。
// myModule.js
export let x = 1;
export const setX = (val) => {
x = val;
};
// closureCreator.js
import { x } from "./myModule.js";
export const getX = () => x; // Close over an imported live binding
import { getX } from "./closureCreator.js";
import { setX } from "./myModule.js";
console.log(getX()); // 1
setX(2);
console.log(getX()); // 2
在迴圈中建立閉包:一個常見錯誤
在引入 let 關鍵字之前,當你在迴圈中建立閉包時,一個常見的問題發生了。為了演示,請考慮以下示例程式碼。
<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>
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();
helpText 陣列定義了三個有用的提示,每個提示都與文件中輸入欄位的 ID 相關聯。迴圈遍歷這些定義,為每個定義設定一個 onfocus 事件,該事件顯示關聯的幫助方法。
如果你嘗試這段程式碼,你會發現它沒有按預期工作。無論你將焦點放在哪個欄位,都會顯示關於你年齡的訊息。
造成這種情況的原因是,賦給 onfocus 的函式形成了閉包;它們由函式定義和從 setupHelp 函式作用域捕獲的環境組成。迴圈建立了三個閉包,但每個閉包都共享相同的單一詞法環境,其中有一個值不斷變化的變數(item)。這是因為變數 item 是用 var 宣告的,因此由於變數提升而具有函式作用域。item.help 的值是在執行 onfocus 回撥時確定的。由於迴圈屆時已經完成,變數 item 物件(由所有三個閉包共享)已指向 helpText 列表中的最後一個條目。
在這種情況下,一個解決方案是使用更多的閉包:特別是,像前面描述的那樣使用一個函式工廠。
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();
這按預期工作。回撥不再共享一個單一的詞法環境,makeHelpCallback 函式為每個回撥建立了一個新的詞法環境,其中 help 引用了 helpText 陣列中對應的字串。
另一種使用匿名閉包來編寫上述程式碼的方法是
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();
如果你不想使用更多的閉包,你可以使用 let 或 const 關鍵字。
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,因此每個閉包都繫結塊作用域變數,這意味著不需要額外的閉包。
如果你正在編寫現代 JavaScript,你可以考慮除了普通的 for 迴圈之外的更多替代方案,例如使用 for...of 迴圈並將 item 宣告為 let 或 const,或者使用 forEach() 方法,這兩種方法都避免了閉包問題。
for (const item of helpText) {
document.getElementById(item.id).onfocus = () => {
document.getElementById("help").textContent = item.help;
};
}
helpText.forEach((item) => {
document.getElementById(item.id).onfocus = () => {
showHelp(item.help);
};
});
效能注意事項
如前所述,每個函式例項都管理自己的作用域和閉包。因此,如果特定任務不需要閉包,則不應在其他函式內部不必要地建立函式,因為它會對指令碼效能(包括處理速度和記憶體消耗)產生負面影響。
例如,在建立新物件/類時,方法通常應該與物件的原型關聯,而不是定義在物件建構函式中。原因是每次呼叫建構函式時,方法都會被重新分配(即,每次物件建立時)。
請看以下情況
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;
};
在前面的兩個示例中,繼承的原型可以被所有物件共享,並且方法定義不需要在每次物件建立時都發生。有關更多資訊,請參閱繼承與原型鏈。