MDN 對閉包的定義為:
閉包是指那些能夠訪問自由變量的函數。
那什麼是自由變量呢?
自由變量是指在函數中使用的,但既不是函數參數也不是函數的局部變量的變量。
由此,我們可以看出閉包共有兩部分組成:
閉包 = 函數 + 函數能夠訪問的自由變量
舉個例子:
var a = 1;
function foo() {
console.log(a);
}
foo();
foo 函數可以訪問變量 a,但是 a 既不是 foo 函數的局部變量,也不是 foo 函數的參數,所以 a 就是自由變量。
那麼,函數 foo + foo 函數訪問的自由變量 a 不就是構成了一個閉包嘛……
還真是這樣的!
所以在《JavaScript權威指南》中就講到:從技術的角度講,所有的JavaScript函數都是閉包。
咦,這怎麼跟我們平時看到的講到的閉包不一樣呢!?
彆著急,這是理論上的閉包,其實還有一個實踐角度上的閉包,讓我們看看湯姆大叔翻譯的關於閉包的文章中的定義:
ECMAScript中,閉包指的是:
- 從理論角度:所有的函數。因為它們都在創建的時候就將上層上下文的數據保存起來了。哪怕是簡單的全局變量也是如此,因為函數中訪問全局變量就相當於是在訪問自由變量,這個時候使用最外層的作用域。
- 從實踐角度:以下函數才算是閉包:
- 即使創建它的上下文已經銷燬,它仍然存在(比如,內部函數從父函數中返回)
- 在代碼中引用了自由變量
接下來就來講講實踐上的閉包。
分析
讓我們先寫個例子,例子依然是來自《JavaScript權威指南》,稍微做點改動:
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
var foo = checkscope();
foo();
首先我們要分析一下這段代碼中執行上下文棧和執行上下文的變化情況。
另一個與這段代碼相似的例子,在《JavaScript深入之執行上下文》中有著非常詳細的分析。如果看不懂以下的執行過程,建議先閱讀這篇文章。
這裡直接給出簡要的執行過程:
- 進入全局代碼,創建全局執行上下文,全局執行上下文壓入執行上下文棧
- 全局執行上下文初始化
- 執行 checkscope 函數,創建 checkscope 函數執行上下文,checkscope 執行上下文被壓入執行上下文棧
- checkscope 執行上下文初始化,創建變量對象、作用域鏈、this等
- checkscope 函數執行完畢,checkscope 執行上下文從執行上下文棧中彈出
- 執行 f 函數,創建 f 函數執行上下文,f 執行上下文被壓入執行上下文棧
- f 執行上下文初始化,創建變量對象、作用域鏈、this等
- f 函數執行完畢,f 函數上下文從執行上下文棧中彈出
瞭解到這個過程,我們應該思考一個問題,那就是:
當 f 函數執行的時候,checkscope 函數上下文已經被銷燬了啊(即從執行上下文棧中被彈出),怎麼還會讀取到 checkscope 作用域下的 scope 值呢?
以上的代碼,要是轉換成 PHP,就會報錯,因為在 PHP 中,f 函數只能讀取到自己作用域和全局作用域裡的值,所以讀不到 checkscope 下的 scope 值。(這段我問的PHP同事……)
然而 JavaScript 卻是可以的!
當我們瞭解了具體的執行過程後,我們知道 f 執行上下文維護了一個作用域鏈:
fContext = {
Scope: [AO, checkscopeContext.AO, globalContext.VO],
}
對的,就是因為這個作用域鏈,f 函數依然可以讀取到 checkscopeContext.AO 的值,說明當 f 函數引用了 checkscopeContext.AO 中的值的時候,即使 checkscopeContext 被銷燬了,但是 JavaScript 依然會讓 checkscopeContext.AO 活在內存中,f 函數依然可以通過 f 函數的作用域鏈找到它,正是因為 JavaScript 做到了這一點,從而實現了閉包這個概念。
所以,讓我們再看一遍實踐角度上閉包的定義:
- 即使創建它的上下文已經銷燬,它仍然存在(比如,內部函數從父函數中返回)
- 在代碼中引用了自由變量
在這裡再補充一個《JavaScript權威指南》英文原版對閉包的定義:
This combination of a function object and a scope (a set of variable bindings) in which the function’s variables are resolved is called a closure in the computer science literature.
閉包在計算機科學中也只是一個普通的概念,大家不要去想得太複雜。
必刷題
接下來,看這道刷題必刷,面試必考的閉包題:
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0]();
data[1]();
data[2]();
答案是都是 3,讓我們分析一下原因:
當執行到 data[0] 函數之前,此時全局上下文的 VO 為:
globalContext = {
VO: {
data: [...],
i: 3
}
}
當執行 data[0] 函數的時候,data[0] 函數的作用域鏈為:
data[0]Context = {
Scope: [AO, globalContext.VO]
}
data[0]Context 的 AO 並沒有 i 值,所以會從 globalContext.VO 中查找,i 為 3,所以打印的結果就是 3。
data[1] 和 data[2] 是一樣的道理。
所以讓我們改成閉包看看:
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = (function (i) {
return function(){
console.log(i);
}
})(i);
}
data[0]();
data[1]();
data[2]();
當執行到 data[0] 函數之前,此時全局上下文的 VO 為:
globalContext = {
VO: {
data: [...],
i: 3
}
}
跟沒改之前一模一樣。
當執行 data[0] 函數的時候,data[0] 函數的作用域鏈發生了改變:
data[0]Context = {
Scope: [AO, 匿名函數Context.AO globalContext.VO]
}
匿名函數執行上下文的AO為:
匿名函數Context = {
AO: {
arguments: {
0: 0,
length: 1
},
i: 0
}
}
data[0]Context 的 AO 並沒有 i 值,所以會沿著作用域鏈從匿名函數 Context.AO 中查找,這時候就會找 i 為 0,找到了就不會往 globalContext.VO 中查找了,即使 globalContext.VO 也有 i 的值(值為3),所以打印的結果就是0。
data[1] 和 data[2] 是一樣的道理。
閱讀更多 破影閣 的文章
關鍵字: 跳槽那些事兒 ECMAScript 閉包