函數(shù)和對其周圍狀態(tài)(lexical environment罩抗,詞法環(huán)境)的引用捆綁在一起構(gòu)成閉包(closure)。也就是說灿椅,閉包可以讓你從內(nèi)部函數(shù)訪問外部函數(shù)作用域套蒂。在 JavaScript 中,每當(dāng)函數(shù)被創(chuàng)建茫蛹,就會在函數(shù)生成時生成閉包操刀。
詞法作用域
請看下面的代碼:
function init() {
var name = "Mozilla"; // name 是一個被 init 創(chuàng)建的局部變量
function displayName() { // displayName() 是內(nèi)部函數(shù),一個閉包
alert(name); // 使用了父函數(shù)中聲明的變量
}
displayName();
}
init();
init()
創(chuàng)建了一個局部變量 name
和一個名為 displayName()
的函數(shù)婴洼。displayName()
是定義在 init()
里的內(nèi)部函數(shù)骨坑,并且僅在 init()
函數(shù)體內(nèi)可用。請注意柬采,displayName()
沒有自己的局部變量欢唾。然而,因為它可以訪問到外部函數(shù)的變量粉捻,所以 displayName()
可以使用父函數(shù) init()
中聲明的變量 name
礁遣。
使用這個 JSFiddle 鏈接運行該代碼后發(fā)現(xiàn), displayName()
函數(shù)內(nèi)的 alert()
語句成功顯示出了變量 name
的值(該變量在其父函數(shù)中聲明)肩刃。這個詞法作用域的例子描述了分析器如何在函數(shù)嵌套的情況下解析變量名祟霍。詞法(lexical)一詞指的是,詞法作用域根據(jù)源代碼中聲明變量的位置來確定該變量在何處可用盈包。嵌套函數(shù)可訪問聲明于它們外部作用域的變量沸呐。
閉包
現(xiàn)在來考慮以下例子 :
function makeFunc() {
var name = "Mozilla";
function displayName() {
alert(name);
}
return displayName;
}
var myFunc = makeFunc();
myFunc();
運行這段代碼的效果和之前 init()
函數(shù)的示例完全一樣。其中不同的地方(也是有意思的地方)在于內(nèi)部函數(shù) displayName()
在執(zhí)行前呢燥,從外部函數(shù)返回崭添。
第一眼看上去,也許不能直觀地看出這段代碼能夠正常運行叛氨。在一些編程語言中呼渣,一個函數(shù)中的局部變量僅存在于此函數(shù)的執(zhí)行期間根暑。一旦 makeFunc()
執(zhí)行完畢,你可能會認(rèn)為 name
變量將不能再被訪問徙邻。然而,因為代碼仍按預(yù)期運行畸裳,所以在 JavaScript 中情況顯然與此不同缰犁。
原因在于,JavaScript中的函數(shù)會形成了閉包怖糊。 閉包是由函數(shù)以及聲明該函數(shù)的詞法環(huán)境組合而成的帅容。該環(huán)境包含了這個閉包創(chuàng)建時作用域內(nèi)的任何局部變量。在本例子中伍伤,myFunc
是執(zhí)行 makeFunc
時創(chuàng)建的 displayName
函數(shù)實例的引用并徘。displayName
的實例維持了一個對它的詞法環(huán)境(變量 name
存在于其中)的引用。因此扰魂,當(dāng) myFunc
被調(diào)用時麦乞,變量 name
仍然可用,其值 Mozilla
就被傳遞到alert
中劝评。
下面是一個更有意思的示例 — 一個 makeAdder
函數(shù):
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)
函數(shù),它接受一個參數(shù) x
蒋畜,并返回一個新的函數(shù)咕幻。返回的函數(shù)接受一個參數(shù) y
仅讽,并返回x+y
的值。
從本質(zhì)上講,makeAdder
是一個函數(shù)工廠 — 他創(chuàng)建了將指定的值和它的參數(shù)相加求和的函數(shù)读恃。在上面的示例中,我們使用函數(shù)工廠創(chuàng)建了兩個新函數(shù) — 一個將其參數(shù)和 5 求和低缩,另一個和 10 求和堪旧。
add5
和 add10
都是閉包。它們共享相同的函數(shù)定義辛润,但是保存了不同的詞法環(huán)境膨处。在 add5
的環(huán)境中,x
為 5砂竖。而在 add10
中真椿,x
則為 10。
實用的閉包
閉包很有用乎澄,因為它允許將函數(shù)與其所操作的某些數(shù)據(jù)(環(huán)境)關(guān)聯(lián)起來突硝。這顯然類似于面向?qū)ο缶幊獭T诿嫦驅(qū)ο缶幊讨兄眉茫瑢ο笤试S我們將某些數(shù)據(jù)(對象的屬性)與一個或者多個方法相關(guān)聯(lián)解恰。
因此锋八,通常你使用只有一個方法的對象的地方,都可以使用閉包护盈。
在 Web 中挟纱,你想要這樣做的情況特別常見。大部分我們所寫的 JavaScript 代碼都是基于事件的 — 定義某種行為腐宋,然后將其添加到用戶觸發(fā)的事件之上(比如點擊或者按鍵)紊服。我們的代碼通常作為回調(diào):為響應(yīng)事件而執(zhí)行的函數(shù)。
假如胸竞,我們想在頁面上添加一些可以調(diào)整字號的按鈕欺嗤。一種方法是以像素為單位指定 body
元素的 font-size
,然后通過相對的 em
單位設(shè)置頁面中其它元素(例如header
)的字號:
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 12px;
}
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.2em;
}
我們的文本尺寸調(diào)整按鈕可以修改 body
元素的 font-size
屬性卫枝,由于我們使用相對單位煎饼,頁面中的其它元素也會相應(yīng)地調(diào)整。
以下是 JavaScript:
function makeSizer(size) {
return function() {
document.body.style.fontSize = size + 'px';
};
}
var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);
size12
校赤,size14
和 size16
三個函數(shù)將分別把 body
文本調(diào)整為 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>
<iframe frameborder="0" height="200" src="https://jsfiddle.net/cubr4hs0/embedded/" width="100%" style="margin: 0px; padding: 0px; border: 0px; max-width: 100%;"></iframe>
用閉包模擬私有方法
編程語言中,比如 Java积蔚,是支持將方法聲明為私有的意鲸,即它們只能被同一個類中的其它方法所調(diào)用。
而 JavaScript 沒有這種原生支持尽爆,但我們可以使用閉包來模擬私有方法怎顾。私有方法不僅僅有利于限制對代碼的訪問:還提供了管理全局命名空間的強(qiáng)大能力,避免非核心的方法弄亂了代碼的公共接口部分漱贱。
下面的示例展現(xiàn)了如何使用閉包來定義公共函數(shù)槐雾,并令其可以訪問私有函數(shù)和變量。這個方式也稱為 模塊模式(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 */
在之前的示例中幅狮,每個閉包都有它自己的詞法環(huán)境募强;而這次我們只創(chuàng)建了一個詞法環(huán)境,為三個函數(shù)所共享:Counter.increment崇摄,``Counter.decrement
和 Counter.value
擎值。
該共享環(huán)境創(chuàng)建于一個立即執(zhí)行的匿名函數(shù)體內(nèi)。這個環(huán)境中包含兩個私有項:名為 privateCounter
的變量和名為 changeBy
的函數(shù)逐抑。這兩項都無法在這個匿名函數(shù)外部直接訪問鸠儿。必須通過匿名函數(shù)返回的三個公共函數(shù)訪問。
這三個公共函數(shù)是共享同一個環(huán)境的閉包。多虧 JavaScript 的詞法作用域进每,它們都可以訪問 privateCounter
變量和 changeBy
函數(shù)汹粤。
你應(yīng)該注意到我們定義了一個匿名函數(shù),用于創(chuàng)建一個計數(shù)器田晚。我們立即執(zhí)行了這個匿名函數(shù)嘱兼,并將他的值賦給了變量Counter
。我們可以把這個函數(shù)儲存在另外一個變量makeCounter
中贤徒,并用他來創(chuàng)建多個計數(shù)器遭京。
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 */
請注意兩個計數(shù)器 Counter1
和 Counter2
是如何維護(hù)它們各自的獨立性的。每個閉包都是引用自己詞法作用域內(nèi)的變量 privateCounter
泞莉。
每次調(diào)用其中一個計數(shù)器時,通過改變這個變量的值船殉,會改變這個閉包的詞法環(huán)境鲫趁。然而在一個閉包內(nèi)對變量的修改,不會影響到另外一個閉包中的變量利虫。
以這種方式使用閉包挨厚,提供了許多與面向?qū)ο缶幊滔嚓P(guān)的好處 —— 特別是數(shù)據(jù)隱藏和封裝。
在循環(huán)中創(chuàng)建閉包:一個常見錯誤
在 ECMAScript 2015 引入 let
關(guān)鍵字 之前糠惫,在循環(huán)中有一個常見的閉包創(chuàng)建問題疫剃。參考下面的示例:
<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 = function() {
showHelp(item.help);
}
}
}
setupHelp();
<iframe frameborder="0" height="200" src="https://jsfiddle.net/51brm6ps/embedded/" width="100%" style="margin: 0px; padding: 0px; border: 0px; max-width: 100%;"></iframe>
數(shù)組 helpText
中定義了三個有用的提示信息,每一個都關(guān)聯(lián)于對應(yīng)的文檔中的input
的 ID硼讽。通過循環(huán)這三項定義巢价,依次為相應(yīng)input
添加了一個 onfocus
事件處理函數(shù),以便顯示幫助信息固阁。
運行這段代碼后壤躲,您會發(fā)現(xiàn)它沒有達(dá)到想要的效果。無論焦點在哪個input
上备燃,顯示的都是關(guān)于年齡的信息碉克。
原因是賦值給 onfocus
的是閉包。這些閉包是由他們的函數(shù)定義和在 setupHelp
作用域中捕獲的環(huán)境所組成的并齐。這三個閉包在循環(huán)中被創(chuàng)建漏麦,但他們共享了同一個詞法作用域,在這個作用域中存在一個變量item况褪。這是因為變量item使用var進(jìn)行聲明撕贞,由于變量提升,所以具有函數(shù)作用域测垛。當(dāng)onfocus
的回調(diào)執(zhí)行時麻掸,item.help
的值被決定。由于循環(huán)在事件觸發(fā)之前早已執(zhí)行完畢赐纱,變量對象item
(被三個閉包所共享)已經(jīng)指向了helpText
的最后一項脊奋。
解決這個問題的一種方案是使用更多的閉包:特別是使用前面所述的函數(shù)工廠:
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();
<iframe frameborder="0" height="300" src="https://jsfiddle.net/v7gjv/12185/embedded/" width="100%" style="margin: 0px; padding: 0px; border: 0px; max-width: 100%;"></iframe>
這段代碼可以如我們所期望的那樣工作熬北。所有的回調(diào)不再共享同一個環(huán)境, makeHelpCallback
函數(shù)為每一個回調(diào)創(chuàng)建一個新的詞法環(huán)境诚隙。在這些環(huán)境中讶隐,help
指向 helpText
數(shù)組中對應(yīng)的字符串。
另一種方法使用了匿名閉包:
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);
}
})(); // 馬上把當(dāng)前循環(huán)項的item與事件回調(diào)相關(guān)聯(lián)起來
}
}
setupHelp();
如果不想使用過多的閉包久又,你可以用ES2015引入的let關(guān)鍵詞:
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++) {
let item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}
setupHelp();
這個例子使用let
而不是var
巫延,因此每個閉包都綁定了塊作用域的變量,這意味著不再需要額外的閉包地消。
另一個可選方案是使用 forEach()
來遍歷helpText
數(shù)組并給每一個[<p>](https://wiki.developer.mozilla.org/en-US/docs/Web/HTML/Element/p "The HTML <p> element represents a paragraph.")
添加一個監(jiān)聽器炉峰,如下所示:
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)'}
];
helpText.forEach(function(text) {
document.getElementById(text.id).onfocus = function() {
showHelp(text.help);
}
});
}
setupHelp();
性能考量
如果不是某些特定任務(wù)需要使用閉包,在其它函數(shù)中創(chuàng)建函數(shù)是不明智的脉执,因為閉包在處理速度和內(nèi)存消耗方面對腳本性能具有負(fù)面影響疼阔。
例如,在創(chuàng)建新的對象或者類時半夷,方法通常應(yīng)該關(guān)聯(lián)于對象的原型婆廊,而不是定義到對象的構(gòu)造器中。原因是這將導(dǎo)致每次構(gòu)造器被調(diào)用時巫橄,方法都會被重新賦值一次(也就是說淘邻,對于每個對象的創(chuàng)建,方法都會被重新賦值)湘换。
考慮以下示例:
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: function() {
return this.name;
},
getMessage: function() {
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;
};
在前面的兩個示例中署恍,繼承的原型可以為所有對象共享崎溃,不必在每一次創(chuàng)建對象時定義方法。參見 對象模型的細(xì)節(jié) 一章可以了解更為詳細(xì)的信息盯质。
該文章轉(zhuǎn)自MDN