函數(shù)都有自己的執(zhí)行環(huán)境,該環(huán)境定義了變量或者函數(shù)訪問數(shù)據(jù)的權(quán)限,當離開執(zhí)行環(huán)境后,該環(huán)境內(nèi)的變量會被銷毀。
function add() {
let a = 1;
console.log(a); // 1
}
console.log(a); // ReferenceError: a is not defined
上例a在 add() 函數(shù)的作用域內(nèi),能夠訪問,離開作用域后,就無法訪問了。
那有沒有辦法在 add() 函數(shù)外訪問a的值呢?
function add() {
const a = 1;
const addOne = function(b) { return b + a; }
return addOne;
}
const addOne = add();
console.log(addOne(1)); // 2
add() 函數(shù)執(zhí)行完畢之后,會從函數(shù)調(diào)用棧中被推出,同時局部變量a應(yīng)該被清理才對, 但我們調(diào)用 addOne(1) ,得到的結(jié)果卻是2。說明a在 add() 執(zhí)行結(jié)束后并沒有被銷毀,而是進入到了 addOne() 的作用域。
這里的 addOne() 函數(shù)被稱為匿名函數(shù)(anonymous function)也叫做閉包(closure)。
那么 JS 的閉包是如何實現(xiàn)對外部變量的存儲的呢?
作用域鏈
上面的例子中 addOne 函數(shù)獲得a的值,我們需要先弄清楚,它是拷貝了a的值到其作用域中,還是a沒有壓根被銷毀,而是給了 addOne 訪問的權(quán)限。
function add() {
let a = 1;
const addOne = function(b) { return b + a; }
++a;
return addOne;
}
const addOne = add();
console.log(addOne(1)); // 3
我們可以發(fā)現(xiàn)在a被 addOne 捕獲之后,再修改它的值, addOne() 的執(zhí)行結(jié)果也隨之變化,所以閉包不是拷貝變量的值,而是持有它的一個「引用」。
JS 在運行的時候,會為每一個執(zhí)行函數(shù)分配內(nèi)存空間,我們稱這個空間為作用域?qū)ο螅⊿cope Object)。當調(diào)用函數(shù)時,函數(shù)中分配的本地變量會被存儲在這個作用域?qū)ο笾?。我們無法直接使用代碼讀取這個作用域?qū)ο?,但是解析器在處理?shù)據(jù)的時候會在后臺使用它們。
JS 的函數(shù)是一等公民,聲明時可能會有嵌套關(guān)系,因此會同時存在多個作用域?qū)ο?,所有函?shù)的作用域?qū)ο髸?strong>環(huán)境棧所管理。環(huán)境棧中的作用域?qū)ο笫前错樞蛟L問的,最先能夠訪問的是當前函數(shù)的作用域,如果訪問的變量在當前作用域沒有,會訪問上一層作用域,直到找到全局作用域(Global)對象。如果訪問到全局作用域也沒有這個對象,會拋出ReferenceError的異常。這就是所謂的作用域鏈(scope chian)。
這跟原型繼承的概念非常相似,只不過在原型鏈頂端找到不到某個屬性時,返回是undefined。
閉包之所以能夠訪問到上一層函數(shù)中的局部變量,是因為當變量被捕獲之后,即使上一層函數(shù)調(diào)用完畢出棧了,但是它的作用域?qū)ο鬀]有被銷毀,所以仍然能夠被閉包訪問。
驗證
上面的理論是否正確,我們通過代碼來驗證一下。
function add(a) {
const addB = function(b) {
console.log(b);
const addC = function(c) {
return a + b + c;
}
return addC;
}
return addB;
}
const addOne = add(1);
const addTwo = addOne(1);
const addThree = addTwo(1);
這個例子中嵌套了兩層閉包,我們分別在第 3 行和第 5 行打上斷點。當斷住第 3 行代碼時,debuger 區(qū)域展示了 addB() 函數(shù)的作用域?qū)ο?Scope。

我們可以看到一個 Closure 屬性,它持有了 add() 函數(shù)作用域中的 a,說明 Closure 對象存儲了從外部捕獲的變量。
當斷住第 5 行進入下一層閉包,出現(xiàn)了兩個 Closure 對象。

從中可以看出,閉包的作用域?qū)ο髸鶕?jù)外部函數(shù)的層級,生成對應(yīng)的對象屬性來存儲變量。假如我們在 addB 函數(shù)中創(chuàng)建一個局部變量 a,覆蓋從 add 函數(shù)的捕獲的局部,對應(yīng)的 Closure(add) 屬性就不會生成了。
鬼畜的 var 關(guān)鍵字
JS 作用域?qū)ο蟮脑O(shè)計顛覆了我以往對程序語言的認知,比如說下面這段代碼,a 明明是個局部變量,但是在 if 語句外面卻能被訪問到它。
function testVar() {
if (1) {
var a = 1;
let b = 2;
}
console.log(a); // 1
console.log(b); // ReferenceError
}
testVar();

var 聲明的變量作用域會被提升到 Local,也就是當前函數(shù)的局部作用域中,而 let 聲明的變量保存在 Block 屬性中,當 if 語句執(zhí)行結(jié)束,這個 Block 會被銷毀,所以在 if 外就無法訪問到 b 變量。
最后我們來看一段有趣的代碼,思考一下輸出結(jié)果會是什么。
function buildList(list) {
var result = [];
for (var i = 0; i < list.length; i++) {
var item = 'item' + i;
result.push( function() {
console.log(item + ' ' + list[i])
});
}
return result;
}
function testList() {
var fnlist = buildList([1,2,3]);
for (var j = 0; j < fnlist.length; j++) {
fnlist[j]();
}
}
testList()
輸出結(jié)果為:
"item2 undefined"
"item2 undefined"
"item2 undefined"
我們使用循環(huán)將三個閉包存入數(shù)組,每次循環(huán)閉包都會捕獲了數(shù)組的下標 i,照理說再取出閉包執(zhí)行的時候,會順序輸出數(shù)組的元素,實際上卻沒有。
這是因為 var 關(guān)鍵字搞得鬼,被 var 關(guān)鍵字聲明的 i 和 item 變量,作用域被提升到 buildList 函數(shù)的整個作用域(Local)中,每次循環(huán)它們的值都會被覆蓋。對于同一層級的變量,閉包只會持有它的一個“引用”,所以在它執(zhí)行時只能訪問到的 i 和 item 的最新值 i = 3,item = item2。
如果將 i 和 item 都用 let 修飾,閉包捕獲的變量會存入閉包作用域?qū)ο蟮?Block 屬性,每次循環(huán) Block 屬性會被重新創(chuàng)建,并被數(shù)組持有。所以,遍歷數(shù)組能輸出預(yù)期的結(jié)果。
item0 1
item1 2
item2 3
總結(jié)
閉包能夠訪問外部函數(shù)的變量,即使變量已經(jīng)離開它所創(chuàng)建的環(huán)境,是因為外部變量會被閉包的作用域?qū)ο笏钟小i]包這種特性實現(xiàn)了嵌套函數(shù)之間數(shù)據(jù)的隱式傳遞。