第六章 面向對象的程序設計 (2 創建對象)
6.2 創建對象
6.2.1 工廠模式
工廠模式是軟件工程領域一種廣為人知的設計模式,這種模式抽象了創建具體對象的過程。考慮到在ECMAScript 中無法創建類,開發人員就發明了一種函數,用函數來封裝以特定接口創建對象的細節。
function createPerson(name, age, job){
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function(){
alert(this.name);
};
return o;
}
var person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");
工廠模式雖然解決了創建多個相似對象的問題,但卻沒有解決對象識別的問題(即怎樣知道一個對象的類型)。
6.2.2 構造函數模式
ECMAScript 中的構造函數可用來創建特定類型的對象。像Object 和Array 這樣的原生構造函數,在運行時會自動出現在執行環境中。此外,也可以創建自定義的構造函數,從而定義自定義對象類型的屬性和方法。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = function(){
alert(this.name);
};
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
要創建Person 的新實例,必須使用new 操作符。以這種方式調用構造函數實際上會經歷以下4個步驟:
(1) 創建一個新對象;
(2) 將構造函數的作用域賦給新對象(因此this 就指向了這個新對象);
(3) 執行構造函數中的代碼(為這個新對象添加屬性);
(4) 返回新對象。
person1 和person2 分別保存著Person 的一個不同的實例。這兩個對象都有一個constructor(構造函數)屬性,該屬性指向Person
alert(person1.constructor == Person); //true alert(person2.constructor == Person); //true
instanceof操作符檢測對象類型更可靠一些
alert(person1 instanceof Object); //true alert(person1 instanceof Person); //true alert(person2 instanceof Object); //true alert(person2 instanceof Person); //true
創建自定義的構造函數意味著將來可以將它的實例標識為一種特定的類型,而這正是構造函數模式勝過工廠模式的地方。
1.將構造函數當作函數
構造函數畢竟也是函數,不存在定義構造函數的特殊語法。任何函數,只要通過new 操作符來調用,那它就可以作為構造函數;而任何函數,如果不通過new 操作符來調用,那它跟普通函數也不會有什麽兩樣。
// 當作構造函數使用 var person = new Person("Nicholas", 29, "Software Engineer"); person.sayName(); //"Nicholas"
// 作為普通函數調用 Person("Greg", 27, "Doctor"); // 添加到window window.sayName(); //"Greg"
// 在另一個對象的作用域中調用 var o = new Object(); Person.call(o, "Kristen", 25, "Nurse");
Person.apply(o1,[‘lily‘,21,‘Nurse‘]); o.sayName(); //"Kristen"
o1.sayName(); //"lily"
不使用new 操作符調用Person()會出現什麽結果:屬性和方法都被添加給window對象了。當在全局作用域中調用一個函數時,this 對象總是指向Global 對象(在瀏覽器中就是window 對象)。因此,在調用完函數之後,可以通過window 對象來調用sayName()方法,並且還返回了"Greg"。
2. 構造函數的問題
使用構造函數的主要問題,就是每個方法都要在每個實例上重新創建一遍。在前面的例子中,person1 和person2 都有一個名為sayName()的方法,但那兩個方法不是同一個Function 的實例。不要忘了——ECMAScript 中的函數是對象,因此每定義一個函數,也就是實例化了一個對象。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function("alert(this.name)"); // 與聲明函數在邏輯上是等價的
}
alert(person1.sayName == person2.sayName); //false
從這個角度上來看構造函數,更容易明白每個Person 實例都包含一個不同的Function 實例(以顯示name 屬性)的本質。說明白些,以這種方式創建函數,會導致不同的作用域鏈和標識符解析,但創建Function 新實例的機制仍然是相同的。因此,不同實例上的同名函數是不相等的。
通過把函數定義轉移到構造函數外部來解決這個問題。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName(){
alert(this.name);
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
alert(person1.sayName == person2.sayName); //true
6.2.3 原型模式
我們創建的每個函數都有一個prototype(原型)屬性,這個屬性是一個指針,指向一個對象,而這個對象的用途是包含可以由特定類型的所有實例共享的屬性和方法。如果按照字面意思來理解,那麽prototype 就是通過調用構造函數而創建的那個對象實例的原型對象。使用原型對象的好處是可以讓所有對象實例共享它所包含的屬性和方法。換句話說,不必在構造函數中定義對象實例的信息,而是可以將這些信息直接添加到原型對象中,
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person1 = new Person();
person1.sayName(); //"Nicholas"
var person2 = new Person();
person2.sayName(); //"Nicholas"
alert(person1.sayName == person2.sayName); //true
1. 理解原型對象
無論什麽時候,只要創建了一個新函數,就會根據一組特定的規則為該函數創建一個prototype屬性,這個屬性指向函數的原型對象。在默認情況下,所有原型對象都會自動獲得一個constructor(構造函數)屬性,這個屬性包含一個指向prototype 屬性所在函數的指針。就拿前面的例子來說,Person.prototype. constructor 指向Person。
創建了自定義的構造函數之後,其原型對象默認只會取得constructor 屬性;至於其他方法,則都是從Object 繼承而來的。當調用構造函數創建一個新實例後,該實例的內部將包含一個指針(內部屬性),指向構造函數的原型對象。ECMA-262 第5 版中管這個指針叫[[Prototype]]。雖然在腳本中沒有標準的方式訪問[[Prototype]],但Firefox、Safari 和Chrome 在每個對象上都支持一個屬性__proto__;而在其他實現中,這個屬性對腳本則是完全不可見的。
Person.prototype 指向了原型對象,而Person.prototype.constructor 又指回了Person。原型對象中除了包含constructor 屬性之外,還包括後來添加的其他屬性。Person 的每個實例——person1 和person2 都包含一個內部屬性,該屬性僅僅指向了Person.prototype;換句話說,它們與構造函數沒有直接的關系。
從本質上講,如果[[Prototype]]指向調用isPrototypeOf()方法的對象(Person.prototype),那麽這個方法就返回true
alert(Person.prototype.isPrototypeOf(person1)); //true alert(Person.prototype.isPrototypeOf(person2)); //true
ECMAScript 5 增加了一個新方法,叫Object.getPrototypeOf(),在所有支持的實現中,這個方法返回[[Prototype]]的值。例如:
alert(Object.getPrototypeOf(person1) == Person.prototype); //true alert(Object.getPrototypeOf(person1).name); //"Nicholas"
Object.getPrototypeOf()返回的對象實際就是這個對象的原型。
如果我們在實例中添加了一個屬性,而該屬性與實例原型中的一個屬性同名,那我們就在實例中創建該屬性,該屬性將會屏蔽原型中的那個屬性。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person1 = new Person();
var person2 = new Person();
person1.name = "Greg";
alert(person1.name); //"Greg"——來自實例
alert(person2.name); //"Nicholas"——來自原型
當為對象實例添加一個屬性時,這個屬性就會屏蔽原型對象中保存的同名屬性;換句話說,添加這個屬性只會阻止我們訪問原型中的那個屬性,但不會修改那個屬性。即使將這個屬性設置為null,也只會在實例中設置這個屬性,而不會恢復其指向原型的連接。不過,使用delete 操作符則可以完全刪除實例屬性,從而讓我們能夠重新訪問原型中的屬性。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person1 = new Person();
var person2 = new Person();
person1.name = "Greg";
alert(person1.name); //"Greg"——來自實例
alert(person2.name); //"Nicholas"——來自原型
delete person1.name;
alert(person1.name); //"Nicholas"——來自原型
使用hasOwnProperty()方法可以檢測一個屬性是存在於實例中,還是存在於原型中。這個方法(不要忘了它是從Object 繼承來的)只在給定屬性存在於對象實例中時,才會返回true。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person1 = new Person();
var person2 = new Person();
alert(person1.hasOwnProperty("name")); //false
person1.name = "Greg";
alert(person1.name); //"Greg"——來自實例
alert(person1.hasOwnProperty("name")); //true
alert(person2.name); //"Nicholas"——來自原型
alert(person2.hasOwnProperty("name")); //false
delete person1.name;
alert(person1.name); //"Nicholas"——來自原型
alert(person1.hasOwnProperty("name")); //false
2. 原型與 in 操作符
有兩種方式使用in 操作符:單獨使用和在for-in 循環中使用。
在單獨使用時,in 操作符會在通過對象能夠訪問給定屬性時返回true,無論該屬性存在於實例中還是原型中。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person1 = new Person();
var person2 = new Person();
alert(person1.hasOwnProperty("name")); //false
alert("name" in person1); //true
person1.name = "Greg";
alert(person1.name); //"Greg" ——來自實例
alert(person1.hasOwnProperty("name")); //true
alert("name" in person1); //true
alert(person2.name); //"Nicholas" ——來自原型
alert(person2.hasOwnProperty("name")); //false
alert("name" in person2); //true
delete person1.name;
alert(person1.name); //"Nicholas" ——來自原型
alert(person1.hasOwnProperty("name")); //false
alert("name" in person1); //true
name 屬性要麽是直接在對象上訪問到的,要麽是通過原型訪問到的。因此,調用"name" in person1 始終都返回true,無論該屬性存在於實例中還是存在於原型中。
同時使用hasOwnProperty()方法和in 操作符,就可以確定該屬性到底是存在於對象中,還是存在於原型中,如下所示。
function hasPrototypeProperty(object, name){
return !object.hasOwnProperty(name) && (name in object);
}
確定屬性是原型中的屬性
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person = new Person();
alert(hasPrototypeProperty(person, "name")); //true
person.name = "Greg";
alert(hasPrototypeProperty(person, "name")); //false
在使用for-in 循環時,返回的是所有能夠通過對象訪問的、可枚舉的(enumerated)屬性,其中既包括存在於實例中的屬性,也包括存在於原型中的屬性。屏蔽了原型中不可枚舉屬性(即將[[Enumerable]]標記為false 的屬性)的實例屬性也會在for-in 循環中返回,因為根據規定,所有開發人員定義的屬性都是可枚舉的——只有在IE8 及更早版本中例外。
要取得對象上所有可枚舉的實例屬性,可以使用ECMAScript 5 的Object.keys()方法。這個方法接收一個對象作為參數,返回一個包含所有可枚舉屬性的字符串數組。
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var keys = Object.keys(Person.prototype);
alert(keys); //"name,age,job,sayName"
var p1 = new Person();
p1.name = "Rob";
p1.age = 31;
var p1keys = Object.keys(p1);
alert(p1keys); //"name,age"
如果你想要得到所有實例屬性,無論它是否可枚舉,都可以使用Object.getOwnPropertyNames()方法。
var keys = Object.getOwnPropertyNames(Person.prototype);
alert(keys); //"constructor,name,age,job,sayName"
Object.keys()和Object.getOwnProperty-Names()方法都可以用來替代for-in 循環。支持這兩個方法的瀏覽器有IE9+、Firefox 4+、Safari 5+、Opera12+和Chrome。
3. 更簡單的原型語法
常見的做法是用一個包含所有屬性和方法的對象字面量來重寫整個原型對象
function Person(){ } Person.prototype = { name : "Nicholas", age : 29, job: "Software Engineer", sayName : function () { alert(this.name); } };
我們將Person.prototype 設置為等於一個以對象字面量形式創建的新對象。最終結果相同,但有一個例外:constructor 屬性不再指向Person 了。
每創建一個函數,就會同時創建它的prototype 對象,這個對象也會自動獲得constructor 屬性。而我們在這裏使用的語法,本質上完全重寫了默認的prototype 對象,因此constructor 屬性也就變成了新對象的constructor 屬性(指向Object 構造函數),不再指向Person 函數。此時,盡管instanceof操作符還能返回正確的結果,但通過constructor 已經無法確定對象的類型了。
var friend = new Person(); alert(friend instanceof Object); //true alert(friend instanceof Person); //true alert(friend.constructor == Person); //false alert(friend.constructor == Object); //true
如果constructor 的值真的很重要,可以像下面這樣特意將它設置回適當的值。
function Person(){ } Person.prototype = { constructor : Person, name : "Nicholas", age : 29, job: "Software Engineer", sayName : function () { alert(this.name); } };
以上代碼特意包含了一個constructor 屬性,並將它的值設置為Person,從而確保了通過該屬性能夠訪問到適當的值。
註意,以這種方式重設constructor 屬性會導致它的[[Enumerable]]特性被設置為true。默認情況下,原生的constructor 屬性是不可枚舉的,因此如果你使用兼容ECMAScript 5 的JavaScript 引擎,可以試一試Object.defineProperty()。
function Person(){ } Person.prototype = { name : "Nicholas", age : 29, job : "Software Engineer", sayName : function () { alert(this.name); } }; //重設構造函數,只適用於ECMAScript 5 兼容的瀏覽器 Object.defineProperty(Person.prototype, "constructor", { enumerable: false, value: Person });
4. 原型的動態性
由於在原型中查找值的過程是一次搜索,因此我們對原型對象所做的任何修改都能夠立即從實例上反映出來——即使是先創建了實例後修改原型也照樣如此。
var friend = new Person(); Person.prototype.sayHi = function(){ alert("hi"); }; friend.sayHi(); //"hi"(沒有問題!)
即使person 實例是在添加新方法之前創建的,但它仍然可以訪問這個新方法。其原因可以歸結為實例與原型之間的松散連接關系。當我們調用person.sayHi()時,首先會在實例中搜索名為sayHi 的屬性,在沒找到的情況下,會繼續搜索原型。因為實例與原型
之間的連接只不過是一個指針,而非一個副本,因此就可以在原型中找到新的sayHi 屬性並返回保存在那裏的函數。
盡管可以隨時為原型添加屬性和方法,並且修改能夠立即在所有對象實例中反映出來,但如果是重寫整個原型對象,那麽情況就不一樣了。我們知道,調用構造函數時會為實例添加一個指向最初原型的[[Prototype]]指針,而把原型修改為另外一個對象就等於切斷了構造函數與最初原型之間的聯系。請記住:實例中的指針僅指向原型,而不指向構造函數。
function Person(){ } var friend = new Person(); Person.prototype = { constructor: Person, name : "Nicholas", age : 29, job : "Software Engineer", sayName : function () { alert(this.name); } }; friend.sayName(); //error
先創建了Person 的一個實例,然後又重寫了其原型對象。然後在調用friend.sayName()時發生了錯誤,因為friend 指向的原型中不包含以該名字命名的屬性。
重寫原型對象切斷了現有原型與任何之前已經存在的對象實例之間的聯系;它們引用的仍然是最初的原型。
5. 原生對象的原型
原型模式的重要性不僅體現在創建自定義類型方面,就連所有原生的引用類型,都是采用這種模式創建的。所有原生引用類型(Object、Array、String,等等)都在其構造函數的原型上定義了方法。例如,在Array.prototype 中可以找到sort()方法,而在String.prototype 中可以找到substring()方法
alert(typeof Array.prototype.sort); //"function" alert(typeof String.prototype.substring); //"function"
通過原生對象的原型,不僅可以取得所有默認方法的引用,而且也可以定義新方法。可以像修改自定義對象的原型一樣修改原生對象的原型,因此可以隨時添加方法。下面的代碼就給基本包裝類型String 添加了一個名為startsWith()的方法。
String.prototype.startsWith = function (text) { return this.indexOf(text) == 0; }; var msg = "Hello world!"; alert(msg.startsWith("Hello")); //true
這裏新定義的startsWith()方法會在傳入的文本位於一個字符串開始時返回true。既然方法被添加給了String.prototype,那麽當前環境中的所有字符串就都可以調用它。
6. 原型對象的問題
原型對象的缺點:首先,它省略了為構造函數傳遞初始化參數這一環節,結果所有實例在默認情況下都將取得相同的屬性值。雖然這會在某種程度上帶來一些不方便,但還不是原型的最大問題。原型模式的最大問題是由其共享的本性所導致的。
原型中所有屬性是被很多實例共享的,這種共享對於函數非常合適。對於那些包含基本值的屬性倒也說得過去,畢竟,通過在實例上添加一個同名屬性,可以隱藏原型中的對應屬性。然而,對於包含引用類型值的屬性來說,問題就比較突出了。
function Person(){ } Person.prototype = { constructor: Person, name : "Nicholas", age : 29, job : "Software Engineer", friends : ["Shelby", "Court"], sayName : function () { alert(this.name); } }; var person1 = new Person(); var person2 = new Person(); person1.friends.push("Van"); alert(person1.friends); //"Shelby,Court,Van" alert(person2.friends); //"Shelby,Court,Van" alert(person1.friends === person2.friends); //true
修改了person1.friends 引用的數組,向數組中添加了一個字符串。由於friends 數組存在於Person.prototype 而非person1 中,所以剛剛提到的修改也會通過person2.friends(與person1.friends 指向同一個數組)反映出來。
實例一般都是要有屬於自己的全部屬性的。而這個問題正是我們很少看到有人單獨使用原型模式的原因所在。
6.2.4 組合使用構造函數模式和原型模式
創建自定義類型的最常見方式,就是組合使用構造函數模式與原型模式。構造函數模式用於定義實例屬性,而原型模式用於定義方法和共享的屬性。結果,每個實例都會有自己的一份實例屬性的副本,但同時又共享著對方法的引用,最大限度地節省了內存。另外,這種混成模式還支持向構造函數傳遞參數;可謂是集兩種模式之長。
function Person(name, age, job){ this.name = name; this.age = age; this.job = job; this.friends = ["Shelby", "Court"]; } Person.prototype = { constructor : Person, sayName : function(){ alert(this.name); } } var person1 = new Person("Nicholas", 29, "Software Engineer"); var person2 = new Person("Greg", 27, "Doctor"); person1.friends.push("Van"); alert(person1.friends); //"Shelby,Count,Van" alert(person2.friends); //"Shelby,Count" alert(person1.friends === person2.friends); //false alert(person1.sayName === person2.sayName); //true
實例屬性都是在構造函數中定義的,而由所有實例共享的屬性constructor 和方法sayName()則是在原型中定義的。而修改了person1.friends(向其中添加一個新字符串),並不會影響到person2.friends,因為它們分別引用了不同的數組。
這種構造函數與原型混成的模式,是目前在ECMAScript 中使用最廣泛、認同度最高的一種創建自定義類型的方法。
6.2.5 動態原型模式
動態原型模式會把獨立的構造函數和原型封裝到構造函數裏,而通過在構造函數中初始化原型(僅在必要的情況下),又保持了同時使用構造函數和原型的優點。換句話說,可以通過檢查某個應該存在的方法是否有效,來決定是否需要初始化原型。
function Person(name, age, job){ //屬性 this.name = name; this.age = age; this.job = job; //方法 if (typeof this.sayName != "function"){ Person.prototype.sayName = function(){ alert(this.name); }; } } var friend = new Person("Nicholas", 29, "Software Engineer"); friend.sayName();
這裏對原型所做的修改,能夠立即在所有實例中得到反映。因此,這種方法確實可以說非常完美。其中,if 語句檢查的可以是初始化之後應該存在的任何屬性或方法——不必用一大堆if 語句檢查每個屬性和每個方法;只要檢查其中一個即可。對於采用這種模式創建的對象,還可以使用instanceof 操作符確定它的類型。
6.2.6 寄生構造函數模式
寄生(parasitic)構造函數模式,這種模式的基本思想是創建一個函數,該函數的作用僅僅是封裝創建對象的代碼,然後再返回新創建的對象;但從表面上看,這個函數又很像是典型的構造函數。
function Person(name, age, job){ var o = new Object(); o.name = name; o.age = age; o.job = job; o.sayName = function(){ alert(this.name); }; return o; } var friend = new Person("Nicholas", 29, "Software Engineer"); friend.sayName(); //"Nicholas"
Person 函數創建了一個新對象,並以相應的屬性和方法初始化該對象,然後又返回了這個對象。除了使用new 操作符並把使用的包裝函數叫做構造函數之外,這個模式跟工廠模式其實是一模一樣的。構造函數在不返回值的情況下,默認會返回新對象實例。而通過在構造函數的末尾添加一個return 語句,可以重寫調用構造函數時返回的值。
這個模式可以在特殊的情況下用來為對象創建構造函數。假設我們想創建一個具有額外方法的特殊數組。由於不能直接修改Array 構造函數,因此可以使用這個模式。
function SpecialArray(){ //創建數組 var values = new Array(); //添加值 values.push.apply(values, arguments); //添加方法 values.toPipedString = function(){ return this.join("|"); }; //返回數組 return values; } var colors = new SpecialArray("red", "blue", "green"); alert(colors.toPipedString()); //"red|blue|green"
關於寄生構造函數模式,有一點需要說明:首先,返回的對象與構造函數或者與構造函數的原型屬性之間沒有關系;也就是說,構造函數返回的對象與在構造函數外部創建的對象沒有什麽不同。為此,不能依賴instanceof 操作符來確定對象類型。由於存在上述問題,我們建議在可以使用其他模式的情況下,不要使用這種模式。
console.log(colors.constructor==SpecialArray);//false console.log(colors instanceof SpecialArray);//false console.log(SpecialArray.prototype.isPrototypeOf(colors));//false console.log(Object.getPrototypeOf(colors)==SpecialArray.prototype);//false
6.2.7 穩妥構造函數模式
道格拉斯·克羅克福德(Douglas Crockford)發明了JavaScript 中的穩妥對象(durable objects)這個概念。所謂穩妥對象,指的是沒有公共屬性,而且其方法也不引用this 的對象。穩妥對象最適合在一些安全的環境中(這些環境中會禁止使用this 和new),或者在防止數據被其他應用程序(如Mashup程序)改動時使用。
穩妥構造函數遵循與寄生構造函數類似的模式,但有兩點不同:一是新創建對象的實例方法不引用this;二是不使用new 操作符調用構造函數。
function PersonWT(name,age,job){ var o=new Object(); o.name=name; o.age=age; o.job=job; o.sayName=function(){ console.log(name); }; return o; } var f=PersonWT(‘lily‘,21,‘software‘); f.sayName();//lily
變量friend 中保存的是一個穩妥對象,而除了調用sayName()方法外,沒有別的方式可以訪問其數據成員。即使有其他代碼會給這個對象添加方法或數據成員,但也不可能有別的辦法訪問傳入到構造函數中的原始數據。穩妥構造函數模式提供的這種安全性,使得它非常適合在某些安全執行環境下使用。
第六章 面向對象的程序設計 (2 創建對象)