理解 JavaScript 閉包

sz_bdqn發表於2010-09-22

要成為高階 JavaScript 程式設計師,就必須理解閉包。

本文結合 ECMA 262 規範詳解了閉包的內部工作機制,讓 JavaScript 程式設計人員對閉包的理解從“巢狀的函式”深入到“識別符號解析、執行環境和作用域鏈”等等 JavaScript 物件背後的執行機制當中,真正領會到閉包的實質。

原文連結:JavaScript Closures

可列印版:JavaScript 閉包

目錄

簡介

返回目錄

Closure
所謂“閉包”,指的是一個擁有許多變數和繫結了這些變數的環境的表示式(通常是一個函式),因而這些變數也是該表示式的一部分。
“閉包”是一個表示式(一般是函式),它具有自由變數以及繫結這些變數的環境(該環境“封閉了”這個表示式)。
(閉包,就是封閉了外部函式作用域中變數的內部函式。但是,如果外部函式不返回這個內部函式,閉包的特性無法顯現。如果外部函式返回這個內部函式,那麼返回的內部函式就成了名副其實的閉包。此時,閉包封閉的外部變數就是自由變數,而由於該自由變數存在,外部函式即便返回,其佔用的記憶體也得不到釋放。——譯者注,2010年4月3日)

閉包是 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。而且,雖然 MyObject1MyObject2 都沒有定義 toString 方法,但是當屬性訪問器通過 objectRef 讀取 toString 屬性的值時:

var val = objectRef.toString;

變數 val 也會被賦予一個函式的引用。這個函式就是在 Object.prototypetoString 屬性中所儲存的函式。之所以會返回這個函式,是因為發生了搜尋 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 物件,這是一個類似陣列的物件,它以整數索引的陣列成員一一對應地儲存著呼叫函式時所傳遞的引數。這個物件也有 lengthcallee 屬性(這兩個屬性與我們討論的內容無關,詳見規範)。然後,會為活動物件建立一個名為“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 方法(以空字串作為其引數)輸出結果。這個陣列將作為輸出的緩衝器,但是將陣列作為函式的區域性變數又會導致在每次呼叫函式時都重新建立一個新陣列,這在每次呼叫函式時只重新指定陣列中的可變內容的情況下並不是必要的。

一種解決方案是將這個陣列宣告為全域性變數,這樣就可以重用這個陣列,而不必每次都建立新陣列。但這個方案的結果是,除了引用函式的全域性變數會使用這個緩衝陣列外,還會多出一個全域性屬性引用陣列自身。如此不僅使程式碼變得不容易管理,而且,如果要在其他地方使用這個陣列時,開發者必須要再次定義函式和陣列。這樣一來,也使得程式碼不容易與其他程式碼整合,因為此時不僅要保證所使用的函式名在全域性名稱空間中是唯一的,而且還要保證函式所依賴的陣列在全域性名稱空間中也必須是唯一的。

而通過閉包可以使作為緩衝器的陣列與依賴它的函式關聯起來(優雅地打包),同時也能夠維持在全域性名稱空間外指定的緩衝陣列的屬性名,免除了名稱衝突和意外互動的危險。

其中的關鍵技巧在於通過執行一個單行(in-line)函式表示式建立一個額外的執行環境,而將該函式表示式返回的內部函式作為在外部程式碼中使用的函式。此時,緩衝陣列被定義為函式表示式的一個區域性變數。這個函式表示式只需執行一次,而陣列也只需建立一次,就可以供依賴它的函式重複使用。

下面的程式碼定義了一個函式,這個函式用於返回一個 HTML 字串,其中大部分內容都是常量,但這些常量字元序列中需要穿插一些可變的資訊,而可變的資訊由呼叫函式時傳遞的引數提供。

通過執行單行函式表示式返回一個內部函式,並將返回的函式賦給一個全域性變數,因此這個函式也可以稱為全域性函式。而緩衝陣列被定義為外部函式表示式的一個區域性變數。它不會暴露在全域性名稱空間中,而且無論什麼時候呼叫依賴它的函式都不需要重新建立這個陣列。

/* 宣告一個全域性變數 - getImgInPositionedDivHtml -
並將一次呼叫一個外部函式表示式返回的內部函式賦給它。      

   這個內部函式會返回一個用於表示絕對定位的 DIV 元素
   包圍著一個 IMG 元素 的 HTML 字串,這樣一來,
   所有可變的屬性值都由呼叫該函式時的引數提供:
*/
var getImgInPositionedDivHtml = (function(){
    /* 外部函式表示式的區域性變數 - buffAr - 儲存著緩衝陣列。
     這個陣列只會被建立一次,生成的陣列例項對內部函式而言永遠是可用的
     因此,可供每次呼叫這個內部函式時使用。      

    其中的空字串用作資料佔位符,相應的資料
    將由內部函式插入到這個陣列中:
    */
    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;
        /* 返回通過使用空字串(相當於將陣列元素連線起來)
	連線陣列每個元素後形成的字串:
        */
        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 Web 瀏覽器(在 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.

 

 

 

 

 

朋友們的留言

  1. amio | 04月 9th, 2010 at 16:06

    補充點相關資料,執行環境裡面倒數第四段(搜尋this.m)對this解釋比較簡單,也有點含糊,這個我發現個更好更細緻的說明,http://digg.com/d1uK9z

    簡單概括就是:
    Javascript語言中支援四類函式呼叫方式,1)全域性函式2)物件方法3)建構函式4)apply/call呼叫。區別在於函式內this指標的繫結,分別是 1)Global物件2)呼叫物件3)構造返回物件4)呼叫時傳入的第一個引數。

  2. 為之漫筆 | 04月 9th, 2010 at 19:35

    @amio

    這個簡單的概括很全面。

  3. 說兩句 | 04月 13th, 2010 at 09:25

    不錯,謝謝!

  4. duanhun87 | 04月 15th, 2010 at 18:51

    花了好長時間看懂了些。。閉包大概都懂了,就是內部函式,在外部呼叫時候,仍然能訪問昔日建立這個內部函式時的執行環境下產生的引數值變數值等。不過講閉包例項那裡,那些例項腦子還是模模糊糊的。

    javascript比我想象中難很多,特別是真正搞懂概念性的東西。對於我這個大專文化,又不是理工出生,無任何程式設計基礎,看這篇文章真的很吃力,沒人在身邊指點太抽象了。現在靠自學,啃犀牛書中,(也在等你的第二版書 )。因為看到犀牛書中的閉包部分實在是暈,百度到你的部落格,找到這篇文章,覺得挺權威的了。因為我發現網上有些人的文章的觀念是錯誤的,怕誤導。這篇是老外翻譯過來,應該是很具有研究價值。

  5. Javascript 閉包 (轉載) | F2E-Doc | 04月 22nd, 2010 at 14:45

    [...] 翻譯:為之漫筆 連結:http://www.cn-cuckoo.com/2007/08/01/understand-javascript-closures-72.html [...]

  6. 何畏's BLOG » Blog Archive » Javascript 閉包 | 05月 8th, 2010 at 00:01

    [...] 翻譯:為之漫筆連結:http://www.cn-cuckoo.com/2007/08/01/understand-javascript-closures-72.html [...]

  7. dddddddddddd | 05月 12th, 2010 at 10:28

    我看著就困很多都是多餘的東西

  8. IT小菜 | 05月 13th, 2010 at 22:27

    你好,以上文章裡的例子我是看懂了,不過jQuery之父寫一本書裡有這麼一個關於閉包的例子
    // An element with an ID of main
    var obj = document.getElementById(“main”);

    // An array of items to bind to
    var items = [ "click", "keypress" ];

    // Iterate through each of the items
    for ( var i = 0; i < items.length; i++ ) {
    // Use a self-executed anonymous function to induce scope
    (function(){
    // Remember the value within this scope
    var item = items[i];

    // Bind a function to the elment
    obj[ "on" + item ] = function() {

    // item refers to a parent variable that has been successfully
    // scoped within the context of this for loop
    alert( "Thanks for your " + item );
    };
    })();
    }
    作者說在這個閉包函式被呼叫時,它引用的計數器的值是其最後一次的值(比如陣列的最後一個位置),而不是你期望的值。我按上述分析想了半天還沒想明白,可能是我還不是真正意義的理解。能不能告訴我為什麼呀?謝謝了。

  9. xxx[i].onclick = function(){alert(i);} 為什麼彈出的數字都是相同的呢? - Web開發常見問題 - xxx i onclick function alert i 為什麼彈出 數字都是相同 呢 Web 開發 JavaScript - 123Doing | 06月 11th, 2010 at 16:54

    [...] 應該使用閉包:http://blog.csdn.net/hitman9099/archive/2009/01/28/3854171.aspxhttp://www.ruanyifeng.com/blog/2009/08/learning_javascript_closures.htmlhttp://www.cn-cuckoo.com/2007/08/01/understand-javascript-closures-72.html [...]

  10. 一個關於jQuery選擇器的問題 - Web開發常見問題 - 一個關於jQuery選擇器 問題 Web 開發 JavaScript - 123Doing | 06月 14th, 2010 at 22:07

    [...] http://www.cn-cuckoo.com/2007/08/01/understand-javascript-closures-72.html [...]

  11. Digests for August 5th « 凋零的羽 | 08月 5th, 2010 at 08:03

    [...] 理解 JavaScript 閉包 [...]

  12. WeekFace | 08月 11th, 2010 at 17:15

    @IT小菜

    內部函式裡面引用的var item這個變數是在外部函式結束之後保留下來的,

    等到真正引用的時候,他的值已經改變了

    換句話說,他只是個引用

  13. narco | 08月 24th, 2010 at 17:10

    拜讀了

  14. guilipan | 09月 14th, 2010 at 14:50

    我覺得把NCZ大神的那個作用域鏈圖放上來就很明白了,雖然老師你講的很好,可是還不夠直觀。。

相關文章