JavaScript高階程式設計(讀書筆記)(七)
本筆記彙總了作者認為“JavaScript高階程式設計”這本書的前七章知識重點,僅供參考。
第七章 函式表示式
小結:
在JavaScript程式設計中,函式表示式是一種非常有用的技術。使用函式表示式可以無須對函式命名,從而實現動態程式設計。匿名函式,也稱為拉姆達函式,是一種使用JavaScript函式的強大方式。以下總結了函式表示式的特點。
- 函式表示式不同於函式宣告。函式宣告要求有名字,但函式表示式不需要。沒有名字的函式表示式也叫作匿名函式;
- 在無法確定如何引用函式的情況下,遞迴函式就會變得比較複雜;
- 遞迴函式應該始終使用argument.callee來遞迴呼叫自身,不要使用函式名——函式名可能會發生變化。
當函式內部定義了其它函式時,就建立了閉包。閉包有權訪問包含函式內部的所有變數,原理如下。
- 在後臺執行環境中,閉包的作用域鏈包含著它自己的作用域、包含函式的作用域和全域性作用域;
- 通常,函式的作用域及其所有變數都會在函式執行結束後被銷燬;
- 但是,當函式返回了一個閉包時,這個函式的作用域將會一直在記憶體中儲存到閉包不存在為止。
使用閉包可以在JavaScript中模仿塊級作用域(JavaScript本身沒有塊級作用域的概念),要點如下:
- 建立並立即呼叫一個函式,這樣既可以執行其中的程式碼,又不會在記憶體中留下對該函式的引用。
- 結果就是函式內部的所有變數都會被立即銷燬——除非將某些變數賦值給了包含作用域(即外部作用域)中的變數。
閉包還可以用於在物件中建立私有變數,相關概念和要點如下:
- 及時JavaScript中沒有正式的私有物件屬性的概念,但可以使用閉包來實現公有方法,而通過公有方法可以訪問在包含作用域中定義的變數;
- 有權訪問私有變數的公有方法叫做特權方法;
- 可以使用建構函式模式、原型模式來實現自定義型別的特權方法,也可以使用塊級模式、增強的模組模式來實現單例的特權方法。
JavaScript中的函式表示式和閉包都是極其有用的特性,利用它們可以實現很多功能。不過,因為建立閉包必須維護額外的作用域,所以過度使用它們可能會佔用大量記憶體。
定義函式的方式有兩種:一種是函式宣告,另一種就是函式表示式。
//函式宣告
function functionName(arg0, arg1, arg2) {
//函式體
}
//函式表示式
var functionName = function(arg0, arg1, arg2) {
//函式體
}
函式宣告的一個重要特徵就是函式宣告提升(function declaration hoisting),意思是在執行程式碼前會先讀取函式宣告。
遞迴
遞迴函式是在一個函式通過名字呼叫自身的情況下構成的。為了和函式名稱解耦,arguments.callee
來表示正在執行的函式的指標,因此可以用它來實現對函式的遞迴呼叫,例如:
function factorial(num) {
if(num<=1) {
return 1;
} else {
return num * arguments.callee(num-1);
}
}
但在嚴格模式下,不能通過指令碼訪問arguments.callee
,訪問這個屬性會導致錯誤。不過,可以使用命名函式表示式來達成相同的結果。如:
var factorial = (function f(num) {
if(num<=1) {
return 1;
} else {
return num * f(num-1);
}
});
以上程式碼建立了一個名為f()
的命名函式表示式,然後將它賦值給factorial
。即便把函式賦值給了另一個變數,函式的名字f
仍然有效,所以遞迴呼叫照樣能正確完成。這種方式在嚴格模式和非嚴格模式下都行得通。
閉包
不少開發人員總是搞不清匿名函式和閉包這兩個概念。閉包是指有權訪問另一個函式作用域中的變數的函式。而匿名函式是指沒有函式名稱的函式。建立閉包的常見方式,就是在一個函式內部建立另一個函式,以前面createComparisonFunction()
函式為例:
function createComparisonFunction(propertyName) {
function createComparisonFunction(object1, object2) {
var value1 = object1[propertyName];
var value2 = object2[propertyName];
if(value1<value2) {
return -1;
} else if(value1<value2) {
return 1;
} else {
return 0;
}
};
}
為了徹底理解閉包,瞭解如何建立作用域鏈以及作用域鏈有什麼作用十分重要。當某個函式被呼叫時,會建立一個執行環境(execution context)及相應的作用域鏈。然後,使用arguments
和其他命名引數的值來初始化函式的活動物件(activation object)。但在作用域鏈中,外部函式的活動物件始終處於第二位,外部函式的外部函式的活動物件處於第三位,……直至作為作用域鏈終點的全域性執行環境。
在函式執行過程中,為讀取和寫入變數的值,就需要在作用域鏈中查詢變數。如下例:
function compare(value1, value2) {
if(value1<value2) {
return -1;
} else if(value1>value2) {
return 1;
} else {
return 0;
}
}
var result = compare(5,10);
以上程式碼先定義了compare()
函式,然後又在全域性作用域中呼叫了它。當第一次呼叫compare()
時,會建立一個包含this
、arguments
、value1
和value2
的活動物件。全域性執行環境的變數物件(包含this
、result
和compare
)在compare()
執行環境的作用域鏈中則處於第二位。下圖展示了包含上述關係的compare()
函式執行時的作用域鏈。
後臺的每個執行環境都有一個表示變數的物件——變數物件。全域性環境的變數物件始終存在,而像compare()
函式這樣的區域性環境的變數物件,則只在函式執行的過程中存在。在建立compare()
函式時,會建立一個預先包含全域性變數物件的作用域鏈,這個作用域鏈被儲存在內部的[[Scope]]
屬性中。當呼叫compare()
函式時,會為函式建立一個執行環境,然後通過複製函式的[[Scope]]
屬性中的物件構建起執行環境的作用域鏈。此後又有一個活動物件(在此作為變數物件使用)被建立並被推入執行環境作用域鏈的前端。對於這個例子中compare()
函式的執行環境而言,其作用域鏈中包含兩個變數物件:本地活動物件和全域性變數物件。顯然,作用域鏈本質上是一個指向變數物件的指標列表,它只引用但不實際包含變數物件。
無論什麼時候在函式中訪問一個變數時,都會從作用域鏈中搜索具有相應名字的變數。一般來講,當函式執行完畢後,區域性活動物件就會被銷燬,記憶體中僅儲存全域性作用域(全域性執行環境的變數物件)。但是,閉包的情況又有所不同。
在另一個函式內部定義的函式將會包含函式(即外部函式)的活動物件新增到它的作用域鏈中。因此,在createComparisonFunction()
函式內部定義的匿名函式的作用域鏈中,實際上將會包含外部函式createComparisonFunction()
的活動物件。下圖展示了當下列程式碼執行時,包含函式與內部匿名函式的作用域鏈。
var compare = createComparisonFunction("name");
var result = compare({ name: "Nicholas" }, { name: "Greg" });
在匿名函式從createComparisFunction()
中返回後,它的作用域鏈被初始化為包含createComparisonFunction()
函式的活動物件和全域性變數物件。這樣,匿名函式就可以訪問在createComparisonFunction()
中定義的所有變數。更為重要的是,createComparisonFunction()
函式在執行完畢後,其活動物件也不會被銷燬,因為匿名函式的作用域鏈仍然在引用這個活動物件。換句話說,當createComparisonFunction()
函式返回後,其執行環境的作用域鏈會被銷燬,但它的活動物件仍然會留在記憶體中;知道匿名函式被銷燬後,createComparisonFunction()
的活動物件才會被銷燬,例如:
//建立函式
var compareNames = createComparisonFunction("name");
//呼叫函式
var result = compareNames({ name: "Nicholas" }, { name: "Greg" });
//解除對匿名函式的引用(以便釋放記憶體)
compareNames = null;
首先,建立的比較函式被儲存在變數compareNames
中。而通過將compareNames
設定為等於null
解除該函式的引用,就等於通知垃圾回收例程將其清除。隨著匿名函式的作用域鏈被銷燬,其他作用域鏈(除了全域性作用域)也都可以安全地銷燬了。下圖展示了呼叫compareNames()
的過程中產生的作用域鏈之間的關係。
閉包與變數
作用域鏈的這種配置機制引出了一個值得注意的副作用,即閉包只能取得包含函式中任何變數的最後一個值。閉包所儲存的是整個變數物件,而不是某個特殊的值。如下:
function createFunctions() {
var result = new Array();
for(var i=0; i<10; i++) {
result[i] = function() {
return i;
};
}
return result;
}
這個函式會返回一個函式陣列,而其中的每個函式都返回10。
我們可以通過建立另一個匿名函式強制讓閉包的行為符合預期。如下:
function createFunctions() {
var result = new Array();
for(var i=0; i<10; i++) {
//匿名函式直接賦值
result[i] = function(num) {
return function() {
return num;
};
}(i);
}
return result;
}
關於this物件
this
物件是在執行時基於函式的執行環境繫結的:在全域性函式中,this
等於window
,而當函式被作為某個物件的方法呼叫時,this
等於那個物件。匿名函式的執行環境具有全域性性,因此其this
物件通常指向window
。
var name = "The Window";
var object = {
name: "My Object",
getNameFunc: function() {
return function() {
return this.name;
};
}
};
alert(object.getNameFunc()()); //"The Window"(在非嚴格模式下)
getNameFunc()
返回一個匿名函式,因此object.getNameFunc()()
就會立即呼叫它返回的函式,結果就是返回一個字串”The Window”。而如果訪問object
的屬性,就需要把外部作用域中的this
物件儲存在一個閉包能夠訪問到的變數裡。如下:
var name = "The Window";
var object = {
name: "My Object",
getNameFunc: function() {
var that = this;
return function() {
return that.name;
};
}
};
alert(object.getNameFunc()()); //"My Object"
在定義匿名函式之前,我們把this
物件賦值給了一個名叫that
的變數。而在定義了閉包之後,閉包也可以訪問這個變數,因為它是我們在包含函式中特意宣告的一個變數。即使在函式返回之後,that
也仍然引用著object
,所以呼叫object.getNameFunc()()
就返回了”My Object”。
每個函式在被呼叫時都會自動取得兩個特殊變數:this
和arguments
。如果想訪問作用域中的arguments
物件,同樣的,必須將該物件的引用儲存到另一個閉包能夠訪問到的變數中。
記憶體洩漏
由於IE9之前的版本對JScript物件和COM物件使用不同的垃圾收集例程,因此閉包在IE的這些版本中會導致一些特殊的問題。具體來說,如果閉包的作用域鏈中儲存著一個HTML元素,那麼就意味著該元素將無法被銷燬。例如
function assignHandler() {
var element = document.getElementById("someElement");
element.onclick = function() {
alert(element.id);
};
}
以上程式碼建立了一個作為element
元素事件處理程式的閉包,而這個閉包則又建立了一個迴圈引用。由於匿名函式儲存了一個對assingHandler()
的活動物件的引用,因此就會導致無法減少element
的引用數。只要匿名函式存在,element
的引用數至少也是1,因此它所佔用的記憶體就永遠不會被回收。不過,可以通過該寫程式碼來解決,如下:
//防止記憶體洩露
function assignHandler() {
var element = document.getElementById("someElement");
var id = element.id;
element.onclick = function() {
alert(id);
};
element = null;
}
上面的程式碼中,通過把element.id
的一個副本儲存在一個變數中,並且在閉包中引用該變數消除了迴圈引用。但僅僅做到這一步,還是不能解決記憶體洩漏的問題。必須要記住:閉包會引用包含函式的整個活動物件,而其中包含著element
。即使閉包不直接引用element
,包含函式的活動物件中也仍然會儲存一個應用。因此,有必要把element
變數設定為null
。這樣就能夠解除對DOM物件的引用,順利地減少其引用數,確保正常回收其佔用的記憶體。
模仿塊級作用域
JavaScript中沒有塊級作用域的概念。這意味著在塊語句中定義的變數,實際上是在包含函式中而非語句中建立的,如下:
function outputNumbers(count) {
for(var i=0; i<count; i++) {
alert(i);
}
alert(i); //計數
}
這個函式中定義了一個for迴圈,而變數i的初始值被設定為0。在Java、C++等於嚴重,變數i只會在for迴圈的語句塊中有定義,迴圈一旦結束,變數i就會被銷燬。可是在JavaScript中,變數i是定義在outputNumbers()
的活動物件中的,因此從它有定義開始,既可以在函式內部隨處訪問它。即使像下面這樣錯誤的重新宣告同一個變數,也不會改變它的值。
function outputNumers(count) {
for(var i=0; i<count; i++) {
alert(i);
}
var i; //重新宣告變數
alert(i); //計數
}
JavaScript從來不會告訴你是否多次聲明瞭同一個變數;遇到這種情況,它只會對後續的宣告視而不見(不過,他會執行後續宣告中的變數初始化)。匿名函式可以用來模仿塊級作用域並避免這個問題。
用作塊級作用域(通常稱為私用作用域)的匿名函式的語法如下所示。
//立即呼叫函式表示式
(function()) {
//這裡是塊級作用域
})();
以上程式碼定義並立即呼叫了一個匿名函式。
函式表示式的後面可以跟圓括號。因此,這裡通過給函式宣告加上一對圓括號將其轉換成函式表示式。
無論在什麼地方,只要臨時需要一些變數,就可以使用私有作用域,例如:
function outputNumbers(count) {
(function() {
for(var i=0; i<count; i++) {
alert(i);
}
})();
alert(i); //導致一個錯誤!
}
這種技術經常在全域性作用域中被用在函式外部,從而限制向全域性作用域中新增過多的變數和函式。一般來說,我們都應該儘量少向全域性作用域中新增變數和函式。在一個有很多開發人員共同參與的大型應用程式中,過多的全域性變數和函式很容易導致命名衝突。而通過建立私有作用域,每個開發人員既可以使用自己的變數,又不必擔心搞亂全域性作用域。
私有變數
嚴格來講,JavaScript中沒有私有成員的概念;所有物件屬性都是公有的。不過,倒是有一個私有變數的概念。任何在函式中定義的變數,都可以認為是私有變數,因為不能在函式的外部訪問這些變數。私有變數包括函式的引數、區域性變數和在函式內部定義的其它函式。
如果在函式內部建立閉包,那麼閉包通過自己的作用域鏈也可以訪問這些變數。而利用這一點,就可以建立用於訪問私有變數的公有方法。
我們把有權訪問私有變數和私有函式的公有方法成為**特權方法(privileged method)。有兩種在物件上建立特權方法的方式。第一種是在建構函式中定義特權方法,基本模式如下。
function MyObject() {
//私有變數和私有函式
var privateVariable = 10;
function privateFunction() {
return false;
}
//特權方法
this.publicMethod = function() {
privateVariable++;
return privateFunction();
};
}
這個模式在建構函式內部定義了所有私有變數和函式。然後,又繼續建立了能夠訪問這些私有成員的特權方法。能夠在建構函式中定義特權方法,是因為特權方法作為閉包有權訪問在建構函式中定義的所有變數和函式。
利用私有和特權成員,可以隱藏那些不應該被直接修改的資料,例如:
function Person(name) {
this.getName = function() {
return name;
};
this.setName = function (value) {
name = value;
};
}
var person = new Person("Nihcholas");
alert(person.getName()); //"Nicholas"
person.setName("Greg");
alert(person.getName()); //"Greg"
以上程式碼的建構函式中定義了兩個特權方法:getName()
和setName()
。這兩個方法都可以在建構函式外部使用,而且都有權訪問私有變數name
。但在Person
建構函式外部,沒有任何辦法訪問name
。
建構函式定義特權方法也有一個缺點,就是必須使用建構函式模式來達到這個目的。而建構函式模式的缺點是針對每個例項都會建立同樣一組新方法,而使用靜態私有變數來實現特權方法就可以避免這個問題。
靜態私有變數
通過在私有作用域中定義私有變數或函式,同樣也可以建立特權方法,其基本模式如下:
(function() {
//私有變數和私有函式
var privateVariable = 10;
function privateFunction() {
return false;
}
//建構函式
MyObject = function() {
};
//公有/特權方法
MyObject.prototype.publicMethod = function() {
privateVariable++;
return privateFunction();
};
})();
這個模式建立了一個私有作用域,並在其中封裝了一個建構函式及相應的方法。在私有作用域中,首先定義了私有變數和私有函式,然後又定義了建構函式及其公有方法。公有方法是在原型上定義的,這一點體現了典型的原型模式。需要注意的是,這個模式在定義建構函式時並沒有使用函式宣告,而是使用了函式表示式。函式宣告只能建立區域性函式,但那並不是我們想要的。出於同樣的原因,我們也沒有在宣告MyObject
時使用var
關鍵字。記住:初始化未經宣告的變數,總是會建立一個全域性變數。因此,MyObject
就成了一個全域性變數,能夠在私有作用域之外被訪問到。但是嚴格模式下將會報錯。
這個模式與在建構函式中定義特權方法的主要區別,就在於私有變數和函式是由例項共享的。由於特權方法是在原型上定義的,因此所有例項都使用同一個函式。而這個特權方法,作為一個閉包,總是儲存著對包含作用域的引用。
(function() {
var name = "";
Person = function(value) {
name = value;
};
Person.prototye.getName = function() {
return name;
};
Person.prototype.setName = function(value) {
name = value;
};
})();
var person1 = new Person("Nicholas");
alert(person1.getName()); //"Nicholas"
person1.setName("Greg");
alert(person1.getName()); //"Greg"
var person2 = new Person("Michael");
alert(person1.getName()); //"Michael"
alert(person2.getName()); //"Michael"
這個例子中的Person
建構函式與getName()
和setName()
方法一樣,都有權訪問私有變數name
。在這種模式下,變數name
就變成了一個靜態的、由所有例項共享的屬性。也就是說,在一個例項上呼叫setName()
會影響所有例項。而呼叫setName()
或建立一個Person
例項都會賦予name
屬性一個新值。結果就是所有例項都會返回相同的值。
以這種方式建立靜態私有變數會因為使用原型而增進程式碼複用,但每個例項都沒有自己的私有變數。到底是使用例項變數,還是靜態私有變數,最終還是要視你的具體需求而定。
模組模式
前面的模式是用於為自定義型別建立私有變數和特權方法的。而道格拉斯所說的模組模式(module pattern)則是為單例建立私有變數和特權方法。所謂單例(singleton),指的就是隻有一個例項的物件。按照慣例,JavaScript是以字面量的方式來建立單例物件的。
var singleton = {
name : value,
method : function() {
//這裡是方法的程式碼
}
};
模組模式通過為單例新增私有變數和特權方法能夠使其得到增強,其語法格式如下:
var singleton = function() {
//私有變數和私有函式
var privateVariable = 10;
function privateFunction() {
return false;
}
//特權/公有方法和屬性
return {
publicProperty: true;
publicMethod: function() {
privateVariable++;
return privateFunction();
}
};
}();
這個模組模式使用了一個返回物件的匿名函式。在這個匿名函式內部,定義了私有變數和函式。然後,將一個物件字面量作為函式的值返回。返回的物件字面量中只包含可以公開的屬性和方法。由於這個物件是在匿名函式內部定義的,因此它的公有方法有權訪問私有變數和函式。從本質上來講,這個物件字面量定義的是單例的公共介面。這種模式在需要對單例進行某些初始化,同時又需要維護其私有變數時是非常有用的,例如:
var application = function() {
//私有變數和函式
var components = new Array();
//初始化
components.push(new BaseComponent());
//公共
return {
getComponentCount : function() {
return components.length;
},
registerComponent : function(component) {
if(typeof component == "object") {
components.push(component);
}
}
};
}();
在Web應用程式中,經常需要使用一個單例來管理應用程式級的資訊。這個簡單的例子建立了一個用於管理元件的application
物件。在建立這個物件的過程中,首先聲明瞭一個私有的components
陣列,並向陣列中添加了一個BaseComponent
的新例項(在這裡不需要關心BaseComponent
的程式碼,我們只是用它來展示初始化操作)。而返回物件的getComponentCount()
和registerComponent()
方法,都是有權訪問陣列components
的特權方法。前者只是返回已註冊的元件數目,後者用於註冊新元件。
簡言之,如果必須建立一個物件並以某些資料對其進行初始化,同時還要公開一些能夠訪問這些私有資料的方法,那麼就可以使用模組模式。以這種模式建立的每個單例都是object
的例項,因為最終要通過一個物件字面量來表示它。事實上,這也沒有什麼;畢竟,單例通常都是作為全域性物件存在的,我們不會將它傳遞給一個函式。因此,也就沒有什麼必要使用instanceof
操作符來檢查其物件型別了。
增強的模組模式
有人進一步改進了模組模式,即在返回物件之前加入對其增強的程式碼。這種增強的模組模式是和那些單例必須是某種型別的例項,同時還必須新增某些屬性和方法對其加以增強的情況。如下:
var singleton = function() {
//私有變數和私有函式
var privateVariable = 10;
function privateFunction() {
retur false;
}
//建立物件
var object = new CustomType();
//新增特權/公有屬性和方法
object.publicProperty = true;
object.publicMethod = function() {
privateVariable++;
return privateFunction();
};
//返回這個物件
return object;
}();
如果前面演示模組模式的例子中的application
物件必須是BaseComponent
的例項,那麼就可以使用以下程式碼。
var application = function() {
//私有變數和函式
var components = new Array();
//初始化
components.push(new BaseComponent());
//建立application的一個區域性副本
var app = new BaseComponent();
//公共介面
app.getComponentCount = function() {
return components.length;
};
app.registerComponent = function(component) {
if(typeof component == "object") {
components.push(component);
}
};
//返回這個副本
return app;
}();