天天看點

第6章 面向對象的程式設計 (一)

 面向對象 (Object-oriented, OO) 的語言有一個标志,那就是它們都有類的概念,而通過類可以建立任意多個具有相同屬性和方法的對象。前面提到過,ECMAScript 中沒有類的概念,是以它的對象也與基于類的語言中的對象有所不同。

ECMA-262 把對象定義為:"無序屬性的集合,其屬性可以包含基本值、對象或者函數。"嚴格來講,這就相當于說對象是一組沒有特定順序的值。對象的每個屬性或方法都有一個名字,而每個名字都映射到一個值。正因為這樣(以及其他将要讨論的原因),我們可以把ECMAScript的對象想象成散清單:無非就是一組名值對,其中值可以是資料或函數。

每個對象都是基于一個引用類型建立的,這個引用類型可以是第5章讨論的原生類型,也可以是開發人員定義的類型。

6.1 建立對象

第5章曾經介紹過,建立自定義對象的最簡單的方式就是建立一個 Object 的執行個體,然後再為它添加屬性和方法,如下所示:

var person = new Object();

person.name = "Nicholas";

person.age = 29;

person.job = "Software Engineer";

person.sayName = function() {

alert(this.name);

};

person.sayName();

上面的例子建立了一個名為 person 的對象,并為它添加了三個屬性 (name、age和job) 和一個方法 (sayName())。其中,sayName() 方法用于顯示this.name (将被解析為 person.name) 的值。早期的 JavaScript 開發人員經常使用這個模式建立新對象。但這種方式有個明顯的缺點:使用同一個接口建立很多對象,會産生大量的重複代碼。為解決這個問題,人們開始使用工廠模式的一種變體。

6.1.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");

person1.sayName();             // "Nicholas"

person2.sayName();             // "Greg"

函數 createPerson() 能夠根據接受的參數來建構一個包含所有必要資訊的 Person 對象。可以無數次地調用這個函數,而每次它都會傳回一個包含三個屬性一個方法的對象。工廠模式雖然解決了建立多個相似對象的問題,但卻沒有解決對象識别的問題 (即怎樣知道一個對象的類型)。随着 JavaScript 的發展,又一個新模式出現了。

6.1.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");

person1.sayName();            // "Nicholas"

person2.sayName();            // "Greg"

在這個例子中,Person() 函數取代了 createPerson() 函數。我們注意到,Person() 中的代碼除了與 createPerson() 中相同的部分外,還存在以下不同之處:

  • 沒有顯式地建立對象;
  • 直接将屬性和方法賦給了 this 對象;
  • 沒有 return 語句。

此外,還應該注意到函數名 Person 使用的是大寫字母 P 。 按照慣例,構造函數始終都應該以一個大寫字母開頭,而非構造函數則應該以一個小寫字母開頭。這個做法借鑒自其他OO語言,主要是為了差別于 ECMAScript 中的其他函數; 因為構造函數本身也是函數,隻不過可以用來建立對象而已。 要建立 Person 的新執行個體,必須使用 new 操作符。 以這種方式調用構造函數實際上會經曆以下4個步驟: (1) 建立一個新對象; (2) 将構造函數的作用域賦給新對象 (是以 this 就指向了這個新對象);------------------------ 構造函數原來的 this 指向 window (3) 執行構造函數中的代碼 (為這個新對象添加屬性); (4) 傳回新對象。 在前面例子的最後,person1 和 person2 分别儲存着 Person 的一個不同的執行個體。這兩個對象都有一個 constructor (構造函數) 屬性,該屬性指向 Person,如下所示: alert(person1.constructor == Person);           // true alert(person2.constructor == Person);           // true   對象的 constructor 屬性最初是用來辨別對象類型的。但是,提到檢測對象類型,還是 instanceof 操作符要更可靠一些。 我們在這個例子中建立的所有對象既是 Object 的執行個體,同時也是 Person 的執行個體,這一點通過 instanceof 操作符可以得到驗證: alert(person1 instanceof Object);         // true alert(person1 instanceof Person);        // true alert(person2 instanceof Object);         // true alert(person2 instanceof Person);       // true 建立自定義的構造函數意味着将來可以将它的執行個體辨別為一種特定的類型;而這正是構造函數模式勝過工廠模式的地方。在這個例子中,person1 和 person2 之是以同時是 Object 的執行個體,是因為所有對象均繼承自 Object 。 以這種方式定義的構造函數是定義在 Global 對象 (在浏覽器中是 window 對象) 中的。是以除非另有說明, instanceof 操作符和 constructor 屬性始終會假設是在全局作用域中查詢構造函數。 1.将構造函數當作函數 構造函數與其他函數的唯一差別,就在于調用它們的方式不同。 不過, 構造函數畢竟也是函數 ,不存在定義構造函數的特殊文法。 任何函數,隻要通過 new 操作符來調用,那它就可以作為構造函數;而任何函數,如果不通過 new 操作符來調用,那它跟普通函數也不會有什麼兩樣。例如,前面例子中定義的 Person() 函數可以通過下列任何一種方式來調用: // 當作構造函數使用 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"); o.sayName();              // "Kristen" 這個例子中的前兩行代碼展示了構造函數的典型用法,即使用 new 操作符來建立一個新對象。接下來的兩行代碼展示了不使用 new 操作符調用 Person() 會出現什麼結果:屬性和方法都被添加給 window 對象了。有讀者可能還記得, 當在全局作用域中調用一個函數時,this 對象總是指向 Global 對象 (在浏覽器中就是 window 對象)。是以,在調用完函數之後,可以通過 window 對象來調用 sayName() 方法,并且還傳回了 "Greg"。最後,也可以使用 call() (或者 apply()) 在某個特殊對象的作用域中調用 Person() 函數。這裡是在對象 o 的作用域中調用的,是以調用後 o 就擁有了所有屬性和 sayName() 方法。 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)");      // 與聲明函數在邏輯上是等價的       } 從這個角度上來看構造函數,更容易明白每個 Person 執行個體都包含一個不同的 Function 執行個體 (以顯示 name 屬性) 的本質。如前所述,這兩個函數是不相等的,下面的代碼可以證明這一點: alert(person1.sayName == person2.sayName);           // false 然而,建立兩個完成同樣任務的 Function 執行個體的确沒有必要;況且有 this 對象在,根本不用在執行代碼前就把函數綁定到特定對象上面。是以,大可像下面這樣,通過把函數定義轉移到構造函數外部來解決這個問題: 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");

person1.sayName();              // "Nicholas" person2.sayName();              // "Greg"

alert(person1 instanceof Object);            // true

alert(person1 instanceof Person);           // true

alert(person2 instanceof Object);             // true

alert(person2 instanceof Person);           // true

alert(person1.constructor == Person);   // true

alert(person2.constructor == Person);    // true

alert(person1.sayName == person2.sayName);    // true

在這個例子中,我們把 sayName() 函數的定義轉移到了構造函數外部。而在構造函數内部,我們将 sayName 屬性設定成等于全局的 sayName 函數。這樣一來,由于 sayName 包含的是一個指向函數的指針,是以 person1 和 person2 對象就共享了在全局作用域中定義的同一個 sayName() 函數。這樣做确實解決了兩個函數做同一件事的問題,可是新問題又來了:在全局作用域中定義的函數實際上隻能被某個對象調用,這讓全局作用域有點名不副實。而更讓人無法接受的是:如果對象需要定義很多方法,那麼就要定義很多個全局函數,于是我們這個自定義的引用類型就絲毫沒有封裝性可言了。好在,這些問題可以通過使用原型模式來解決。

6.1.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

在此,我們将 sayName() 方法和所有屬性直接添加到了 Person 的 prototype 屬性中,構造函數變成了空函數。即使如此,也仍然可以通過調用構造函數來建立一個新對象,而且新對象還會具有相同的屬性和方法。但與構造函數模式不同的是,新對象的這些屬性和方法是由所有執行個體共享的。換句話說,person1 和 person2 通路的都是同一組屬性和同一個 sayName() 函數。要了解原型模式的工作原理,必須先了解 ECMAScript 中原型的性質。

1.了解原型

無論什麼時候,隻要建立了一個新函數,就會根據一組特定的規則為該函數建立一個 prototype 屬性。在預設情況下,所有 prototype 屬性都會自動獲得一個 constructor (構造函數) 屬性,這個屬性包含一個指向 prototype 屬性所在函數的指針。就拿前面的例子來說,Person.prototype.constructor 指向 Person 。而通過這個構造函數,我們還可繼續為原型添加其他屬性和方法。

建立了自定義的構造函數之後,其原型屬性預設隻會取得 constructor 屬性;至于其他方法,則都是從 Object 繼承而來的。當調用構造函數建立一個新執行個體後,該執行個體的内部将包含一個指針 (内部屬性),指向構造函數的原型屬性。在很多實作中,這個内部屬性的名字是 __proto__,而且通過腳本可以通路到 (在 Firefox、Safari、Chrome 和 Flash 的 ActionScript 中,都可以通過腳本通路 __proto__);而在其他實作中,這個屬性對腳本則是完全不可見的。不過,要明确的真正重要的一點,就是這個連接配接存在于執行個體與構造函數的原型屬性之間,而不是存在于執行個體與構造函數之間。

以前面使用 Person 構造函數和 Person.prototype 建立執行個體的代碼為例,下圖展示了各個對象之間的關系。

第6章 面向對象的程式設計 (一)

上圖展示了 Person 構造函數、Person 的原型屬性以及 Person 現有的兩個執行個體之間的關系。在此,Person.prototype 指向了原型對象,而 Person.prototype.constructor 又指回了 Person 。原型對象中除了包含 constructor 屬性之外,還包括後來添加的其他屬性。Person 的每個執行個體 -- person1 和 person2 都包含一個内部屬性,該屬性僅僅指向了 Person.prototype ;換句話說,它們與構造函數沒有直接的關系。此外,要格外注意的是,雖然這兩個執行個體都不包含屬性和方法,但我們卻可以調用 person1.sayName()。這是通過查找對象屬性的過程來實作的。

雖然在某些實作中無法通路到内部的 __proto__ 屬性,但在所有實作中都可以通過 isPrototypeOf() 方法來确定對象之間是否存在這種關系。從本質上講,如果對象的 __proto__ 指向調用 isPrototypeOf() 方法的對象 (Person.prototype) ,那麼這個方法就傳回 true ,如下所示:

alert(Person.prototype.isPrototypeOf(person1));            // true

alert(Person.prototype.isPrototypeOf(person2));            // true

這裡,我們用原型對象的 isPrototypeOf() 方法測試了 person1 和 person2 。因為它們内部都有一個指向 Person.prototype 的指針,是以都傳回了 true 。

每當代碼讀取某個對象的某個屬性時,都會執行一次搜尋,目标是具有給定名字的屬性。搜尋首先從對象執行個體本身開始。如果在執行個體中找到了具有給定名字的屬性,則傳回該屬性的值;如果沒有找到,則繼續搜尋指針指向的原型對象,在原型對象中查找具有給定名字的屬性。如果在原型對象中找到了這個屬性,則傳回該屬性的值。也就是說,在我們調用 person1.sayName() 的時候,會先後執行兩次搜尋。首先,解析器會問:“執行個體 person1 有 sayName 屬性嗎?” 答:“沒有”。然後,它繼續搜尋,再問:“person1 的原型有 sayName 屬性嗎?” 答:“有。” 于是,它就讀取那個儲存在原型對象中的函數。當我們調用 person2.sayName()時,将會重制相同的搜尋過程,得到相同的結果。而這正是多個對象執行個體共享原型所儲存的屬性和方法的基本原理。

前面提到過,原型最初隻包含 constructor 屬性,而該屬性也是共享的,是以可以通過對象執行個體通路。

雖然可以通過對象執行個體通路儲存在原型中的值,但卻不能通過對象執行個體重寫原型中的值。如果我們在執行個體中添加了一個屬性,而該屬性與執行個體原型中的一個屬性同名,那我們就在執行個體中建立該屬性,該屬性将會屏蔽原型中的那個屬性。來看下面的例子:

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"   -- 來自原型

在這個例子中,person1 的 name 被一個新值給屏蔽了。但無論通路 person1.name 還是通路 person2.name 都能夠正常地傳回值,即分别是 "Greg" (來自對象執行個體) 和 "Nicholas" (來自原型)。當在 alert() 中通路 person1.name 時,需要讀取它的值,是以就會在這個執行個體上搜尋一個名為 name 的屬性。這個屬性确實存在,于是就傳回它的值而不必再搜尋原型了。當以同樣的方式通路 person2.name 時,并沒有在執行個體上發現該屬性,是以就會繼續搜尋原型,結果在那裡找到了 name 屬性。

當為對象執行個體添加一個屬性時,這個屬性就會屏蔽原型對象中儲存的同名屬性;換句話說,添加這個屬性隻會阻止我們通路原型中的那個屬性,但不會修改那個屬性。即使将這個屬性設定為 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"  -- 來自原型

在這個修改後的例子中,我們使用 delete 操作符删除了 person1.name ,之前它儲存的 "Greg" 值屏蔽了同名的原型屬性。把它删除以後,就恢複了對原型中 name 屬性的連接配接。是以,接下來再調用 person1.name 時,傳回的就是原型中 name 屬性的值了。

使用 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

通過使用 hasOwnProperty() 方法,什麼時候通路的是執行個體屬性,什麼時候通路的是原型屬性就一清二楚了。調用 person1.hasOwnProperty("name") 時,隻有當 person1 重寫 name 屬性後才會傳回 true,因為隻有這時候 name 才是一個執行個體屬性,而非原型屬性。下圖展示了上面例子在不同情況下的實作與原型的關系(為了簡單起見,圖中省略了與 Person 構造函數的關系)。

第6章 面向對象的程式設計 (一)

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);

}

由于 in 操作符隻要通過對象能夠通路到屬性就傳回 true ,hasOwnProperty() 隻在屬性存在于執行個體中時才傳回 true ,是以隻要 in 操作符傳回 true 而 hasOwnProperty() 傳回 false ,就可以确定屬性是原型中的屬性。下面來看一看上面定義的函數 hasPrototypeProperty() 的用法:

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

在這裡,name 屬性先是存在于原型中,是以 hasPrototypeProperty() 傳回 true 。當在執行個體中重寫 name 屬性後,該屬性就存在于執行個體中了,是以 hasPrototypeProperty() 傳回 false 。

在使用 for-in 循環時,傳回的是所有能夠通過對象通路的、可枚舉的 (enumerated) 屬性,其中既包括存在于執行個體中的屬性,也包括存在于原型中的屬性。屏蔽了原型中不可枚舉屬性 (即設定了[[DontEnum]]标記的屬性) 的執行個體屬性也會在 for-in 循環中傳回,因為根據規定,所有開發人員定義的屬性都是可枚舉的 -- 隻有IE例外。

IE 的 JScript 實作中存在一個 bug ,即屏蔽不可枚舉屬性的執行個體屬性不會出現在 for-in 循環中。

例如:

var o = {

toString : function(){

return "My Object";

}

}

for (var prop in o){

if(prop == "toString"){

alert("Found toString");                 // 在 IE 中不會顯示

}

}

當以上代碼運作時,應該會顯示一個警告框,表明找到了 toString() 方法。這裡的對象 o 定義了一個名為 toString() 的方法,該方法屏蔽了原型中 (不可枚舉) 的 toString() 方法。在 IE 中,由于其實作認為原型的 toString() 方法被打上了 [[DontEnum]] 标記就應該跳過該屬性,結果我們就不會看到警告框。該 bug 會影響預設不可枚舉的所有屬性和方法,包括: hasOwnProperty() 、propertyIsEnumerable() 、toLocaleString() 、 toString() 和 valueOf() 。有的浏覽器也為 constructor 和 prototype 屬性打上了 [[DontEnum]]标記,但這并不是所有浏覽器共同的做法。

3.更簡單的原型文法

讀者大概注意到了,前面例子中每添加一個屬性和方法就要敲一遍 Person.prototype 。為減少不必要的輸入,也為了從視覺上更好地封裝原型的功能,更常見的做法是用一個包含所有屬性和方法的對象字面量來重寫整個原型對象,如下面的例子所示:

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 person = new Person();

alert(person instanceof Object);               // true

alert(person instanceof Person);              // true

alert(person.constructor == Person);      // false

alert(person.constructor == Object);        // true

在此,用 instanceof 操作符測試 Object 和 Person 仍然傳回 true,但 constructor 屬性則等于 Object 而不等于 Person 了。如果 constructor 的值真的很重要,可以像下面這樣特意将它設定回适當的值:

function Person(){

}

Person.prototype = {

constructor : Person,

name : "Nicholas",

age : 29,

job : "Software Engineer",

sayName : function(){

alert(this.name);

}

};

以上代碼特意包含了一個 constructor 屬性,并将它的值設定為 Person,進而確定了通過該屬性能夠通路到适當的值。

var person = new Person();

alert(person instanceof Object);              // true

alert(person instanceof Person);            // true

alert(person.constructor == Person);     // true

alert(person.constructor == Object);       // false

4.原型的動态性

由于在原型中查找值的過程是一次搜尋,是以我們對原型對象所做的任何修改都能夠立即從執行個體上反映出來--即使是先建立了執行個體後修改原型也照樣如此。請看下面的例子:

var person = new Person();

Person.prototype.sayHi = function(){

alert("hi");

};

person.sayHi();              // "hi"  (沒有問題!)

以上代碼先建立了 Person 的一個執行個體,并将其儲存在 person 中。然後,下一條語句在 Person.prototype 中添加了一個方法 sayHi() 。即使 person 執行個體是在添加新方法之前建立的,但它仍然可以通路這個新方法。其原因可以歸結為執行個體與原型之間的松散連接配接關系。當我們調用 person.sayHi() 時,首先會在執行個體中搜尋名為 sayHi 的屬性,在沒找到的情況下,會繼續搜尋原型。因為執行個體與原型之間的連接配接隻不過是一個指針,而非一個副本,是以就可以在原型中找到新的 sayHi 屬性并傳回儲存在那裡的函數。

盡管可以随時為原型添加屬性和方法,并且修改能夠立即在所有對象執行個體中反映出來,但如果是重寫整個原型對象,那麼情況就不一樣了。我們知道,調用構造函數時會為執行個體添加一個指向最初原型的 __proto__ 指針,而把原型修改為另外一個對象就等于切斷了構造函數與最初原型之間的聯系。請記住:執行個體中的指針僅指向原型,而不指向構造函數。看下面的例子:

function Person(){

}

var person = new Person();

Person.prototype = {

constructor: Person,

name: "Nicholas",

age: 29,

job: "Software Engineer",

sayName: function(){

alert(this.name);

}

};

person.sayName();        // error

在這個例子中,我們先建立了 Person 的一個執行個體,然後又重寫了其原型對象。然後在調用 person.sayName() 時發生了錯誤,因為 person 指向的原型中不包含該名字命名的屬性。下圖展示了這個過程的内幕。

第6章 面向對象的程式設計 (一)

從圖可以看出,重寫原型對象切斷了現有原型與任何之前已經存在的對象執行個體之間的聯系;它們引用的仍然是最初的原型。

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 ,那麼目前環境中的所有字元串就都可以調用它。由于 msg 是字元串,而且背景會調用 String 基本包裝函數建立這個字元串,是以通過 msg 就可以調用 startWith() 方法。

盡管可以這樣做,但我們不推薦在産品化的程式中修改原生對象的原型。如果因某個實作中缺少某個方法,就在原生對象的原型中添加這個方法,那麼當在另一個支援該方法的實作中運作代碼時,就可能會導緻命名沖突。而且,這樣做也可能會意外地重寫原生方法。

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

在此,Person.prototype 對象有一個名為 friends 的屬性,該屬性包含一個字元串數組。然後,建立了 Person 的兩個執行個體。接着,修改了 person1.friends 引用的數組,向數組中添加了一個字元串。由于 friends 數組存在于 Person.prototype 而非 person1 中,是以剛剛提到的修改也會通過 person2.friends (與 person1.friends 指向同一個數組) 反映出來。假如我們的初衷就是像這樣在所有執行個體中共享一個數組,那麼對這個結果我沒有話可說。可是,執行個體一般都是要有屬于自己的全部屬性的。而這個問題正是我們很少看到有人單獨使用原型模式的原因所在。

6.1.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", 29, "Doctor");

person1.friends.push("Van");

alert(person1.friends);                   // "Shelby, Court, Van"

alert(person2.friends);                   // "Shelby, Court"

alert(person1.friends === person2.friends);          // false

alert(person2.sayName === person2.sayName);   // true

在這個例子中,執行個體屬性都是在構造函數中定義的,而由所有執行個體共享的屬性 constructor 和方法 sayName() 則是在原型中定義的。而修改了 person1.friends (向其中添加了一個新字元串),并不會影響到 person2.friends,因為它們分别引用了不同的數組。

這種構造函數與原型混成的模式,是目前在 ECMAScript 中使用最廣泛、認同度最高的一種建立自定義類型的方法。可以說,這是用來定義引用類型的一種預設模式。

6.1.5 動态原型模式

有其他OO語言經驗的開發人員在看到獨立的構造函數和原型時,很可能會感到非常困惑。動态原型模式正是緻力于解決這個問題的一個方案,它把所有資訊都封裝在了構造函數中,而通過在構造函數中初始化原型 (僅在必要的情況下),又保持了同時使用構造函數和原型的優點。換句話說,可以通過檢查某個應該存在的方法是否有效,來決定是否需要初始化原型。來看一個例子:

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 person = new Person("Nicholas", 29, "Software Engineer");

person.sayName();

注意構造函數代碼中加背景的部分。這裡隻在 sayName() 方法不存在的情況下,才會将它添加到原型中。這段代碼隻會在初次調用構造函數時才會執行。此後,原型已經完成初始化,不需要再做什麼修改了。不過要記住,這裡對原型所做的修改,能夠立即在所有執行個體中得到反映。是以,這種方法确實可以說非常完美。其中,if 語句檢查的可以是初始化之後應該存在的任何屬性或方法 -- 不必用一大堆 if 語句檢查每個屬性和每個方法;隻要檢查其中一個即可。對于采用這種模式建立的對象,還可以使用 instanceof 操作符确定它的類型。

使用動态原型模式時,不能使用對象字面量重寫原型。前面已經解釋過了,如果在已經建立了執行個體的情況下重寫原型,那麼就會切斷現有執行個體與新原型之間的聯系。

6.1.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 person = new Person("Nicholas", 29, "Software Engineer");

person.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"

在這個例子中,我們建立了一個名叫 SpecialArray 的構造函數。在這個函數内部,首先建立了一個數組,然後 push() 方法 (用構造函數接收到的所有參數) 初始化了數組的值。随後,又給數組執行個體添加了一個 toPipedString() 方法,該方法傳回以豎線分割的數組值。最後,将數組以函數值的形式傳回。接着,我們調用了 SpecialArray 構造函數,向其中傳入了用于初始化數組的值,此後又調用了 toPipedString() 方法。

關于寄生構造函數模式,有一點需要說明:首先,傳回的對象與構造函數或者與構造函數的原型屬性之間沒有關系;也就是說,構造函數傳回的對象與在構造函數外部建立的對象沒有什麼不同。為此,不能依賴 instanceof 操作符來确定對象類型。由于存在上述問題,我們建議在可以使用其他模式的情況下,不要使用這種模式。

6.1.7 穩妥構造函數模式

道格拉斯.克羅克福德 (Douglas Crockford) 發明了 JavaScript 中的穩妥對象 (durable objects) 這個概念。所謂穩妥對象,指的是沒有公共屬性,而且其方法也不引用 this 對象。穩妥對象最适合在一些安全的環境中 (這些環境中會禁止使用 this 和 new),或者在防止資料被其他應用程式 (如 Mashup 程式) 改動時使用。穩妥構造函數遵循與寄生構造函數類似的模式,但有兩點不同:一是新建立對象的執行個體方法不引用 this ;二是不使用 new 操作符調用構造函數。按照穩妥構造函數的要求,可以将前面的 Person 構造函數重寫如下:

function Person(name, age, job) {

// 建立要傳回的對象

var o = new Object();

// 可以在這裡定義私有變量和函數

// 添加方法

o.sayName = function(){

alert(name);

};

// 傳回對象

return o;

}

注意,在以這種模式建立的對象中,除了使用 sayName() 方法之外,沒有其他辦法通路 name 的值。可以像下面使用穩妥的 Person 構造函數:

var person = Person("Nicholas", 29, "Software Engineer");

person.sayName();                   // "Nicholas"

這樣,變量 person 中儲存的是一個穩妥對象,而除了調用 sayName() 方法外,沒有别的方式可以通路其資料成員。即使有其他代碼會給這個對象添加方法或資料成員,但也不可能有别的方法通路傳入到構造函數中的原始資料。穩妥構造函數模式提供的這種安全性,使得它非常适合在某些安全執行環境 -- 例如, ADsafe (www.adsafe.org) 和 Caja (http://code.google.com/p/google-caja) 提供的環境 -- 下使用。

與寄生構造函數模式類似,使用穩妥構造函數模式建立的對象與構造函數之間也沒有什麼關系,是以 instanceof 操作符對這種對象也沒有意義。

繼續閱讀