閉包真的是一個談爛掉的內容。說到閉包,自然就涉及到執行環境、變量對象以及作用域鏈。湯姆大叔翻譯的《深入理解JavaScript系列》很好,幫我解決了一直以來似懂非懂的很多問題,包括閉包。下面就給自己總結一下。包括參考大叔的譯文以及《JavaScript高級程序設計(第3版)》,一些例子引用自它們。
附上大叔的鏈接:《深入理解JavaScript系列》
一、執行環境(或“執行上下文”,意義一樣)
首先說下ECMAScript可執行代碼的類型包括:全局代碼、函數代碼、eval()代碼。
每當執行流轉到可執行代碼時,即會進入一個執行環境。活動的執行環境構成一個棧:棧的底部始終是全局環境,頂部是當前活動的執行環境。
❶全局執行環境是最外圍的一個執行環境。在瀏覽器中,全局環境就是window對象,因此所有全局變量和函數都是作為window對象的屬性和方法創建的。
❷每個函數都有自己的執行環境。當執行流進入一個函數時,函數的環境被推入棧中。而在函數執行之后,棧將其環境彈出,把控制權返回給之前的執行環境。某個執行環境中的代碼執行完后,該環境銷毀,保存在其中的所有變量和函數定義也隨之銷毀。而全局執行環境直到應用程序退出才會被銷毀。
❸eval的執行環境與調用環境的執行環境相同。
二、變量對象
我們知道變量和執行環境有着密切的關系:
var a = 10; // 全局上下文中的變量 (function () { var b = 20; // function上下文中的局部變量 })(); alert(a); // 10 alert(b); // 全局變量 "b" 沒有聲明
而且我們也知道在JS里沒有塊級作用域這一說法,ES規范指出獨立作用域只能通過函數(function)代碼類型的執行環境創建。也就是說,像for循環並不能創建一個局部環境:
for (var k in {a: 1, b: 2}) { alert(k); } alert(k); // 盡管循環已經結束但變量k依然在當前作用域
既然變量與執行環境相關,那變量自己應該知道它的數據存放在哪里,並知道如何訪問。這就引出了“變量對象”這個概念。
每個執行環境都有一個與之關聯的變量對象,這個對象存儲着在環境中定義的以下內容:
1. 函數的形參
2. var聲明的變量
3. 函數聲明(不包括函數表達式)
舉例來說,用一個普通對象來表示變量對象,它是執行環境的一個屬性:
執行環境 = { 變量對象:{ //環境中的數據 } };
例如:
var a = 10; function test(x) { var b = 20; }; test(30);
對應的變量對象為:
// 全局執行環境的變量對象 全局環境的變量對象= { a: 10, test: 指向test()函數 }; // test函數執行環境的變量對象 test函數環境的變量對象 = { x: 30, b: 20 };
那么,不同執行環境中的變量對象的初始化是怎樣的呢?下面詳細看一下:
❶全局環境中的變量對象
先看下全局對象的明確定義:
全局對象 是在進入任何執行環境之前就已經創建了的對象。
這個對象只存在一份,它的屬性在程序中的任何地方都可以訪問,全局對象的生命周期終止於程序退出那一刻。
全局對象初始創建階段,將Math、String等作為自身屬性,初始化如下:
globla = { Math: String: ... ... window:globla //引用自身 };
在這里,變量對象就是全局對象自己。
❷函數環境中的變量對象
在函數執行環境中,“活動對象” 扮演着變量對象這個角色。活動對象是在進入函數執行環境時創建的,它通過函數的arguments屬性初始化:
活動對象 = { arguments: //是個對象,包括callee、length等屬性 };
理解了變量對象的初始化之后,下面就是關於變量對象的核心了。
環境中的代碼,被分為兩個階段來處理:進入執行環境 、執行代碼。變量對象的修改變化與這兩個階段緊密相關。
這2個階段的處理是一般行為,和環境的類型無關(即,在全局環境和函數環境中的表現是一樣的)。
①進入環境
當進入執行環境時(代碼執行之前),變量對象已包含下列屬性(上面有提到):
①函數的所有形參(如果是在函數執行環境中。因為全局環境沒有形參。)
————由 形參名稱 和 對應值 組成,作為變量對象的屬性。如果沒有傳遞對應的參數,將undefined作為對應值。
②所有函數聲明(注意是聲明,函數表達式不算。)
————由 函數名 和 對應值(函數對象)組成,作為變量對象的屬性。如果變量對象已經存在同名的屬性,則覆蓋這個屬性。
③所有變量聲明(由var聲明的變量)
————由 變量名 和 對應值(undefined) 組成,作為變量對象的屬性。如果變量名與已經聲明的形參或函數相同,則變量聲明不會干擾已經存在的這類屬性。
————注意:此時的對應值是undefined。
讓我們來看一個例子:
function test(a, b) { alert(c); //undefined alert(d); //function d() {} alert(e); //undefined alert(x); //出錯 var c = 10; function d() {} var e = function _e() {}; (function x() {}); } test(10); //
我們考慮當進入帶有參數10的test函數環境時(代碼執行之前),活動對象表現如下:
活動對象(test) = { a: 10, b: undefined, c: undefined, d:指向函數d, e: undefined };
注意,活動對象里不包含函數x。這是因為x是一個函數表達式而不是函數聲明,函數表達式不會影響變量對象(在這里是活動對象)。函數_e同樣是函數表達式,但是我們注意到它分配給了變量e,所以可以通過名稱e來訪問。
在這之后,將進入處理代碼的第二個階段:執行代碼。
②執行代碼
這個階段內,變量/活動對象已經擁有了屬性(不過,並不是所有屬性都有值,就像上面那個例子,大部分屬性的值還是系統默認的undefined)。
繼續上面那個例子,活動對象在“執行代碼”這個階段被修改如下():
AO(test) = { a: 10, b: undefined, //沒有相應該參數傳入,undefined c: 10, //之前是undefined d: 指向函數d, e: 指向函數表達式_e //之前是undefined };
注意此時,函數表達式_e保存到了已聲明的變量e上,但函數表達式"x"本身不存在於活動對象中,也就是說,如果嘗試調用函數"x",無論在函數定義之前或之后,都會出現 “x is not defined”的錯誤。
理解了以上內容之后,再來看一個例子:
alert(x); // function var x = 10; alert(x); // 10 x = 20; function x() {}; alert(x); // 20
為什么第一個alert(x)的值是function,而且它還是在x聲明之前訪問的x?為什么不是10或20呢?
現在我們知道,函數聲明是在進入環境時填入活動對象的,同一時間,還有一個變量聲明'x',但是正如前面所說,變量聲明在順序上跟在函數聲明和形參聲明之后。即,在進入環境階段,變量聲明不會干擾變量對象中已經存在的同名函數或形參聲明。所以,就這個例子來說,在進入環境時,變量對象的結構如下:
變量對象 = { x:指向函數x //如果function x沒有已經聲明的話,這時的x應該是undefined };
緊接着,在代碼執行階段,變量對象作如下修改:
變量對象['x'] = 10; 變量對象['x'] = 20; //可以在第二、三個alert看到這個結果
再看一個例子:
if (true) { var a = 1; } else { var b = 2; } //變量是在進入環境階段放入變量對象的,雖然else部分永遠不會執行, //但是不管怎樣,變量b仍然存在於變量對象中。 alert(a); //1 alert(b); //undefined,不是b未聲明,而是b的值是undefined
另外,關於var聲明變量和不用var聲明:
大叔的譯文中指出:任何時候,變量只能通過var關鍵字才能聲明。
像a = 10;這僅僅是給全局對象創建了一個新屬性(但它不是變量)。它之所以能成為全局對象的屬性,完全是因為全局對象===全局變量對象。看例子:
alert(a); // undefined alert(b); // "b" 沒有聲明,出錯 b = 10; var a = 20;
進入環境階段:
變量對象 = {
a: undefined
};
可以看到,因為b不是一個變量,所以在這個階段根本就沒有b,b將只在代碼執行階段才會出現,但在這里,還未執行到那就出錯了。
還有一個要注意的:var聲明的變量,相對於屬性(如a = 10;或window.a = 10;),變量的[[Configurable]]特性值為false,即不能通過delete刪除,而屬性則可以。
三、作用域鏈
現在我們已經知道,一個執行環境的數據(變量、函數聲明和函數形參)作為屬性存儲在變量對象中。
同時也知道,變量對象在每次進入環境時創建,並填入初始值,值的更新出現在代碼執行階段。
下面的內容討論作用域鏈。
如果要簡要地描述並展示其重點,那么作用域鏈大多數與內部函數相關。
我們可以創建內部函數,甚至能從父函數中返回這些函數。
var x = 10; function foo() { var y = 20; function bar() { alert(x + y); } return bar; } foo()(); // 30
很明顯每個環境擁有自己的變量對象:對於全局環境,它是全局對象自身;對於函數,它是活動對象。
作用域鏈正是內部環境所有變量對象(包括父變量對象)的列表。此鏈用來在標識符解析中變量查找。
作用域鏈本質上,是一個指向變量對象的指針列表,它只引用但不實際包含變量對象。
對於上面這個例子,bar執行環境中的作用域鏈包括:bar變量對象、foo變量對象、全局變量對象。
函數執行環境中的作用域鏈在函數調用時創建,包含這個函數的活動對象和函數的[[scope]]屬性。示例如下:
活動的執行環境 = { 變量對象: {...}, // or 活動對象 this: thisValue, Scope: [ // 作用域鏈 // 它是所有變量對象的列表。 ] };
其中的Scope定義為:Scope = 被調用函數的活動對象 + [[scope]]。
這種標識符的解析過程,與函數的生命周期相關,下面詳細討論。
(1)函數的生命周期
函數的生命周期分為創建和激活(調用時)兩個階段。
❶函數創建
讓我們先看看在全局環境中的變量和函數聲明(這里的變量對象就是全局對象自身,我們懂的。)
var x = 10; function foo() { var y = 20; alert(x + y); } foo(); // 30
函數激活時,得到了正確的也是預期中的結果。但我們注意到,變量y在函數foo中定義(意味着它在foo的活動對象中),但是x並未在foo環境中定義,相應地,它不會添加到foo的活動對象中。那么,foo是如何訪問到變量x的?其實我們大都知道函數能訪問更高一層的環境中的變量對象,事實也是如此,而這種機制正是通過函數內部的[[scope]]屬性實現的。
[[scope]]是所有父變量對象的層級鏈,處於當前函數環境,在函數創建時存在於其中。
注意重要的一點:[[scope]]屬性在函數創建時被存儲,永遠不變,直到函數銷毀。函數可以不被調用,但這個屬性一直存在。
且,與作用域鏈相比,作用域鏈是執行環境的一個屬性,而[[scope]]是函數的屬性。
上面的例子,函數foo的[[scope]]如下:
foo.[[Scope]] = [ 全局執行環境.變量對象 // === Global ];
繼續,我們知道在函數調用時進入執行環境,這時活動對象被創建,this、作用域鏈被確定。下面詳細考慮這個時刻。
❷函數激活
正如上面提到的,進入環境創建變量/活動對象之后,環境的Scope屬性(即作用域鏈)定義為:Scope = 變量/活動對象 + [[scope]]。
這個定義意思是:活動對象是被添加到[[scope]]前端,在作用域鏈中處理第一位。這很重要,對於標識符的查找,是從自身變量對象開始的,逐漸往父變量對象查找。
(2)通過構造函數創建的函數的[[scope]]
在上面的例子中,我們看到,在函數創建時,函數獲得[[scope]]屬性,該屬性存儲着所有父環境的變量/活動對象。但有一個例外,那就是通過構造函數創建的函數。
var x = 10; function foo() { var y = 20; function barFD() { // 函數聲明 alert(x); alert(y); } var barFE = function () { // 函數表達式 alert(x); alert(y); }; var barFn = Function('alert(x); alert(y);'); barFD(); // 10, 20 barFE(); // 10, 20 barFn(); // 10, "y" is not defined } foo();
從以上例子中,我們看出問題所在:通過構造函數創建的函數,它的[[scope]]僅包含全局對象。
另外關於eval,實踐中很少用到eval,但有一點提示,eval代碼的環境與當前的調用環境擁有相同的作用域鏈。
(3)延長作用域鏈
有兩個能延長作用域鏈的方法:with聲明和catch語句。它們添加到作用域鏈的最前端(比被調用函數的活動對象還要靠前)。
如果發生其中一個,作用域鏈作如下修改:
Scope = withObject|catchObject +活動/變量對象 + [[Scope]]
看個例子:
var x = 10, y = 10; with ({x: 20}) { var x = 30, y = 30; alert(x); // 30 alert(y); // 30 } alert(x); // 10 alert(y); // 30
//1. x = 10,y = 10;
//2. 進入環境,對象{x:20}添加到作用域鏈的前端。
//3. 執行代碼,x為20,變為30,y為10,變為30。
//4.with聲明完成后,對象被移除,那個因with對象而改變的x=30也被移除。
//最后兩個alert,x保持最初不變,y在with里已發生改變。
四、閉包
到了這里,其實如果對前面的[[scope]]和作用域鏈完全理解的話,閉包也就懂了。
大叔的譯文對閉包給出的2個定義是:
❶從理論角度:所有函數都是閉包。因為它們在創建的時候就將所有父環境的數據保存起來了。哪怕是簡單的全局變量也是如此,因為在函數中訪問全局變量就相當於在訪問自由變量(指不在參數聲明,也不在局部聲明的變量),這個時候使用最外層的作用域。
❷從實踐角度:以下函數才算是閉包:
①即使創建它的環境銷毀,它仍然存在(比如,內部函數從父函數返回);②在代碼中引用了自由變量。
閉包的性能問題總被提及,現在我們知道原因了:創建閉包的父環境即使被銷毀了,但閉包仍然引用着父環境的變量對象,也就是說需要繼續維護着這個變量對象的內存。
下面我們再來具體看一下。
var x = 10; function foo() { alert(x); } (function (funArg) { var x = 20; // 變量"x"在foo中靜態保存的,在該函數創建的時候就保存了 funArg(); // 10, 而不是20 })(foo);
我們已經知道,創建foo函數的父級環境(在這里是全局環境)的數據是保存在foo函數的內部屬性[[scope]]中的。
這里還要注意的是:同一個父環境創建的閉包是共用一個[[scope]]屬性的。也就是說,某個閉包對其中[[scope]]的變量的修改會影響到其他閉包對其變量的讀取。
var firstClosure; var secondClosure; function foo() { var x = 1; firstClosure = function () { return ++x; }; secondClosure = function () { return --x; }; x = 2; // 影響"x", 在2個閉包公有的[[Scope]]中 alert(firstClosure()); // 3, 通過第一個閉包的[[Scope]] } foo(); alert(firstClosure()); // 4 alert(secondClosure()); // 3
關於這個問題,大叔的譯文和《JS高級》里都有一個例子:
var data = []; for (var k = 0; k < 3; k++) { data[k] = function () { alert(k); }; } data[0](); // 3, 而不是0 data[1](); // 3, 而不是1 data[2](); // 3, 而不是2
這就是閉包共用一個[[scope]]的問題。可以按下面的方法解決:
var data = []; for (var k = 0; k < 3; k++) { data[k] = (function _helper(x) { return function () { alert(x); }; })(k); // 傳入"k"值 } // 現在結果是正確的了 data[0](); // 0 data[1](); // 1 data[2](); // 2
在上例中,每次_helper都會創建一個新的變量對象,其中含有參數x,其值就是傳遞進來的k值。此時,返回的函數的[[scope]]如下:
data[0].[[Scope]] === [ ... // 其它變量對象 父級環境中的活動對象: {data: [...], k: 3}, _helper環境中的活動對象: {x: 0} ]; data[1].[[Scope]] === [ ... // 其它變量對象 父級環境中的活動對象: {data: [...], k: 3}, _helper環境中的活動對象: {x: 1} ]; data[2].[[Scope]] === [ ... // 其它變量對象 父級環境中的活動對象: {data: [...], k: 3}, _helper環境中的活動對象: {x: 2} ];
要注意的是,如果在返回的函數中,要獲取k值,那么該值還會是3。
五、小結
總結得好長啊好長。因為我覺得一口氣將這幾個點連在一起梳理一下比較好。
嗯,就這樣吧。