本文翻譯自 MDN ( Mozilla Developer Network ):
- 原文地址:MDN
- 譯文地址:shixinzhang 的博客
讀完本文你將瞭解到:
詞法作用域
考慮如下代碼:
function init() {
var name = 'Mozilla'; // name 是 init 函數創建的局部變量
function displayName() { // displayName() 是函數內部方法,一個閉包
alert(name); // 它使用了父函數聲明的變量
}
displayName();
}
init();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
init()
函數創建了本地變量 name
和函數 displayName()
。
displayName()
是定義在 init()
內部的內部函數,因此只能在 init()
函數內被訪問。 displayName()
沒有內部變量,但是由於內部函數可以訪問外部函數的變量, displayName()
可以訪問 init()
中的變量 name
。
運行上述代碼,我們可以看到 name
的值成功地被打印出來。
這是“詞法作用域”(其描述了 JS 解析器如何處理嵌套函數中的變量)的一個例子。
詞法作用域是指一個變量在源碼中聲明的位置作爲它的作用域。同時嵌套的函數可以訪問到其外層作用域中聲明的變量。
閉包
現在看一下下面的代碼:
function makeFunc() {
var name = 'Mozilla';
function displayName() {
alert(name);
}
return displayName;
}
var myFunc = makeFunc();
myFunc();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
運行上面的代碼會和第一個例子有同樣的結果。不同的是 - 同時很有趣的是- 內部函數 displayName()
在執行前先被外部函數作爲返回值返回了。
乍一看,這個代碼雖然能執行卻並不直觀。在一些編程語言中,函數內的局部變量只在函數執行期間存活。一旦 makeFunc()
函數執行完畢,你可能覺得 name
變量就不能存在了。然而,從代碼的運行結果來看,JavaScript 跟我們前面說到的“一些編程語言”關於變量明顯有不同之處。
上面代碼的“不同之處”就在於,makeFunc()
返回了一個閉包。
閉包由函數和它的詞法環境組成。這個環境指的是函數創建時,它可以訪問的所有變量。在上面的例子中,myFunc
引用了一個閉包,這個閉包由 displayName()
函數和閉包創建時存在的 “Mozilla” 字符串組成。由於 displayName()
持有了 name
的引用,myFunc
持有了 displayName()
的引用,因此 myFunc
調用時,name
還是處於可以訪問的狀態。
下面是一個更有趣的例子:
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
上面的例子中,makeAdder()
接受一個參數 x,然後返回一個函數,它的參數是 y,返回值是 x+y。
本質上,makeAdder()
是一個函數工廠 — 爲它傳入一個參數就可以創建一個參數與其他值求和的函數。
上面的例子中我們使用函數工廠創建了兩個函數,一個將會給參數加 5,另一個加 10。
add5
和 add10
都是閉包。他們使用相同的函數定義,但詞法環境不同。在 add5 中,x 是 5;add10 中 x 是 10。
閉包實戰場景之回調
閉包有用之處在於它可以將一些數據和操作它的函數關聯起來。這和麪向對象編程明顯相似。在面對象編程中,我們可以將某些數據(對象的屬性)與一個或者多個方法相關聯。
因此,當你想只用一個方法操作一個對象時,可以使用閉包。
在 web 編程時,你使用閉包的場景可能會很多。大部分前端 JavaScript 代碼都是“事件驅動”的:我們定義行爲,然後把它關聯到某個用戶事件上(點擊或者按鍵)。我們的代碼通常會作爲一個回調(事件觸發時調用的函數)綁定到事件上。
比如說,我們想要爲一個頁面添加幾個用於調整字體大小的按鈕。一種方法是以像素爲單位指定 body 元素的 font-size,然後通過相對的 em 單位設置頁面中其它元素(例如頁眉)的字號。
這裏是 CSS 代碼:
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 12px;
}
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.2em;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
我們修改字體尺寸的按鈕可以修改 body 元素的 font-size 屬性,而由於我們使用相對單位,頁面中的其它元素也會相應地調整。
HTML 代碼:
<p>Some paragraph text</p>
<h1>some heading 1 text</h1>
<h2>some heading 2 text</h2>
<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>
- 1
- 2
- 3
- 4
- 5
- 6
- 7
JavaScript 代碼:
function makeSizer(size) {
return function() {
document.body.style.fontSize = size + 'px';
};
}
var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
size12, size14, 和 size16 現在可以分別調整 body 的字體到 12, 14, 16 像素。我們接下來可以把它們綁定到按鈕上:
document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;
- 1
- 2
- 3
現在分別點擊幾個按鈕,整個頁面的字體都會調整。
用閉包模擬私有方法
一些編程語言,比如 Java,可以創建私有方法(只能被同一個類中的其他方法調用的方法)。
JavaScript 不支持這種方法,但是我們可以使用閉包模擬實現。私有方法不僅可以限制代碼的訪問權限,還提供了管理全局命名空間的強大能力,避免非核心的方法弄亂了代碼的公共接口。
下面的代碼說明了如何使用閉包定義能訪問私有函數和私有變量的公有函數。這種方式也叫做模塊模式:
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
之前的例子中,每個閉包都有其獨自的詞法環境。但是這個例子中,三個方法 counter.value()
, counter.increment()
和 counter.decrement()
共享一個詞法環境。
這個共享的環境創建於一個匿名函數體內,該函數一經定義就立刻執行。環境中包含兩個私有項:名爲 privateCounter 的變量和名爲 changeBy 的函數。 它倆都無法在匿名函數外部直接訪問。必須通過匿名包裝器返回的對象的三個公共函數訪問。
多虧了 JavaScript 的詞法作用域,這三個函數可以訪問 privateCounter 和 changeBy(),使得它們三個閉包共享一個環境。
你可能注意到,上述代碼中我們在匿名函數中創建了 privateCounter,然後立即執行了這個函數,爲 privateCounter 賦了值,然後將結果返回給 counter。
我們也可以將這個函數保存到另一個變量中,以便創建多個計數器。
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();
alert(counter1.value()); /* Alerts 0 */
counter1.increment();
counter1.increment();
alert(counter1.value()); /* Alerts 2 */
counter1.decrement();
alert(counter1.value()); /* Alerts 1 */
alert(counter2.value()); /* Alerts 0 */
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
現在兩個計數器 counter1, counter2 持有不同的詞法環境,它倆有各自的 privateCounter 與值。調用其中一個計數器,不會影響另一個的值。
這樣使用閉包可以提供很多面向對象編程裏的好處,比如數據隱藏和封裝。
常見的錯誤:在循環中創建閉包
在 ECMAScrpit 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>
- 1
- 2
- 3
- 4
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'}
];
for (var i = 0; i < helpText.length; i++) {
var item = helpText[i]; //var 聲明的變量,它的作用域在函數體內,而不是塊內
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}
setupHelp();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
上述代碼中,helpText
是三個 id 與提示信息關聯對象的數組。在循環中,我們遍歷了 helpText 數組,爲數組中的 id 對應的組件添加了聚焦事件的響應。
如果你運行上面的代碼,就會發現,不論你選擇哪個輸入框,最終顯示的提示信息都是 “Your age”。
原因就是:我們賦值給 onfocus
事件的是三個閉包。這三個閉包由函數和 setUpHelp()
函數內的環境組成。
循環中創建了三個閉包,但是它們都使用了相同的詞法環境 item,item 有一個值會變的變量 item.help。
當 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();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
這樣運行結果就正確了。不像前面的例子,三個回調共享一個詞法環境,上面的代碼中,使用 makeHelpCallback()
函數爲每一個回調創建了一個新的詞法環境。在這些環境中,help 指向 helpText 數組中正確對應的字符串。
使用匿名函數解決這個問題的另外一種寫法是這樣的:
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);
}
})(); // 立即調用綁定函數,使用正確的值綁定到事件上;而不是使用循環結束的值
}
}
setupHelp();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
如果你不想使用更多的閉包,也可以使用 ES2015 中介紹的塊級作用域 let
關鍵字:
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();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
上面的代碼使用 let 而不是 var 修飾了變量 item,因此每個閉包綁定的是當前塊內的變量。不需要額外的閉包。
注意性能
在不是必需的情況下,在其它函數中創建函數是不明智的。因爲閉包對腳本性能具有負面影響,包括處理速度和內存消耗。
比如,在創建新的對象或者類時,方法通常應該關聯到對象的原型,而不是定義到對象的構造器中。因爲這將導致每次構造器被調用,方法都會被重新賦值一次(也就是說,創建每一個對象時都會重新爲方法賦值)。
舉個例子:
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
this.getName = function() {
return this.name;
};
this.getMessage = function() {
return this.message;
};
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
上面的代碼沒有利用閉包的優點,我們可以把它改寫成這樣:
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
}
MyObject.prototype = {
getName: function() {
return this.name;
},
getMessage: function() {
return this.message;
}
};
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
然而一般來說,不建議重定義原型。
下面的代碼將屬性添加到已有的原型上:
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;
};
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
但是,我們還可以將上面的代碼簡寫成這樣:
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
}
(function() {
this.getName = function() {
return this.name;
};
this.getMessage = function() {
return this.message;
};
}).call(MyObject.prototype);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
在前面的三個示例中,繼承的原型可以爲所有對象共享,且不必在每一次創建對象時重新定義方法。