原文:理解 JavaScript 閉包
要成為進階 JavaScript 程式員,就必須理解閉包。
本文結合 ECMA 262 規範詳解了閉包的內部工作機制,讓 JavaScript 編程人員對閉包的理解從“嵌套的函數”深入到“標識符解析、執行環境和範圍鏈”等等 JavaScript 對象背後的運行機制當中,真正領會到閉包的實質。
原文連結:JavaScript Closures
可列印版:JavaScript 閉包
目錄
- 簡介
- 對象屬性名稱解析
- 標識符解析、執行環境和範圍鏈
- 執行環境
- 範圍鏈與 [[scope]]
- 標識符解析
- 閉包
- 通過閉包可以做什嗎?
- 例 1:為函數引用設定延時
- 例 2:通過對象執行個體方法關聯函數
- 例 3:封裝相關的功能
- 其他例子
- 意外的閉包
- Internet Explorer 的記憶體流失問題
簡介
返回目錄
-
Closure
-
所謂“閉包”,指的是一個擁有許多變數和綁定了這些變數的環境的運算式(通常是一個函數),因而這些變數也是該運算式的一部分。
閉包是 ECMAScript (JavaScript)最強大的特性之一,但用好閉包的前提是必須理解閉包。閉包的建立相對容易,人們甚至會在不經意間建立閉包,但這些無意建立的閉包卻存在潛在的危害,尤其是在比較常見的瀏覽器環境下。如果想要揚長避短地使用閉包這一特性,則必須瞭解它們的工作機制。而閉包工作機制的實現很大程度上有賴於標識符(或者說對象屬性)解析過程中範圍的角色。
關於閉包,最簡單的描述就是 ECMAScript 允許使用內建函式--即函數定義和函數運算式位於另一個函數的函數體內。而且,這些內建函式可以訪問它們所在的外部函數中聲明的所有局部變數、參數和聲明的其他內建函式。當其中一個這樣的內建函式在包含它們的外部函數之外被調用時,就會形成閉包。也就是說,內建函式會在外部函數返回後被執行。而當這個內建函式執行時,它仍然必需訪問其外部函數的局部變數、參數以及其他內建函式。這些局部變數、參數和函式宣告(最初時)的值是外部函數返回時的值,但也會受到內建函式的影響。
遺憾的是,要適當地理解閉包就必須理解閉包背後啟動並執行機制,以及許多相關的技術細節。雖然本文的前半部分並沒有涉及 ECMA 262 規範指定的某些演算法,但仍然有許多無法迴避或簡化的內容。對於個別熟悉對象屬性名稱解析的人來說,可以跳過相關的內容,但是除非你對閉包也非常熟悉,否則最好是不要跳下面幾節。
對象屬性名稱解析
返回目錄
ECMAScript 認可兩類對象:原生(Native)對象和宿主(Host)對象,其中宿主對象包含一個被稱為內建對象的原生對象的子類(ECMA 262 3rd Ed Section 4.3)。原生對象屬於語言,而宿主對象由環境提供,比如說可能是文檔對象、DOM 等類似的對象。
原生對象具有鬆散和動態命名屬性(對於某些實現的內建對象子類別而言,動態性是受限的--但這不是太大的問題)。對象的命名屬性用於儲存值,該值可以是指向另一個對象(Objects)的引用(在這個意義上說,函數也是對象),也可以是一些基本的資料類型,比如:String、Number、Boolean、Null 或 Undefined。其中比較特殊的是 Undefined 類型,因為可以給對象的屬性指定一個 Undefined 類型的值,而不會刪除對象的相應屬性。而且,該屬性只是儲存著 undefined 值。
下面簡要介紹一下如何設定和讀取對象的屬性值,並最大程度地體現相應的內部細節。
值的賦予
返回目錄
對象的命名屬性可以通過為該命名屬性賦值來建立,或重新賦值。即,對於:
var objectRef = new Object(); //建立一個普通的 JavaScript 對象。
可以通過下面語句來建立名為 “testNumber” 的屬性:
objectRef.testNumber = 5;
/* - 或- */
objectRef[”testNumber”] = 5;
在賦值之前,對象中沒有“testNumber” 屬性,但在賦值後,則建立一個屬性。之後的任何指派陳述式都不需要再建立這個屬性,而只會重新設定它的值:
objectRef.testNumber = 8;
/* - or:- */
objectRef[”testNumber”] = 8;
稍後我們會介紹,Javascript 對象都有原型(prototypes)屬性,而這些原型本身也是對象,因而也可以帶有命名的屬性。但是,原型對象命名屬性的作用並不體現在賦值階段。同樣,在將值賦給其命名屬性時,如果對象沒有該屬性則會建立該命名屬性,否則會重設該屬性的值。
值的讀取
返回目錄
當讀取對象的屬性值時,原型對象的作用便體現出來。如果對象的原型中包含屬性訪問器(property accessor)所使用的屬性名稱,那麼該屬性的值就會返回:
/* 為命名屬性賦值。如果在賦值前對象沒有相應的屬性,那麼賦值後就會得到一個:*/
objectRef.testNumber = 8;
/* 從屬性中讀取值 */
var val = objectRef.testNumber;
/* 現在, - val - 中儲存著剛賦給對象命名屬性的值 8*/
而且,由於所有對象都有原型,而原型本身也是對象,所以原型也可能有原型,這樣就構成了所謂的原型鏈。原型鏈終止於鏈中原型為 null 的對象。Object
建構函式的預設原型就有一個 null 原型,因此:
var objectRef = new Object(); //建立一個普通的 JavaScript 對象。
建立了一個原型為 Object.prototype
的對象,而該原型自身則擁有一個值為 null 的原型。也就是說, objectRef
的原型鏈中只包含一個對象-- Object.prototype
。但對於下面的代碼而言:
/* 建立 - MyObject1 - 類型對象的函數*/
function MyObject1(formalParameter){
/* 給建立的對象添加一個名為 - testNumber - 的屬性
並將傳遞給建構函式的第一個參數指定為該屬性的值:*/
this.testNumber = formalParameter;
}
/* 建立 - MyObject2 - 類型對象的函數*/
function MyObject2(formalParameter){
/* 給建立的對象添加一個名為 - testString - 的屬性
並將傳遞給建構函式的第一個參數指定為該屬性的值:*/
this.testString = formalParameter;
}
/* 接下來的操作用 MyObject1 類的執行個體替換了所有與 MyObject2 類的執行個體相關聯的原型。而且,為 MyObject1 建構函式傳遞了參數 - 8 - ,因而其 - testNumber - 屬性被賦予該值:*/
MyObject2.prototype = new MyObject1( 8 );
/* 最後,將一個字串作為建構函式的第一個參數,建立一個 - MyObject2 - 的執行個體,並將指向該對象的引用賦給變數 - objectRef - :*/
var objectRef = new MyObject2( “String_Value” );
被變數 objectRef
所引用的 MyObject2
的執行個體擁有一個原型鏈。該鏈中的第一個對象是在建立後被指定給 MyObject2
建構函式的 prototype
屬性的 MyObject1
的一個執行個體。MyObject1
的執行個體也有一個原型,即與 Object.prototype
所引用的對象對應的預設的 Object 對象的原型。最後, Object.prototype
有一個值為 null 的原型,因此這條原型鏈到此結束。
當某個屬性訪問器嘗試讀取由 objectRef
所引用的對象的屬性值時,整個原型鏈都會被搜尋。在下面這種簡單的情況下:
var val = objectRef.testString;
因為 objectRef
所引用的 MyObject2
的執行個體有一個名為“testString”的屬性,因此被設定為“String_Value”的該屬性的值被賦給了變數 val
。但是:
var val = objectRef.testNumber;
則不能從 MyObject2
執行個體自身中讀取到相應的命名屬性值,因為該執行個體沒有這個屬性。然而,變數 val
的值仍然被設定為 8
,而不是未定義--這是因為在該執行個體中尋找相應的命名屬性失敗後,解釋程式會繼續檢查其原型對象。而該執行個體的原型對象是 MyObject1
的執行個體,這個執行個體有一個名為“testNumber”的屬性並且值為 8
,所以這個屬性訪問器最後會取得值 8
。而且,雖然 MyObject1
和 MyObject2
都沒有定義 toString
方法,但是當屬性訪問器通過 objectRef
讀取 toString
屬性的值時:
var val = objectRef.toString;
變數 val
也會被賦予一個函數的引用。這個函數就是在 Object.prototype
的 toString
屬性中所儲存的函數。之所以會返回這個函數,是因為發生了搜尋 objectRef
原型鏈的過程。當在作為對象的 objectRef
中發現沒有“toString”屬性存在時,會搜尋其原型對象,而當原型對象中不存在該屬性時,則會繼續搜尋原型的原型。而原型鏈中最終的原型是 Object.prototype
,這個對象確實有一個 toString
方法,因此該方法的引用被返回。
最後:
var val = objectRef.madeUpProperty;
返回 undefined
,因為在搜尋原型鏈的過程中,直至 Object.prototype
的原型--null,都沒有找到任何對象有名為“madeUpPeoperty”的屬性,因此最終返回 undefined
。
不論是在對象或對象的原型中,讀取命名屬性值的時候只返回首先找到的屬性值。而當為對象的命名屬性賦值時,如果對象自身不存在該屬性則建立相應的屬性。
這意味著,如果執行像 objectRef.testNumber = 3
這樣一條指派陳述式,那麼這個 MyObject2
的執行個體自身也會建立一個名為“testNumber”的屬性,而之後任何讀取該命名屬性的嘗試都將獲得相同的新值。這時候,屬性訪問器不會再進一步搜尋原型鏈,但 MyObject1
執行個體值為 8
的“testNumber”屬性並沒有被修改。給 objectRef
對象的賦值只是遮擋了其原型鏈中相應的屬性。
注意:ECMAScript 為 Object 類型定義了一個內部 [[prototype]]
屬性。這個屬性不能通過指令碼直接存取,但在屬性訪問器解析過程中,則需要用到這個內部 [[prototype]]
屬性所引用的對象鏈--即原型鏈。可以通過一個公用的 prototype
屬性,來對與內部的 [[prototype]]
屬性對應的原型對象進行賦值或定義。這兩者之間的關係在 ECMA 262(3rd edition)中有詳細描述,但超出了本文要討論的範疇。
標識符解析、執行環境和範圍鏈執行環境
返回目錄
執行環境是 ECMAScript 規範(ECMA 262 第 3 版)用於定義 ECMAScript 實現必要行為的一個抽象的概念。對如何?執行環境,規範沒有作規定。但由於執行環境中包含引用規範所定義結構的相關屬性,因此執行環境中應該保有(甚至實現)帶有屬性的對象--即使屬性不是公用屬性。
所有 JavaScript 代碼都是在一個執行環境中被執行的。全域代碼(作為內建的JS 檔案執行的代碼,或者 HTML
頁面載入的代碼)是在我稱之為“全域執行環境”的執行環境中執行的,而對函數的每次調用(
有可能是作為建構函式)同樣有關聯的執行環境。通過 eval
函數執行的代碼也有截然不同的執行環境,但因為 JavaScript 程式員在正常情況下一般不會使用 eval
,所以這裡不作討論。有關執行環境的詳細說明請參閱 ECMA 262(3rd edition)第 10.2 節。
當調用一個 JavaScript 函數時,該函數就會進入相應的執行環境。如果又調用了另外一個函數(或者遞迴地調用同一個函數),則又會建立一個新的執行環境,並且在函數調用期間執行過程都處於該環境中。當調用的函數返回後,執行過程會返回原始執行環境。因而,運行中的 JavaScript 代碼就構成了一個執行環境棧。
在建立執行環境的過程中,會按照定義的先後順序完成一系列操作。首先,在一個函數的執行環境中,會建立一個“活動”對象。使用中的物件是規範中規定的另外一種機制。之所以稱之為對象,是因為它擁有可訪問的命名屬性,但是它又不像正常對象那樣具有原型(至少沒有預定義的原型),而且不能通過 JavaScript 代碼直接引用使用中的物件。
為函數調用建立執行環境的下一步是建立一個 arguments
對象,這是一個類似數組的對象,它以整數索引的數群組成員一一對應地儲存著調用函數時所傳遞的參數。這個對象也有 length
和 callee
屬性(這兩個屬性與我們討論的內容無關,詳見規範)。然後,會為使用中的物件建立一個名為“arguments”的屬性,該屬性引用前面建立的 arguments
對象。
接著,為執行環境分配範圍。範圍由對象列表(鏈)組成。每個函數對象都有一個內部的 [[scope]]
屬性(該屬性我們稍後會詳細介紹),這個屬性也由對象列表(鏈)組成。指定給一個函數調用執行環境的範圍,由該函數對象的 [[scope]]
屬性所引用的對象列表(鏈)組成,同時,使用中的物件被添加到該對象列表的頂部(鏈的前端)。
之後會發生由 ECMA 262 中所謂“可變”對象完成的“變數執行個體化”的過程。只不過此時使用使用中的物件作為可變對象(這裡很重要,請注意:它們是同一個對象)。此時會將函數的形式參數建立為可變對象的命名屬性,如果調用函數時傳遞的參數與形式參數一致,則將相應參數的值賦給這些命名屬性(否則,會給命名屬性賦 undefined
值)。對於定義的內建函式,會以其聲明時所用名稱為可變對象建立同名屬性,而相應的內建函式則被建立為函數對象並指定給該屬性。變數執行個體化的最後一步是將在函數內部聲明的所有局部變數建立為可變對象的命名屬性。
根據聲明的局部變數建立的可變對象的屬性在變數執行個體化過程中會被賦予 undefined
值。在執行函數體內的代碼、並計算相應的賦值運算式之前不會對局部變數執行真正的執行個體化。
事實上,擁有 arguments
屬性的使用中的物件和擁有與函數局部變數對應的命名屬性的可變對象是同一個對象。因此,可以將標識符 arguments
作為函數的局部變數來看待。
最後,要為使用 this
關鍵字而賦值。如果所賦的值引用一個對象,那麼首碼以 this
關鍵字的屬性訪問器就是引用該對象的屬性。如果所賦(內部)值是 null,那麼 this
關鍵字則引用全域對象。
建立全域執行環境的過程會稍有不同,因為它沒有參數,所以不需要通過定義的使用中的物件來引用這些參數。但全域執行環境也需要一個範圍,而它的範圍鏈實際上只由一個對象--全域對象--組成。全域執行環境也會有變數執行個體化的過程,它的內建函式就是涉及大部分 JavaScript 代碼的、常規的頂級函式宣告。而且,在變數執行個體化過程中全域對象就是可變對象,這就是為什麼全域性聲明的函數是全域對象屬性的原因。全域性聲明的變數同樣如此。
全域執行環境也會使用 this
對象來引用全域對象。
範圍鏈與 [[scope]]
返回目錄
調用函數時建立的執行環境會包含一個範圍鏈,這個範圍鏈是通過將該執行環境的活動(可變)對象添加到儲存於所調用函數對象的 [[scope]]
屬性中的範圍鏈前端而構成的。所以,理解函數對象內部的 [[scope]]
屬性的定義過程至關重要。
在 ECMAScript 中,函數也是對象。函數對象在變數執行個體化過程中會根據函式宣告來建立,或者是在計算函數運算式或調用 Function
建構函式時建立。
通過調用 Function
建構函式建立的函數對象,其內部的 [[scope]]
屬性引用的範圍鏈中始終只包含全域對象。
通過函式宣告或函數運算式建立的函數對象,其內部的 [[scope]]
屬性引用的則是建立它們的執行環境的範圍鏈。
在最簡單的情況下,比如聲明如下全域函數:-
function exampleFunction(formalParameter){
… // 函數體內的代碼
}
- 當為建立全域執行環境而進行變數執行個體化時,會根據上面的函式宣告建立相應的函數對象。因為全域執行環境的範圍鏈中只包含全域對象,所以它就給自己建立的、並以名為“exampleFunction”的屬性引用的這個函數對象的內部 [[scope]]
屬性,賦予了只包含全域對象的範圍鏈。
當在全域環境中計算函數運算式時,也會發生類似的指定範圍鏈的過程:-
var exampleFuncRef = function(){
… // 函數體代碼
}
在這種情況下,不同的是在全域執行環境的變數執行個體化過程中,會先為全域對象建立一個命名屬性。而在計算指派陳述式之前,暫時不會建立函數對象,也不會將該函數對象的引用指定給全域對象的命名屬性。但是,最終還是會在全域執行環境中建立這個函數對象(當計算函數運算式時。譯者注),而為這個建立的函數對象的 [[scope]]
屬性指定的範圍鏈中仍然只包含全域對象。內部的函式宣告或運算式會導致在包含它們的外部函數的執行環境中建立相應的函數對象,因此這些函數對象的範圍鏈會稍微複雜一些。在下面的代碼中,先定義了一個帶有內建函式聲明的外部函數,然後調用外部函數:
/* 建立全域變數 - y - 它引用一個對象:- */var y = {x:5}; // 帶有一個屬性 - x - 的對象直接量function exampleFuncWith(){ var z; /* 將全域對象 - y - 引用的對象添加到範圍鏈的前端:- */ with(y){ /* 對函數運算式求值,以建立函數對象並將該函數對象的引用指定給局部變數 - z - :- */ z = function(){ … // 內建函式運算式中的代碼; }}…}/* 執行 - exampleFuncWith - 函數:- */
exampleFuncWith();在調用 exampleFuncWith
函數建立的執行環境中包含一個由其使用中的物件後跟全域對象構成的範圍鏈。而在執行 with
語句時,又會把全域變數 y
引用的對象添加到這個範圍鏈的前端。在對其中的函數運算式求值的過程中,所建立函數對象的 [[scope]]
屬性與建立它的執行環境的範圍保持一致--即,該屬性會引用一個由對象 y
後跟調用外部函數時所建立執行環境的使用中的物件,後跟全域對象的範圍鏈。
當與 with
語句相關的語句塊執行結束時,執行環境的範圍得以恢複(y
會被移除),但是已經建立的函數對象(z
。譯者注)的 [[scope]]
屬性所引用的範圍鏈中位於最前面的仍然是對象 y
。
例 3:封裝相關的功能
返回目錄
閉包可以用於建立額外的範圍,通過該範圍可以將相關的和具有依賴性的程式碼群組織起來,以便將意外互動的風險降到最低。假設有一個用於構建字串的函數,為了避免重複性的串連操作(和建立眾多的中間字串),我們的願望是使用一個數組按順序來儲存字串的各個部分,然後再使用 Array.prototype.join
方法(以Null 字元串作為其參數)輸出結果。這個數組將作為輸出的緩衝器,但是將數組作為函數的局部變數又會導致在每次調用函數時都重新建立一個新數組,這在每次調用函數時只重新指定數組中的可變內容的情況下並不是必要的。
一種解決方案是將這個數組聲明為全域變數,這樣就可以重用這個數組,而不必每次都建立新數組。但這個方案的結果是,除了引用函數的全域變數會使用這個緩衝數組外,還會多出一個全域屬性引用數組自身。如此不僅使代碼變得不容易管理,而且,如果要在其他地方使用這個數組時,開發人員必須要再次定義函數和數組。這樣一來,也使得代碼不容易與其他代碼整合,因為此時不僅要保證所使用的函數名在全域命名空間中是唯一的,而且還要保證函數所依賴的數組在全域命名空間中也必須是唯一的。
而通過閉包可以使作為緩衝器的數組與依賴它的函數關聯起來(優雅地打包),同時也能夠維持在全域命名空間外指定的緩衝數組的屬性名稱,免除了名稱衝突和意外互動的危險。
其中的關鍵技巧在於通過執行一個單行(in-line)函數運算式建立一個額外的執行環境,而將該函數運算式返回的內建函式作為在外部代碼中使用的函數。此時,緩衝數組被定義為函數運算式的一個局部變數。這個函數運算式只需執行一次,而數組也只需建立一次,就可以供依賴它的函數重複使用。
下面的代碼定義了一個函數,這個函數用於返回一個 HTML 字串,其中大部分內容都是常量,但這些常量字元序列中需要穿插一些可變的資訊,而可變的資訊由調用函數時傳遞的參數提供。
通過執行單行函數運算式返回一個內建函式,並將返回的函數賦給一個全域變數,因此這個函數也可以稱為全域函數。而緩衝數組被定義為外部函數運算式的一個局部變數。它不會暴露在全域命名空間中,而且無論什麼時候調用依賴它的函數都不需要重新建立這個數組。
/* 聲明一個全域變數 - getImgInPositionedDivHtml -並將一次調用一個外部函數運算式返回的內建函式賦給它。 這個內建函式會返回一個用於表示絕對位置的 DIV 元素 包圍著一個 IMG 元素 的 HTML 字串,這樣一來, 所有可變的屬性值都由調用該函數時的參數提供:*/var getImgInPositionedDivHtml = (function(){ /* 外部函數運算式的局部變數 - buffAr - 儲存著緩衝數組。 這個數組只會被建立一次,產生的數組執行個體對內建函式而言永遠是可用的 因此,可供每次調用這個內建函式時使用。 其中的Null 字元串用作資料預留位置,相應的資料 將由內建函式插入到這個數組中: */ var buffAr = [ ‘<div id=”‘, ”, //index 1, DIV ID 屬性 ‘” style=”position:absolute;top:’, ”, //index 3, DIV 頂部位置 ‘px;left:’, ”, //index 5, DIV 左端位置 ‘px;width:’, ”, //index 7, DIV 寬度 ‘px;height:’, ”, //index 9, DIV 高度 ‘px;overflow:hidden;”><img src=”‘, ”, //index 11, IMG URL ‘” width=”‘, ”, //index 13, IMG 寬度 ‘” height=”‘, ”, //index 15, IMG 高度 ‘” alt=”‘, ”, //index 17, IMG alt 常值內容 ‘”></div>’ ]; /* 返回作為對函數運算式求值後結果的內建函式對象。 這個內建函式就是每次調用執行的函數- getImgInPositionedDivHtml( … ) - */ return (function(url, id, width, height, top, left, altText){ /* 將不同的參數插入到緩衝數組相應的位置:*/ buffAr[1] = id; buffAr[3] = top; buffAr[5] = left; buffAr[13] = (buffAr[7] = width); buffAr[15] = (buffAr[9] = height); buffAr[11] = url; buffAr[17] = altText; /* 返回通過使用Null 字元串(相當於將數組元素串連起來)串連數組每個元素後形成的字串: */ return buffAr.join(”); }); //:內建函式運算式結束。})();/*^^- :單行外部函數運算式。*/
如果一個函數依賴於另一(或多)個其他函數,而其他函數又沒有必要被其他代碼直接調用,那麼可以運用相同的技術來封裝這些函數,而通過一個公開暴露的函數來調用它們。這樣,就將一個複雜的多函數處理過程封裝成了一個具有移植性的代碼單元。
其他例子
有關閉包的一個可能是最廣為人知的應用是 Douglas Crockford’s technique for the emulation of private instance variables in ECMAScript objects。這種應用方式可以擴充到各種嵌套包含的可訪問性(或可見度)的範圍結構,包括 the emulation of private static members for ECMAScript objects。
閉包可能的用途是無限的,可能理解其工作原理才是把握如何使用它的最好指南。
意外的閉包
返回目錄
在建立可訪問的內建函式的函數體之外解析該內建函式就會構成閉包。這表明閉包很容易建立,但這樣一來可能會導致一種結果,即沒有認識到閉包是一種語言特性的 JavaScript 作者,會按照內建函式能完成多種任務的想法來使用內建函式。但他們對使用內建函式的結果並不明了,而且根本意識不到建立了閉包,或者那樣做意味著什麼。
正如下一節談到 IE 中記憶體流失問題時所提及的,意外建立的閉包可能導致嚴重的負面效應,而且也會影響到代碼的效能。問題不在於閉包本身,如果能夠真正做到謹慎地使用它們,反而會有助於建立高效的代碼。換句話說,使用內建函式會影響到效率。
使用內建函式最常見的一種情況就是將其作為 DOM 元素的事件處理器。例如,下面的代碼用於向一個連結元素添加 onclick 事件處理器:
/* 定義一個全域變數,通過下面的函數將它的值 作為查詢字串的一部分添加到連結的 - href - 中:*/var quantaty = 5;/* 當給這個函數傳遞一個連結(作為函數中的參數 - linkRef -)時, 會將一個 onclick 事件處理器指定給該連結,該事件處理器 將全域變數 - quantaty - 的值作為字串添加到連結的 - href - 屬性中,然後返回 true 使該連結在單擊後定位到由 - href - 屬性包含的查詢字串指定的資源:*/function addGlobalQueryOnClick(linkRef){ /* 如果可以將參數 - linkRef - 通過類型轉換為 ture (說明它引用了一個對象): */ if(linkRef){ /* 對一個函數運算式求值,並將對該函數對象的引用 指定給這個連結元素的 onclick 事件處理器: */ linkRef.onclick = function(){ /* 這個內建函式運算式將查詢字串 添加到附加事件處理器的元素的 - href - 屬性中: */ this.href += (’?quantaty=’+escape(quantaty)); return true; }; }}
無論什麼時候調用 addGlobalQueryOnClick
函數,都會建立一個新的內建函式(通過賦值構成了閉包)。從效率的角度上看,如果只是調用一兩次 addGlobalQueryOnClick
函數並沒有什麼大的妨礙,但如果頻繁使用該函數,就會導致建立許多截然不同的函數對象(每對內建函式運算式求一次值,就會產生一個新的函數對象)。
上面例子中的代碼沒有關注內建函式在建立它的函數外部可以訪問(或者說構成了閉包)這一事實。實際上,同樣的效果可以通過另一種方式來完成。即單獨地定義一個用於事件處理器的函數,然後將該函數的引用指定給元素的事件處理屬性。這樣,只需建立一個函數對象,而所有使用相同事件處理器的元素都可以共用對這個函數的引用:
/* 定義一個全域變數,通過下面的函數將它的值 作為查詢字串的一部分添加到連結的 - href - 中:*/var quantaty = 5;/* 當把一個連結(作為函數中的參數 - linkRef -)傳遞給這個函數時, 會給這個連結添加一個 onclick 事件處理器,該事件處理器會 將全域變數 - quantaty - 的值作為查詢字串的一部分添加到 連結的 - href - 中,然後返回 true,以便單擊連結時定位到由 作為 - href - 屬性值的查詢字串所指定的資源:*/function addGlobalQueryOnClick(linkRef){ /* 如果 - linkRef - 參數能夠通過類型轉換為 true (說明它引用了一個對象): */ if(linkRef){ /* 將一個對全域函數的引用指定給這個連結 的事件處理屬性,使函數成為連結元素的事件處理器: */ linkRef.onclick = forAddQueryOnClick; }}/* 聲明一個全域函數,作為連結元素的事件處理器, 這個函數將一個全域變數的值作為要添加事件處理器的 連結元素的 - href - 值的一部分:*/function forAddQueryOnClick(){ this.href += (’?quantaty=’+escape(quantaty)); return true;}
在上面例子的第一個版本中,內建函式並沒有作為閉包發揮應有的作用。在那種情況下,反而是不使用閉包更有效率,因為不用重複建立許多本質上相同的函數對象。
類似地考量同樣適用於對象的建構函式。與下面代碼中的建構函式架構類似的代碼並不罕見:
function ExampleConst(param){ /* 通過對函數運算式求值建立對象的方法, 並將求值所得的函數對象的引用賦給要建立對象的屬性: */ this.method1 = function(){ … // 方法體。 }; this.method2 = function(){ … // 方法體。 }; this.method3 = function(){ … // 方法體。 }; /* 把建構函式的參數賦給對象的一個屬性:*/ this.publicProp = param;}
每當通過 new ExampleConst(n)
使用這個建構函式建立一個對象時,都會建立一組新的、作為對象方法的函數對象。因此,建立的對象執行個體越多,相應的函數對象也就越多。
Douglas Crockford 提出的模仿 JavaScript 對象私人成員的技術,就利用了將對內建函式的引用指定給在建構函式中構造對象的公用屬性而形成的閉包。如果對象的方法沒有利用在建構函式中形成的閉包,那麼在執行個體化每個對象時建立的多個函數對象,會使執行個體化過程變慢,而且將有更多的資源被佔用,以滿足建立更多函數對象的需要。
這那種情況下,只建立一次函數對象,並把它們指定給建構函式 prototype
的相應屬性顯然更有效率。這樣一來,它們就能被建構函式建立的所有對象共用了:
function ExampleConst(param){ /* 將建構函式的參數賦給對象的一個屬性:*/ this.publicProp = param;}/* 通過對函數運算式求值,並將結果函數對象的引用 指定給建構函式原型的相應屬性來建立對象的方法:*/ExampleConst.prototype.method1 = function(){ … // 方法體。};ExampleConst.prototype.method2 = function(){ … // 方法體。};ExampleConst.prototype.method3 = function(){ … // 方法體。};
Internet Explorer 的記憶體流失問題
返回目錄
Internet Explorer 網頁瀏覽器(在 IE 4 到 IE 6 中核實)的垃圾收集系統中存在一個問題,即如果 ECMAScript 和某些宿主對象構成了 “循環參考”,那麼這些對象將不會被當作垃圾收集。此時所謂的宿主對象指的是任何 DOM 節點(包括 document 對象及其後代元素)和 ActiveX 對象。如果在一個循環參考中包含了一或多個這樣的對象,那麼這些對象直到瀏覽器關閉都不會被釋放,而它們所佔用的記憶體同樣在瀏覽器關閉之前都不會交回系統重用。
當兩個或多個對象以首尾相連的方式相互引用時,就構成了循環參考。比如對象 1 的一個屬性引用了對象 2 ,對象 2 的一個屬性引用了對象 3,而對象 3 的一個屬性又引用了對象 1。對於純粹的 ECMAScript 對象而言,只要沒有其他對象引用對象 1、2、3,也就是說它們只是相互之間的引用,那麼仍然會被垃圾收集系統識別並處理。但是,在 Internet Explorer 中,如果循環參考中的任何對象是 DOM 節點或者 ActiveX 對象,垃圾收集系統則不會發現它們之間的迴圈關係與系統中的其他對象是隔離的並釋放它們。最終它們將被保留在記憶體中,直到瀏覽器關閉。
閉包非常容易構成循環參考。如果一個構成閉包的函數對象被指定給,比如一個 DOM 節點的事件處理器,而對該節點的引用又被指定給函數對象範圍中的一個活動(或可變)對象,那麼就存在一個循環參考。DOM_Node.onevent ->function_object.[[scope]] ->scope_chain ->Activation_object.nodeRef ->DOM_Node。形成這樣一個循環參考是輕而易舉,而且稍微瀏覽一下包含類似循環參考代碼的網站(通常會出現在網站的每個頁面中),就會消耗大量(甚至全部)系統記憶體。
多加註意可以避免形成循環參考,而在無法避免時,也可以使用補償的方法,比如使用 IE 的 onunload 事件來來清空(null)事件處理函數的引用。時刻意識到這個問題並理解閉包的工作機制是在 IE 中避免此類問題的關鍵。
comp.lang.javascript FAQ notes T.O.C.
- 撰稿 Richard Cornford,2004 年 3 月
- 修改建議來自:
- Martin Honnen.
- Yann-Erwan Perio (Yep).
- Lasse Reichstein Nielsen. (definition of closure)
- Mike Scirocco.
- Dr John Stockton.