面向對象(Object-Oriented,OO)的語言有一個標志,那就是它們都有類的慨念,而通過類可以創(chuàng)建任意多個具有相同屬性和方法的對象。典型的面向對象編程語言(比如 C++ 和 Java),存在“類”(class
)這個概念。所謂:
“類”就是對象的模板,對象就是“類”的實例 。
ECMAScript 中沒有類的概念,因此它的對象也與基于類的語言中的對象有所不同。
但是,JavaScript 語言的對象體系,不是基于“類”的,而是基于構造函數(shù)(constructor
)和原型鏈(prototype
)。
一、創(chuàng)建單個對象
A. Object Constructor:
創(chuàng)建自定義對象的最簡單方式就是創(chuàng)建一個 Object
的實例,然后再為它添加屬性和方法,如下:
var person = new Object();
person.name = "Nicholas";
person.age = 29;
person.job = "Software Engineer";
person.sayName = function(){
console.log(this.name);
};
上面的例子創(chuàng)建了一個名為 person 的對象,并為它添加了三個屬性(name
、ag
e 和 job
)和一個方法(sayName()
)。其中,sayName()
方法用于顯示 this.name
(將被解析為 person.name
)的值。
B. Object Literal
早期的 JavaScript 開發(fā)人員經(jīng)常使用 Object構造器 模式創(chuàng)建新對象。幾年后, 對象字面量 成為創(chuàng)建這種對象的首選模式。
var person = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName: function(){
alert(this.name);
}
};
雖然 Object 構造函數(shù) 或 對象字面量 都可以用來創(chuàng)建單個對象,但這些方式有個明顯的缺點:使用同一個接口創(chuàng)建很多對象,會產(chǎn)生大量的重復代碼。為解決這個問題,人們開始使用工廠模式的一種變體。
二、Factory pattern
工廠模式是軟件工程領域一種廣為人知的設計模式,這種模式 抽象了創(chuàng)建具體對象的過程??紤]到在 ECMAScript 中無法創(chuàng)建類,開發(fā)人員就發(fā)明了一種函數(shù),用函數(shù)來封裝以特定接口創(chuàng)建對象的細節(jié),如下面的例子所示。
function createPerson(name, age, job){
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function(){
console.log(this.name);
};
return o;
}
var person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");
函數(shù) createPerson()
能夠根據(jù)接受的參數(shù)來構建一個包含所有必要信息的 Person
對象??梢詿o數(shù)次地調用這個函數(shù),而每次它都會返回一個包含三個屬性一個方法的對象。
工廠模式雖然 解決了創(chuàng)建多個相似對象的問題 ,但卻 沒有解決對象識別的問題 (即怎樣知道一個對象的類型)。隨著 JavaScript的發(fā)展,又一個新模式出現(xiàn)了。
三、Constructor pattern
ECMAScript 中的構造函數(shù)可用來創(chuàng)建特定類型的對象。像 Object
和 Array
這樣的原生構造函數(shù),在運行時會自動出現(xiàn)在執(zhí)行環(huán)境中。
此外,也 可以創(chuàng)建自定義的構造函數(shù),從而定義自定義對象類型的屬性和方法。例如,可以使用構造函數(shù)模式將前面的例子重寫如下:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = function(){
console.log(this.name);
};
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
在這個例子中,Person()
中的代碼除了與 createPerson()
中相同的部分外,還存在以下不同之處:
- 沒有顯式地創(chuàng)建對象;
- 直接將屬性和方法賦給了
this
對象; - 沒有
return
語句。
此外,還應該注意到函數(shù)名 Person
使用的是大寫字母 P
。按照慣例,構造函數(shù)始終都應該以一個大寫字母開頭,而非構造函數(shù)則應該以一個小寫字母開頭。這個做法借鑒自其他 OO 語言,主要是為了區(qū)別于 ECMAScript 中的其他函數(shù);因為構造函數(shù)本身也是函數(shù),只不過可以用來創(chuàng)建對象而已。
要創(chuàng)建 Person
的新實例,必須使用 new
操作符。以這種方式調用構造函數(shù)實際上會經(jīng)歷以下 4個步驟:
- 創(chuàng)建一個新對象;
- 將構造函數(shù)的作用域賦給新對象(因此
this
就指向了這個新對象); - 執(zhí)行構造函數(shù)中的代碼(為這個新對象添加屬性);
- 返回新對象。
想了解具體 new 作用的細節(jié),可以參考 阮一峰老師的 構造函數(shù)與 new 命令 章節(jié)。
在前面例子的最后,person1
和 person2
分別保存著 Person
的一個不同的實例。這兩個對象都有一個
constructor
(構造函數(shù))屬性,該屬性指向 Person
,如下所示。
alert(person1.constructor == Person); //true
alert(person2.constructor == Person); //true
對象的 constructor
屬性最初是用來標識對象類型的。但是,提到檢測對象類型,還是 instanceof
操作符要更可靠一些。我們在這個例子中創(chuàng)建的所有對象既是 Object
的實例,同時也是 Person
的實例,這一點通過 instanceof
操作符可以得到驗證。
alert(person1 instanceof Object); // true
alert(person1 instanceof Person); // true
alert(person2 instanceof Object); // true
alert(person2 instanceof Person); // true
創(chuàng)建自定義的構造函數(shù)意味著 將來可以 將它的實例標識為一種特定的類型,而這正是構造函數(shù)模式勝過工廠模式的地方。在這個例子中,person1
和 person2
之所以同時是 Object
的實例,是因為所有對象均繼承自 Object
。
問題一: 將構造函數(shù)當作函數(shù)
構造函數(shù)與其他函數(shù)的唯一區(qū)別,就在于調用它們的方式不同。不過,構造函數(shù)畢竟也是函數(shù),不存在定義構造函數(shù)的特殊語法。
任何函數(shù),只要通過 new
操作符來調用,那它就可以作為構造函數(shù);而任何函數(shù),如果不通過 new
操作符來調用,那它跟普通函數(shù)也不會有什么兩樣。
例如,前面例子中定義的 Person()
函數(shù)可以通過下列任何一種方式來調用。
// 當作構造函數(shù)使用
var person = new Person("Nicholas", 29, "Software Engineer");
person.sayName(); // "Nicholas"
// 作為普通函數(shù)調用
Person("Greg", 27, "Doctor"); // 添加到 window
window.sayName(); // "Greg"
// 在另一個對象的作用域中調用
var o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName(); // "Kristen"
這個例子中的前兩行代碼展示了構造函數(shù)的典型用法,即使用 new
操作符來創(chuàng)建一個新對象。
接下來的兩行代碼展示了不使用 new
操作符調用 Person()
會出現(xiàn)什么結果:屬性和方法都被添加給 window
對象了。有讀者可能還記得,當在全局作用域中調用一個函數(shù)時,this
對象總是指向 Global
對象(在瀏覽器中就是 window
對象)。因此,在調用完函數(shù)之后,可以通過 window 對象來調用 sayName()
方法,并且還返回了 "Greg"。
最后,也可以使用 call()
(或者 apply()
)在某個特殊對象的作用域中調用Person()
函數(shù)。這里是在對象 o
的作用域中調用的,因此調用后 o
就擁有了所有屬性和 sayName()
方法。
問題二: 構造函數(shù)的問題
構造函數(shù)模式雖然好用,但也并非沒有缺點。使用 構造函數(shù)的主要問題,就是每個方法都要在每個實例上重新創(chuàng)建一遍。
在前面的例子中,person1
和 person2
都有一個名為 sayName()
的方法,但那兩個方法不是同一個 Function
的實例。不要忘了——ECMAScript
中的函數(shù)是對象,因此每定義一個函數(shù),也就是實例化了一個對象。從邏輯角度講,此時的構造函數(shù)也可以這樣定義。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function("console.log(this.name)"); // 與聲明函數(shù)在邏輯上是等價的
}
從這個角度上來看構造函數(shù),更容易明白每個 Person
實例都包含一個不同的 Function
實例(以顯示 name 屬性)的本質。說明白些,以這種方式創(chuàng)建函數(shù),會導致不同的作用域鏈和標識符解析,但創(chuàng)建 Function
新實例的機制仍然是相同的。因此,不同實例上的同名函數(shù)是不相等的,以下代碼可以證明這一點。
console.log(person1.sayName == person2.sayName); // false
然而,創(chuàng)建兩個完成同樣任務的 Function
實例的確沒有必要;況且有 this
對象在,根本不用在執(zhí)行代碼前就把函數(shù)綁定到特定對象上面。因此,大可像下面這樣,通過把函數(shù)定義轉移到構造函數(shù)外部 來解決這個問題。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName(){
console.log(this.name);
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
在這個例子中,我們把 sayName()
函數(shù)的定義轉移到了構造函數(shù)外部。而在構造函數(shù)內部,我們將 sayName
屬性設置成等于全局的 sayName
函數(shù)。這樣一來,由于 sayName
包含的是一個指向函數(shù)的指針,因此 person1
和 person2
對象就共享了在全局作用域中定義的同一個 sayName()
函數(shù)。
這樣做確實解決了兩個函數(shù)做同一件事的問題,可是新問題又來了:在全局作用域中定義的函數(shù)實際上只能被某個對象調用,這讓全局作用域有點名不副實。而更讓人無法接受的是:如果對象需要定義很多方法,那么就要定義很多個全局函數(shù),于是我們這個自定義的引用類型就絲毫沒有封裝性可言了。
好在,這些問題可以通過使用 原型模式 來解決。
四、Prototype pattern
我們創(chuàng)建的每個函數(shù)都有一個 prototype
(原型)屬性,這個屬性是一個指針,指向一個對象,而這個對象的用途是包含可以由特定類型的所有實例共享的屬性和方法。如果按照字面意思來理解,那么 prototype
就是 通過調用構造函數(shù)而創(chuàng)建的那個對象實例的原型對象 。使用原型對象的好處是可以讓所有對象實例共享它所包含的屬性和方法。
換句話說,不必在構造函數(shù)中定義對象實例的信息,而是可以將這些信息直接添加到原型對象中,如下面的例子所示。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
console.log(this.name);
};
var person1 = new Person();
person1.sayName(); // "Nicholas"
var person2 = new Person();
person2.sayName(); // "Nicholas"
console.log(person1.sayName == person2.sayName); // true
在此,我們將 sayName()
方法和所有屬性直接添加到了 Person
的 prototype
屬性中,構造函數(shù)變成了空函數(shù)。
即使如此,也仍然可以通過調用構造函數(shù)來創(chuàng)建新對象,而且新對象還會具有相同的屬性和方法。但與構造函數(shù)模式不同的是,新對象的這些屬性和方法是由所有實例共享的。換句話說,person1
和 person2
訪問的都是同一組屬性和同一個 sayName()
函數(shù)。
要理解原型模式的工作原理,必須先理解 ECMAScript 中原型對象的性質。
知識點一:理解原型對象
無論什么時候,只要創(chuàng)建了一個新函數(shù),就會根據(jù)一組特定的規(guī)則為該函數(shù)創(chuàng)建一個 prototype
屬性,這個屬性指向函數(shù)的原型對象。
在默認情況下,所有原型對象都會自動獲得一個 constructor
(構造函數(shù))屬性,這個屬性包含一個指向 prototype
屬性所在函數(shù)的指針。就拿前面的例子來說,Person.prototype.constructor
指向 Person
。而通過這個構造函數(shù),我們還可繼續(xù)為原型對象添加其他屬性和方法。
創(chuàng)建了自定義的構造函數(shù)之后,其原型對象默認只會取得
constructor
屬性;至于其他方法,則都是從 Object 繼承而來的。
知識點二:構造函數(shù)、構造函數(shù)的原型對象、構造函數(shù)的實例
當調用構造函數(shù)創(chuàng)建一個新實例后,該實例的內部將包含一個指針(內部屬性),指向構造函數(shù)的原型對象。
ECMA-262 第 5 版中管這個指針叫 [[Prototype]]
。雖然在腳本中沒有標準的方式訪問 [[Prototype]]
,但 Firefox、Safari 和 Chrome 在每個對象上都支持一個屬性 __proto__
;而在其他實現(xiàn)中,這個屬性對腳本則是完全不可見的。
不過,要明確的真正重要的一點就是,這個連接存在于 實例與構造函數(shù)的原型對象之間,而不是 存在于實例與構造函數(shù)之間。
以前面使用 Person
構造函數(shù)和 Person.prototype
創(chuàng)建實例的代碼為例,下圖展示了各個對象之間的關系。
上圖展示了 Person 構造函數(shù)、Person 的原型屬性以及 Person 現(xiàn)有的兩個實例之間的關系。在此,
-
Person.prototype
指向了原型對象,而Person.prototype.constructor
又指回了Person
。 - 原型對象中除了包含
constructor
屬性之外,還包括后來添加的其他屬性。 -
Person
的每個實例——person1
和person2
都包含一個內部屬性,該屬性僅僅指向了Person.prototype
;
換句話說,它們與構造函數(shù)沒有直接的關系。此外,要格外注意的是,雖然這兩個實例都不包含屬性和方法,但我們卻可以調用 person1.sayName()
。這是通過查找對象屬性的過程來實現(xiàn)的。
知識點三:isPrototypeOf()
雖然在所有實現(xiàn)中都無法訪問到 [[Prototype]]
,但可以通過 isPrototypeOf()
方法來確定對象之間是否存在這種關系。從本質上講,如果 [[Prototype]]
指向調用 isPrototypeOf()
方法的對象(Person.prototype
),那么這個方法就返回 true,如下所示:
console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Person.prototype.isPrototypeOf(person2)); // true
這里,我們用原型對象的 isPrototypeOf()
方法測試了 person1
和 person2
。因為它們內部都有一個指向 Person.prototype
的指針,因此都返回了 true
。
知識點四:getPrototypeOf()
ECMAScript 5 增加了一個新方法,叫 Object.getPrototypeOf()
,在所有支持的實現(xiàn)中,這個方法返回 [[Prototype]]
的值。例如:
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true
console.log(Object.getPrototypeOf(person1).name); // "Nicholas"
這里的第一行代碼只是確定 Object.getPrototypeOf()
返回的對象實際就是這個對象的原型。第二行代碼取得了原型對象中 name
屬性的值,也就是 "Nicholas"
。使用 Object.getPrototypeOf()
可以方便地取得一個對象的原型,而這在利用原型實現(xiàn)繼承的情況下是非常重要的(支持這個方法的瀏覽器有 IE9+、Firefox 3.5+、Safari 5+、Opera 12+和 Chrome)。
知識五:代碼讀取對象的屬性 - 程序執(zhí)行過程
每當代碼讀取某個對象的某個屬性時,都會執(zhí)行一次搜索,目標是具有給定名字的屬性。
- 搜索首先從對象實例本身開始。
- 如果在實例中找到了具有給定名字的屬性,則返回該屬性的值;如果沒有找到,則繼續(xù)搜索指針指向的原型對象,在原型對象中查找具有給定名字的屬性。
- 如果在原型對象中找到了這個屬性,則返回該屬性的值。
也就是說,在我們調用 person1.sayName()
的時候,會先后執(zhí)行兩次搜索。
- 首先,解析器會問:“實例
person1
有sayName
屬性嗎?” 答:“沒有?!?/li> - 然后,它繼續(xù)搜索,再問:“
person1
的原型有sayName
屬性嗎? ”答:“有?!?/li>
于是,它就讀取那個保存在原型對象中的函數(shù)。當我們調用 person2.sayName()
時,將會重現(xiàn)相同的搜索過程,得到相同的結果。而這正是多個對象實例共享原型所保存的屬性和方法的基本原理。
前面提到過,原型最初只包含
constructor
屬性,而該屬性也是共享的,因此可以通過對象實例訪問。
知識六:對象實例重寫原型中的值
雖然可以通過對象實例訪問保存在原型中的值,但卻不能通過對象實例重寫原型中的值。如果我們在實例中添加了一個屬性,而該屬性與實例原型中的一個屬性同名,那我們就 在實例中創(chuàng)建該屬性,該屬性將會屏蔽原型中的那個屬性 。來看下面的例子。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
console.log(this.name);
};
var person1 = new Person();
var person2 = new Person();
person1.name = "Greg";
console.log(person1.name); // "Greg" —— 來自實例
console.log(person2.name); // "Nicholas" —— 來自原型
在這個例子中,person1
的 name
被一個新值給屏蔽了。但無論訪問 person1.name
還是訪問 person2.name
都能夠正常地返回值,即分別是"Greg"(來自對象實例)和"Nicholas"(來自原型)。
- 當在
console.log()
中訪問person1.name
時,需要讀取它的值,因此就會在這個實例上搜索一個名為name
的屬性。這個屬性確實存在,于是就返回它的值而不必再搜索原型了。 - 當以同樣的方式訪問
person2.name
時,并沒有在實例上發(fā)現(xiàn)該屬性,因此就會繼續(xù)搜索原型,結果在那里找到了name
屬性。
當為對象實例添加一個屬性時,這個屬性就會屏蔽原型對象中保存的同名屬性;換句話說,添加這個屬性只會阻止我們訪問原型中的那個屬性,但不會修改那個屬性。即使將這個屬性設置為 null
,也只會在實例中設置這個屬性,而不會恢復其指向原型的連接。不過,使用 delete
操作符則可以完全刪除實例屬性,從而讓我們能夠重新訪問原型中的屬性,如下所示。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
console.log(this.name);
};
var person1 = new Person();
var person2 = new Person();
person1.name = "Greg";
console.log(person1.name); // "Greg"——來自實例
console.log(person2.name); // "Nicholas"——來自原型
delete person1.name;
console.log(person1.name); // "Nicholas"——來自原型
在這個修改后的例子中,我們使用 delete
操作符刪除了 person1.name
,之前它保存的 "Greg"
值屏蔽了同名的原型屬性。把它刪除以后,就恢復了對原型中 name
屬性的連接。因此,接下來再調用person1.name
時,返回的就是原型中 name
屬性的值了。
知識點七:hasOwnProperty()
使用 hasOwnProperty()
方法可以檢測一個屬性是存在于實例中,還是存在于原型中。這個方法(不要忘了它是從 Object
繼承來的)只在給定屬性存在于對象實例中時,才會返回 true。來看下面這個例子。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
console.log(this.name);
};
var person1 = new Person();
var person2 = new Person();
console.log(person1.hasOwnProperty("name")); // false
person1.name = "Greg";
console.log(person1.name); // "Greg"——來自實例
console.log(person1.hasOwnProperty("name")); // true
console.log(person2.name); // "Nicholas"——來自原型
console.log(person2.hasOwnProperty("name")); // false
delete person1.name;
console.log(person1.name); // "Nicholas"——來自原型
console.log(person1.hasOwnProperty("name")); // false
通過使用 hasOwnProperty()
方法,什么時候訪問的是實例屬性,什么時候訪問的是原型屬性就一清二楚了。
調用 person1.hasOwnProperty( "name")
時,只有當 person1
重寫 name
屬性后才會返回 true,因為只有這時候 name
才是一個實例屬性,而非原型屬性。下圖展示了上面例子在不同情況下的實現(xiàn)與原型的關系(為了簡單起見,圖中省略了與 Person 構造函數(shù)的關系)。
ECMAScript 5 的 Object.getOwnPropertyDescriptor()
方法只能用于實例屬性,要取得原型屬性的描述符,必須直接在原型對象上調用 Object.getOwnPropertyDescriptor()
方法。
知識點八:原型與 in 操作符
有兩種方式使用 in 操作符:單獨使用和在 for-in 循環(huán)中使用。在單獨使用時,in 操作符會在通過對象能夠訪問給定屬性時返回 true,無論該屬性存在于實例中還是原型中??匆豢聪旅娴睦?。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
console.log(this.name);
};
var person1 = new Person();
var person2 = new Person();
console.log(person1.hasOwnProperty("name")); // false
console.log("name" in person1); // true
person1.name = "Greg";
console.log(person1.name); // "Greg" ——來自實例
console.log(person1.hasOwnProperty("name")); // true
console.log("name" in person1); // true
console.log(person2.name); // "Nicholas" ——來自原型
console.log(person2.hasOwnProperty("name")); // false
console.log("name" in person2); // true
delete person1.name;
console.log(person1.name); // "Nicholas" ——來自原型
console.log(person1.hasOwnProperty("name")); // false
console.log("name" in person1); // true
在以上代碼執(zhí)行的整個過程中,name
屬性要么是直接在對象上訪問到的,要么是通過原型訪問到的。因此,調用 "name" in person1
始終都返回 true
,無論該屬性存在于實例中還是存在于原型中。
同時使用 hasOwnProperty()
方法和 in
操作符,就可以確定該屬性到底是存在于對象中,還是存在于原型中,如下所示。
function hasPrototypeProperty(object, name){
return !object.hasOwnProperty(name) && (name in object);
}
由于 in
操作符只要通過對象能夠訪問到屬性就返回 true
,hasOwnProperty()
只在屬性存在于實例中時才返回 true
,因此只要 in
操作符返回 true
而 hasOwnProperty()
返回 false
,就可以確定屬性是原型中的屬性。
下面來看一看上面定義的函數(shù) hasPrototypeProperty()
的用法。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
console.log(this.name);
};
var person = new Person();
console.log(hasPrototypeProperty(person, "name")); // true
person.name = "Greg";
console.log(hasPrototypeProperty(person, "name")); // false
在這里,name
屬性先是存在于原型中,因此 hasPrototypeProperty()
返回 true
。當在實例中重寫 name
屬性后,該屬性就存在于實例中了,因此 hasPrototypeProperty()
返回 false
。即使原型中仍然有 name
屬性,但由于現(xiàn)在實例中也有了這個屬性,因此原型中的 name
屬性就用不到了。
在使用 for-in
循環(huán)時,返回的是所有能夠通過對象訪問的、可枚舉的(enumerated)屬性,其中既包括存在于實例中的屬性,也包括存在于原型中的屬性。屏蔽了原型中不可枚舉屬性(即將 [[Enumerable]]
標記為 false
的屬性)的實例屬性也會在 for-in
循環(huán)中返回,因為根據(jù)規(guī)定,所有開發(fā)人員定義的屬性都是可枚舉的——只有在 IE8 及更早版本中例外。
知識點十:Object.keys()
要取得對象上所有可枚舉的實例屬性,可以使用 ECMAScript 5 的 Object.keys()
方法。這個方法接收一個對象作為參數(shù),返回一個包含所有可枚舉屬性的字符串數(shù)組。例如:
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
console.log(this.name);
};
var keys = Object.keys(Person.prototype);
console.log(keys); // "name,age,job,sayName"
var p1 = new Person();
p1.name = "Rob";
p1.age = 31;
var p1keys = Object.keys(p1);
console.log(p1keys); // "name,age"
這里,變量 keys
中將保存一個數(shù)組,數(shù)組中是字符串 "name"
、"age"
、"job"
和 "sayName"
。這個順序也是它們在 for-in
循環(huán)中出現(xiàn)的順序。如果是通過 Person
的實例調用,則 Object.keys()
返回的數(shù)組只包含 "name"
和 "age"
這兩個實例屬性。
知識點十一:Object.getOwnPropertyNames()
如果你想要得到所有實例屬性,無論它是否可枚舉,都可以使用 Object.getOwnPropertyNames()
方法。
var keys = Object.getOwnPropertyNames(Person.prototype);
console.log(keys); // "constructor,name,age,job,sayName"
注意結果中包含了不可枚舉的 constructor
屬性。Object.keys()
和 Object.getOwnPropertyNames()
方法都可以用來替代 for-in
循環(huán)。支持這兩個方法的瀏覽器有 IE9+、Firefox 4+、Safari 5+、Opera12+和 Chrome。
知識點十二:更簡單的原型語法
智慧如你,你大概注意到了,前面例子中每添加一個屬性和方法就要敲一遍 Person.prototype
。為減少不必要的輸入,也為了從視覺上更好地封裝原型的功能,更常見的做法是 用一個包含所有屬性和方法的對象字面量來重寫整個原型對象,如下面的例子所示。
function Person(){
}
Person.prototype = {
name : "Nicholas",
age : 29,
job: "Software Engineer",
sayName : function () {
console.log(this.name);
}
};
在上面的代碼中,我們將 Person.prototype
設置為等于一個 以對象字面量形式創(chuàng)建的新對象。最終結果相同,但有一個例外:constructor
屬性不再指向 Person
了。
前面曾經(jīng)介紹過,每創(chuàng)建一個函數(shù),就會同時創(chuàng)建它的 prototype
對象,這個對象也會自動獲得 constructor
屬性。而我們在這里使用的語法,本質上完全重寫了默認的 prototype
對象,因此 constructor
屬性也就變成了新對象的 constructor
屬性(指向 Object 構造函數(shù)),不再指向 Person
函數(shù)。此時,盡管 instanceof
操作符還能返回正確的結果,但通過 constructor
已經(jīng)無法確定對象的類型了,如下所示。
var friend = new Person();
console.log(friend instanceof Object); // true
console.log(friend instanceof Person); // true
console.log(friend.constructor == Person); // false
console.log(friend.constructor == Object); // true
在此,用 instanceof
操作符測試 Object
和 Person
仍然返回 true
,但 constructor
屬性則等于 Object
而不等于 Person
了。如果 constructor
的值真的很重要,可以像下面這樣特意將它設置回適當?shù)闹怠?/p>
function Person(){
}
Person.prototype = {
constructor : Person,
name : "Nicholas",
age : 29,
job: "Software Engineer",
sayName : function () {
console.log(this.name);
}
};
以上代碼特意包含了一個 constructor
屬性,并將它的值設置為 Person
,從而確保了通過該屬性能夠訪問到適當?shù)闹怠?br>
注意,以這種方式重設 constructor
屬性會導致它的 [[Enumerable]]
特性被設置為 true
。默認情況下,原生的 constructor
屬性是不可枚舉的,因此如果你使用兼容 ECMAScript 5 的 JavaScript 引擎,可以試一試 Object.defineProperty()
。
function Person(){
}
Person.prototype = {
name : "Nicholas",
age : 29,
job : "Software Engineer",
sayName : function () {
console.log(this.name);
}
};
// 重設構造函數(shù),只適用于 ECMAScript 5 兼容的瀏覽器
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false,
value: Person
});
知識點十三: 原型的動態(tài)性
由于在原型中查找值的過程是一次搜索,因此我們對原型對象所做的任何修改都能夠立即從實例上反映出來——即使是先創(chuàng)建了實例后修改原型也照樣如此。請看下面的例子。
var friend = new Person();
Person.prototype.sayHi = function(){
console.log("hi");
};
friend.sayHi(); // "hi"
以上代碼先創(chuàng)建了 Person
的一個實例,并將其保存在 person
中。然后,下一條語句在 Person.prototype
中添加了一個方法 sayHi()
。即使 person
實例是在添加新方法之前創(chuàng)建的,但它仍然可以訪問這個新方法。其原因可以歸結為實例與原型之間的松散連接關系。當我們調用 person.sayHi()
時,首先會在實例中搜索名為 sayHi
的屬性,在沒找到的情況下,會繼續(xù)搜索原型。因為實例與原型之間的連接只不過是一個指針,而非一個副本,因此就可以在原型中找到新的 sayHi
屬性并返回保存在那里的函數(shù)。
盡管可以隨時為原型添加屬性和方法,并且修改能夠立即在所有對象實例中反映出來,但如果是重寫整個原型對象,那么情況就不一樣了。我們知道,調用構造函數(shù)時會為實例添加一個指向最初原型的 [[Prototype]]
指針,而把原型修改為另外一個對象就等于切斷了構造函數(shù)與最初原型之間的聯(lián)系。
請記?。?strong>實例中的指針僅指向原型,而不指向構造函數(shù)。看下面的例子。
function Person(){
}
var friend = new Person();
Person.prototype = {
constructor: Person,
name : "Nicholas",
age : 29,
job : "Software Engineer",
sayName : function () {
console.log(this.name);
}
};
friend.sayName(); // error
在這個例子中,我們先創(chuàng)建了 Person
的一個實例,然后又重寫了其原型對象。然后在調用 friend.sayName()
時發(fā)生了錯誤,因為 friend
指向的原型中不包含以該名字命名的屬性。下圖展示了這個過程的內幕。
從圖中可以看出,重寫原型對象切斷了現(xiàn)有原型與任何之前已經(jīng)存在的對象實例之間的聯(lián)系;它們引用的仍然是最初的原型。
知識點十四:原生對象的原型
原型模式的重要性不僅體現(xiàn)在創(chuàng)建自定義類型方面,就連所有原生的引用類型,都是采用這種模式創(chuàng)建的。所有原生引用類型(Object、Array、String,等等)都在其構造函數(shù)的原型上定義了方法。
例如,在 Array.prototype
中可以找到 sort()
方法,而在 String.prototype
中可以找到 substring()
方法,如下所示。
console.log(typeof Array.prototype.sort); // "function"
console.log(typeof String.prototype.substring); // "function"
通過原生對象的原型,不僅可以取得所有默認方法的引用,而且也可以定義新方法??梢韵裥薷淖远x對象的原型一樣修改原生對象的原型,因此可以隨時添加方法。下面的代碼就給基本包裝類型 String
添加了一個名為 startsWith()
的方法。
String.prototype.startsWith = function (text) {
return this.indexOf(text) == 0;
};
var msg = "Hello world!";
console.log(msg.startsWith("Hello")); // true
這里新定義的 startsWith()
方法會在傳入的文本位于一個字符串開始時返回 true
。既然方法被添加給了 String.prototype
,那么當前環(huán)境中的所有字符串就都可以調用它。由于 msg
是字符串,而且后臺會調用 String
基本包裝函數(shù)創(chuàng)建這個字符串,因此通過 msg
就可以調用 startsWith()
方法。
知識點十五:原型對象的問題
原型模式也不是沒有缺點。首先,它省略了為構造函數(shù)傳遞初始化參數(shù)這一環(huán)節(jié),結果所有實例在默認情況下都將取得相同的屬性值。雖然這會在某種程度上帶來一些不方便,但還不是原型的最大問題。原型模式的最大問題是由其共享的本性所導致的。
原型中所有屬性是被很多實例共享的,這種共享對于函數(shù)非常合適。對于那些包含基本值的屬性倒也說得過去,畢竟(如前面的例子所示),通過在實例上添加一個同名屬性,可以隱藏原型中的對應屬性。然而,對于包含引用類型值的屬性來說,問題就比較突出了。來看下面的例子。
function Person(){
}
Person.prototype = {
constructor: Person,
name : "Nicholas",
age : 29,
job : "Software Engineer",
friends : ["Shelby", "Court"],
sayName : function () {
console.log(this.name);
}
};
var person1 = new Person();
var person2 = new Person();
person1.friends.push("Van");
console.log(person1.friends); // "Shelby,Court,Van"
console.log(person2.friends); // "Shelby,Court,Van"
console.log(person1.friends === person2.friends); // true
在此,Person.prototype
對象有一個名為 friends
的屬性,該屬性包含一個字符串數(shù)組。然后,創(chuàng)建了 Person
的兩個實例。接著,修改了 person1.friends
引用的數(shù)組,向數(shù)組中添加了一個字符串。由于 friends
數(shù)組存在于 Person.prototype
而非 person1
中,所以剛剛提到的修改也會通過person2.friends
(與 person1.friends
指向同一個數(shù)組)反映出來。
假如我們的初衷就是像這樣在所有實例中共享一個數(shù)組,那么對這個結果我沒有話可說??墒?,實例一般都是要有屬于自己的全部屬性的。而這個問題正是我們很少看到有人單獨使用原型模式的原因所在。
五、Constructor / prototype pattern
創(chuàng)建自定義類型的最常見方式,就是 組合使用構造函數(shù)模式與原型模式。
- 構造函數(shù)模式用于定義實例屬性
- 而原型模式用于定義方法和共享的屬性。
結果,每個實例都會有自己的一份實例屬性的副本,但同時又共享著對方法的引用,最大限度地節(jié)省了內存。另外,這種混成模式還支持向構造函數(shù)傳遞參數(shù),可謂是集兩種模式之長。下面的代碼重寫了前面的例子。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.friends = ["Shelby", "Court"];
}
Person.prototype = {
constructor : Person,
sayName : function(){
console.log(this.name);
}
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
person1.friends.push("Van");
console.log(person1.friends); // "Shelby,Count,Van"
console.log(person2.friends); // "Shelby,Count"
console.log(person1.friends === person2.friends); // false
console.log(person1.sayName === person2.sayName); // true
在這個例子中,實例屬性都是在構造函數(shù)中定義的,而由所有實例共享的屬性 constructor
和方法 sayName()
則是在原型中定義的。而修改了 person1.friends
(向其中添加一個新字符串),并不會影響到 person2.friends
,因為它們分別引用了不同的數(shù)組。
這種構造函數(shù)與原型混成的模式,是目前在 ECMAScript 中使用最廣泛、認同度最高的一種創(chuàng)建自定義類型的方法。可以說,這是用來定義引用類型的一種默認模式。
六、Dynamic prototype pattern
有其他 OO 語言經(jīng)驗的開發(fā)人員在看到獨立的構造函數(shù)和原型時,很可能會感到非常困惑。
動態(tài)原型模式 正是致力于解決這個問題的一個方案,它把所有信息都封裝在了構造函數(shù)中,而通過在構造函數(shù)中初始化原型(僅在必要的情況下),又保持了同時使用構造函數(shù)和原型的優(yōu)點。換句話說,可以通過
檢查某個應該存在的方法是否有效,來決定是否需要初始化原型。來看一個例子。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
if (typeof this.sayName != "function"){
Person.prototype.sayName = function(){
console.log(this.name);
};
}
}
var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName();
注意構造函數(shù)代碼中方法的部分。這里只在 sayName()
方法不存在的情況下,才會將它添加到原型中。這段代碼只會在初次調用構造函數(shù)時才會執(zhí)行。此后,原型已經(jīng)完成初始化,不需要再做什么修改了。不過要記住,這里對原型所做的修改,能夠立即在所有實例中得到反映。因此,這種方法確實可以說非常完美。其中,if 語句檢查的可以是初始化之后應該存在的任何屬性或方法——不必用一大堆if 語句檢查每個屬性和每個方法;只要檢查其中一個即可。對于采用這種模式創(chuàng)建的對象,還可以使用 instanceof 操作符確定它的類型。
使用動態(tài)原型模式時,不能使用對象字面量重寫原型。前面已經(jīng)解釋過了,如果在已經(jīng)創(chuàng)建了實例的情況下重寫原型,那么就會切斷現(xiàn)有實例與新原型之間的聯(lián)系。
七、Parasitic constructor pattern
通常,在前述的幾種模式都不適用的情況下,可以使用 寄生(parasitic)構造函數(shù) 模式。這種模式的基本思想是創(chuàng)建一個函數(shù),該函數(shù)的作用僅僅是封裝創(chuàng)建對象的代碼,然后再返回新創(chuàng)建的對象;但從表面上看,這個函數(shù)又很像是典型的構造函數(shù)。下面是一個例子。
function Person(name, age, job){
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function(){
console.log(this.name);
};
return o;
}
var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName(); // "Nicholas"
在這個例子中,Person
函數(shù)創(chuàng)建了一個新對象,并以相應的屬性和方法初始化該對象,然后又返回了這個對象。除了使用 new
操作符并把使用的包裝函數(shù)叫做構造函數(shù)之外,這個模式跟工廠模式其實是一模一樣的。構造函數(shù)在不返回值的情況下,默認會返回新對象實例。而通過在構造函數(shù)的末尾添加一個 return
語句,可以重寫調用構造函數(shù)時返回的值。
這個模式可以在特殊的情況下用來為對象創(chuàng)建構造函數(shù)。假設我們想創(chuàng)建一個具有額外方法的特殊數(shù)組。由于不能直接修改 Array
構造函數(shù),因此可以使用這個模式。
function SpecialArray(){
//創(chuàng)建數(shù)組
var values = new Array();
//添加值
values.push.apply(values, arguments);
//添加方法
values.toPipedString = function(){
return this.join("|");
};
//返回數(shù)組
return values;
}
var colors = new SpecialArray("red", "blue", "green");
console.log(colors.toPipedString()); // "red|blue|green"
在這個例子中,我們創(chuàng)建了一個名叫 SpecialArray
的構造函數(shù)。在這個函數(shù)內部,首先創(chuàng)建了一個數(shù)組,然后 push()
方法(用構造函數(shù)接收到的所有參數(shù))初始化了數(shù)組的值。隨后,又給數(shù)組實例添加了一個 toPipedString()
方法,該方法返回以豎線分割的數(shù)組值。最后,將數(shù)組以函數(shù)值的形式返回。接著,我們調用了 SpecialArray
構造函數(shù),向其中傳入了用于初始化數(shù)組的值,此后又調用了 toPipedString()
方法。
關于寄生構造函數(shù)模式,有一點需要說明:首先,返回的對象與構造函數(shù)或者與構造函數(shù)的原型屬性之間沒有關系;也就是說,構造函數(shù)返回的對象與在構造函數(shù)外部創(chuàng)建的對象沒有什么不同。為此,不能依賴 instanceof
操作符來確定對象類型。由于存在上述問題,我們建議在可以使用其他模式的情況下,不要使用這種模式。
八、Durable constructor pattern
道格拉斯·克羅克福德(Douglas Crockford)發(fā)明了 JavaScript 中的穩(wěn)妥對象(durable objects)這個概念。
所謂穩(wěn)妥對象,指的是沒有公共屬性,而且其方法也不引用 this 的對象。穩(wěn)妥對象最適合在一些安全的環(huán)境中(這些環(huán)境中會禁止使用 this 和 new),或者在防止數(shù)據(jù)被其他應用程序(如 Mashup程序)改動時使用。
穩(wěn)妥構造函數(shù)遵循與寄生構造函數(shù)類似的模式,但有兩點不同:一是新創(chuàng)建對象的實例方法不引用 this;二是不使用 new 操作符調用構造函數(shù)。按照穩(wěn)妥構造函數(shù)的要求,可以將前面的 Person 構造函數(shù)重寫如下。
function Person(name, age, job){
//創(chuàng)建要返回的對象
var o = new Object();
//可以在這里定義私有變量和函數(shù)
//添加方法
o.sayName = function(){
alert(name);
};
//返回對象
return o;
}
注意,在以這種模式創(chuàng)建的對象中,除了使用 sayName()
方法之外,沒有其他辦法訪問 name
的值。
可以像下面使用穩(wěn)妥的 Person
構造函數(shù)。
var friend = Person("Nicholas", 29, "Software Engineer");
friend.sayName(); //"Nicholas"
這樣,變量 friend
中保存的是一個穩(wěn)妥對象,而除了調用 sayName()
方法外,沒有別的方式可
以訪問其數(shù)據(jù)成員。即使有其他代碼會給這個對象添加方法或數(shù)據(jù)成員,但也不可能有別的辦法訪問傳
入到構造函數(shù)中的原始數(shù)據(jù)。穩(wěn)妥構造函數(shù)模式提供的這種安全性,使得它非常適合在某些安全執(zhí)行環(huán)
境——例如,ADsafe(www.adsafe.org)和 Caja(http://code.google.com/p/google-caja/)提供的環(huán)境——
下使用。