1. 程式人生 > >JavaScript基礎——面向物件的程式設計(一)建立物件的幾種方式總結

JavaScript基礎——面向物件的程式設計(一)建立物件的幾種方式總結

簡介

面向物件(Object-Oriented, OO)的語言有一個標誌,那就是它們都有類的概念,而通過類可以建立任意多個具有相同屬性和方法的物件。前面提到過,ECMAScript中沒有類的概念,因此它的物件也與基於類的語言中的物件有所不同。

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

每個物件都是基於一個引用型別建立的,這個引用型別可以是前面討論的原生型別,也可以是開發人員定義的型別。

理解物件

前面介紹過,建立自定義物件的最簡單方式就是建立一個Object的例項,然後為它新增屬性和方法,如:

    var person = new Object();
     person.name = "Nicholas";
     person.age = 29;
     person.toString = function(){
         return "[name=" + this.name + "; age=" + this.age + "]";
     };
     alert(person);//[name=Nicholas; age=29]

上面的例子中建立了一個名為person的物件,併為它添加了兩個屬性(name和age)和一個方法(toString())。其中的toString()方法,在alert()中預設呼叫,並顯示物件的資訊。早期的JavaScript開發人員經常使用這種模式建立新物件。幾年後,物件字面量成為建立這種物件的首選模式,上面的例子使用字面量語法可以寫成: 

    var person = {
             name:"Nicholas",
             age:29,
             toString:function(){
                 return "[name=" + this.name + "; age=" + this.age + "]";
             }
     };
     alert(person);//[name=Nicholas; age=29]

這個例子中的person物件與前面例子中的person物件是一樣的,都有相同的屬性和方法。這些屬性在建立時都帶有一些特徵值(characteristic),JavaScript通過這些特徵值來定義它們的行為。

屬性型別

ECMA-262第5版在定義只有內部才用的特性時,描述了屬性的各種特徵。ECMA-262定義這些特性是為了實現JavaScript引擎用的,因此在JavaScript中不能直接訪問它們。為了表示特性是內部值,該規範把它們放在了兩對方括號中,例如[[Enumerable]]。

資料屬性

(1)      [[Configurable]]:表示能否通過delete刪除屬性從而重新定義屬性,能否修改屬性的特性,或者能否把屬性修改為訪問器屬性。像前面例子中那樣直接在物件上定義的屬性,它們的這個特性預設值為true。

(2)      [[Enumerable]]:表示能否通過for-in迴圈返回屬性。像前面例子中那樣直接在物件上定義的屬性,它們的這個特性預設值為true。

(3)      [[Writable]]:表示能否修改屬性的值。像前面例子中那樣直接在物件上定義的屬性,它們的這個特性預設值為true。

(4)      [[Value]]:包含這個屬性的資料值。讀取屬性值的時候,從這個位置讀;寫入屬性值的時候,把新值儲存在這個位置。這個特性的預設值為undefined。

對於像前面例子中那樣直接在物件上定義的屬性,它們的[[Configurable]]、[[Enumerable]]和[[Writable]]特性都被設定為true,而[[Value]]特性被設定為指定的值。

要修改屬性預設的特性,必須使用ECMAScript5的Object.defineProperty()方法。這個方法接收三個引數:屬性所在的物件、屬性的名字和一個描述符物件。其中,描述符物件的屬性必須是:configurable、enumerable、writable和value。設定其中的一或多個值,可以修改對應的特性值,如:

     var person = {
             name:"Nicholas",
             age:29,
             toString:function(){
                 return "[name=" + this.name + "; age=" + this.age + "]";
             }
     };
     Object.defineProperty(person , "name",{
         writable:false
     });
     person.name="goskalrie";//修改無效
     alert(person);//[name=Nicholas; age=29]
這個例子將上面例子中person物件的name屬性的特性設定為只讀,這個屬性的值是不可修改的,嘗試為name屬性指定新值,在非嚴格模式下,賦值操作將被忽略;在嚴格模式下,賦值操作將會導致丟擲錯誤。

類似的規則也適用於不可配置的屬性,如:

     var person = {
             name:"Nicholas",
             age:29,
             toString:function(){
                 return "[name=" + this.name + "; age=" + this.age + "]";
             }
     };
     Object.defineProperty(person , "name",{
         configurable:false
     });
     delete person.name;//刪除無效
     alert(person);//[name=Nicholas; age=29]
把configurable設定為false,表示不能從物件中刪除屬性。如果對這個屬性呼叫delete,則在非嚴格模式下什麼也不會發生,而在嚴格模式下會導致錯誤。而且,一旦把屬性定義為不可配置的,就不能再把它變回可配置了。此時,再呼叫Object.defineProperty()方法修改除writable之外的特性,都會導致錯誤。

也就是說,可以多次呼叫Object.defineProperty()方法修改同一個屬性,但在把configurable特性設定為false之後就會有限制了。

在呼叫Object.defineProperty()方法時,如果不指定configurable、enumerable和writable特性的預設值都是false。多數情況下,可能都沒必要利用Object.defineProperty()方法提供的這些高階功能。不過,理解這些概念對理解JavaScript物件卻非常有用。

訪問器屬性

訪問器屬性不包含資料值;它們包含一對getter和setter函式(不過,這兩個函式都不是必需的)。在讀取訪問器屬性時,會呼叫getter函式,這個函式負責返回有效的值;在寫入訪問器屬性時,會呼叫setter函式並傳入新值。這個函式負責決定如何處理資料。訪問器屬性有如下4個特性。

(1)       [[Configurable]]:表示能否通過delete刪除屬性從而重新定義屬性,能否修改屬性的特性,或者能否把屬性修改為資料屬性。對於直接在物件上定義的屬性,它們的這個特性預設值為true。

(2)      [[Enumerable]]:表示能否通過for-in迴圈返回屬性。對於直接在物件上定義的屬性,它們的這個特性預設值為true。

(3)      [[Get]]:在讀取屬性時呼叫的函式。預設值為undefined。

(4)      [[Set]]:在寫入屬性時呼叫的函式。預設值為undefined。

訪問器屬性不能直接定義,必須使用Object.defineProperty()來定義,如:

     var book = {
             _year:2004,
             edition:1
     };
     Object.defineProperty(book , "year" , {
         get:function(){
             return this._year;
         },
         set:function(newValue){
             if(newValue > 2004){
                 this._year = newValue;
                 this.edition += newValue - 2004;
             }
         }
     });
     book.year = 2005;
     alert(book.edition);//2
以上程式碼建立了一個book物件,並給它定義兩個預設的屬性:_year和edition。_year前面的下劃線是一種常用的記號,用於表示只能通過物件方法訪問的屬性。而訪問器屬性year則包含一個getter函式和一個setter函式。getter函式返回_year的值,setter函式通過計算來確定正確的版本。因此把year屬性修改為2005會導致_year變成2005,而edition變為2。這是使用訪問器屬性的常見方式,即設定一個屬性的值會導致其它屬性發生變化。

不一定非要同時指定getter和setter。只指定getter意味著屬性是不能寫,嘗試寫入屬性會被忽略。在嚴格模式下,嘗試寫入只指定了getter函式的屬性會丟擲錯誤。類似地,沒有指定setter函式的屬性也不能讀,否則在非嚴格模式下會返回undefined,而在嚴格模式下會丟擲錯誤。

定義多個屬性

由於為物件定義多個屬性的可能性很大,ECMAScript5又定義了一個Object.defineProperties()方法。利用這個方法可以通過描述一次定義多個屬性。這個方法接收兩個物件引數:第一個物件是要新增和修改其屬性的物件,第二個物件的屬性與第一個物件中要新增或修改的屬性一一對應,如:

     var book = {};
     Object.defineProperties(book , {
         _year:{
            value:2004
         },
         edition:{
             value:1
         },
         year:{
             get:function(){
                 return this._year;
             },
             set:function(newValue){
                 if(newValue > 2004){
                     this._year = newValue;
                     this.edition += newValue - 2004;
                 }
             }
         }
     });
以上程式碼在book對外上定義了兩個資料屬性(_year和edition)和一個訪問器屬性(year)。最終的物件與上面定義的物件同。唯一的區別是這裡屬性都是在同意時間建立的。

讀取屬性的特性

使用ECMAScript5的Object.getOwnPropertyDescriptor()方法,可以取得給定屬性的描述符。這個方法接收兩個引數:屬性所在的物件和要讀取其描述符的屬性名稱。返回值是一個物件,如果是訪問器屬性,這個物件的屬性有configurable、enumerable、get和set;如果是資料屬性,這個對像的屬性有configurable、enumerable、writable和value。如:

     var book = {};
     Object.defineProperties(book , {
         _year:{
            value:2004
         },
         edition:{
             value:1
         },
         year:{
             get:function(){
                 return this._year;
             },
             set:function(newValue){
                 if(newValue > 2004){
                     this._year = newValue;
                     this.edition += newValue - 2004;
                 }
             }
         }
     });
     var descriptor =Object.getOwnPropertyDescriptor(book , "_year");
     alert(descriptor.value);//2004
     alert(descriptor.configurable);//false
     alert(typeof descriptor.get);//"undefined"
     var descriptor = Object.getOwnPropertyDescriptor(book, "year");
     alert(descriptor.value);//undefined
     alert(descriptor. enumerable);//false
     alert(typeof descriptor.get);//"function"
對於資料屬性_year,value等於最初的值,configurable是false,而get等於undefined。對於訪問器屬性_year,value等於undefined,enumerable是false,而get是一個指向getter函式的指標。

在JavaScript中,可以針對任何物件——包括DOM和BOM物件,使用Object.getOwnPropertyDescriptor()方法。支援這個方法的瀏覽器有IE9+、Firefox4+、Safari5+、Opera12+和Chrome。

建立物件

雖然Object建構函式或物件字面量都可以用來建立單個物件,但這些方式有個明顯的缺點:使用同一個介面建立很多物件,會產生大量的重複程式碼。為解決這個問題,人們開始使用工廠模式的一種變體。

工廠模式

工廠模式是軟體工程領域一種廣為人知的設計模式,這種模式抽象了建立具體物件的過程。考慮到在ECMAScript中無法建立類,開發人員就發明了一種函式,用函式來封裝以特定介面建立物件的細節,如:

     function createPerson(name , age){
         var obj = new Object();
         obj.name = name;
         obj.age = age;
         obj.sayName = function(){
             alert(this.name);
         };
         return obj;
     }
     var person1 = createPerson("Nicholas" , 29);
     var person2 = createPerson("Greg" , 21);
函式createPerson()能夠根據接收的引數來構建一個包含所有必要資訊的Person物件。可以無數次地呼叫這個函式,而每次它都會返回一個包含兩個個屬性一個方法的物件。工廠模式雖然解決了建立多個相似物件的問題,但卻沒有解決物件識別的問題(即怎樣知道一個物件的型別)。隨著JavaScript的發展,又一個新模式出現了。

建構函式模式

前面介紹過,ECMAScript中的建構函式可用來建立特定型別的物件。像Object和Array這樣的原生建構函式,在執行時會自動出現在執行環境中。此外,也可以建立自定義的建構函式,從而定義自定義物件型別的屬性和方法。將前面的例子改寫如下:

     function Person(name , age){
         this.name = name;
         this.age = age;
         this.sayName = function(){
             alert(this.name);
         };
     }
     var person1 = new Person("Nicholas" , 29);
     var person2 = new Person("Greg" , 21);
在這個例子中,Person()函式取代了createPerson()函式。我們注意到,Person()中的程式碼除了與createPerson()中相同的部分除外,還存在以下不同之處:

I.沒有顯式地建立物件;

II.直接將屬性和方法賦給了this物件;

III.沒有return語句。

此外,還應該注意到函式名Person使用的是大寫字母開頭。按照慣例,建構函式始終都應該以一個大寫字母開頭,而非建構函式則應該以一個小寫字母開頭。這個做法借鑑自其他OO語言,主要是為了區別於ECMAScript中的其他函式;因為建構函式本身也是函式,只不過可以用來建立物件而已。

要建立Person的新例項,必須使用new操作符。以這種方式呼叫構造桉樹實際上會經歷以下4個步驟:

(1)      建立一個新物件;

(2)      將建構函式的作用域給新物件(因此this就指向了這個新物件);

(3)      執行建構函式中的程式碼(為這個新物件新增屬性);

(4)      返回新物件。

在前面例子的最後,person1和person2分別儲存著Person的一個不同的例項。這兩個物件都有一個constructor(建構函式)屬性,該屬性指向Person,如:

     function Person(name , age){
         this.name = name;
         this.age = age;
         this.sayName = function(){
             alert(this.name);
         };
     }
     var person1 = new Person("Nicholas" , 29);
     var person2 = new Person("Greg" , 21);
     alert(person1.constructor == Person);//true
     alert(person2.constructor == Person);//true
     alert(person1 instanceof Object);//true
     alert(person1 instanceof Person);//true
     alert(person2 instanceof Object);//true
     alert(person2 instanceof Person);//true
物件的constructor屬性最初是用來標識物件型別的。但是,提到檢測物件型別,還是instanceof操作符更可靠一些。如上例,建立的所有物件既是Object的例項,同時也是Person的例項。

建立自定義的建構函式意味著將來可以將它的例項標識為一種特定的型別;而這正式建構函式模式勝過工廠模式的地方。在這個例子中,person1和person2之所以同時是Object的例項,是因為所有物件均繼承自Object。

將建構函式當作函式

建構函式與其他函式的唯一區別,就在於呼叫它們的方式不同。不過,建構函式畢竟也是函式,不存在定義建構函式的特殊語法。任何函式,只要通過new操作符來呼叫,那它就可以作為建構函式;而任何函式,如果不通過new操作符來呼叫,那它跟普通函式也不會有什麼兩樣。例如,前面例子中定義的Person()函式可以通過下列任何一種方式來呼叫:

     //當作建構函式使用
     var person = new Person("Nicholas" , 29);
     person.sayName();//"Nicholas"
     //作為普通函式呼叫
     Person("Greg" , 21);//新增到window
     window.sayName();//"Greg"
     var o = new Object();
     Person.call(o , "goser" , 24);
     o.sayName();//"goser"
這個例子中的前兩行程式碼展示了建構函式的經典用法,即使用new操作符來建立一個新物件。接下來的兩行程式碼展示了不使用new操作符呼叫Person()會出現什麼結果:屬性和方法都被新增給window物件。

建構函式的問題

建構函式雖然好用,但也並非沒有缺點。使用建構函式的主要問題,就是每個方法都要在每個例項上重新建立一遍。在前面的例子中,person1和person2都有一個名為sayName()的方法,但那兩個方法不是同一個Function的例項。不要忘了——ECMAScript中的函式是物件,因此每定義一個函式,也就是例項化了一個物件。從邏輯角度講,此時的建構函式也可以這樣定義:

     function Person(name , age){
         this.name = name;
         this.age = age;
         this.sayName = new Function("alert(this.name)");//與宣告函式在邏輯上是等價的
     }
從這個角度上來看建構函式,更容易明白每個Person例項都包含一個不同的Function例項的本質。說明白些,以這種方式建立函式,會導致不同的作用域鏈和識別符號解析,但建立Function新例項的機制仍然是相同的。因此,不同例項上的同名函式是不相等的,以下程式碼可以證明這一點:
alert(person.sayName ==person2.sayName);//false
然而,建立兩個完成同樣任務的Function例項的確沒有必要;況且this物件在,根本不用在執行程式碼前就把函式繫結到特定物件上面。因此,通過把函式定義到建構函式外部來解決這個問題:
     function sayName(){
         alert(this.name);
     }
     function Person(name , age){
         this.name = name;
         this.age = age;
         this.sayName = sayName;
     }
在這個例子中,把sayName()函式的定義轉移到了建構函式外部。而在建構函式內部,將sayName屬性設定成等於全域性的sayName函式。這樣依賴,由於sayName包含的是一個指向函式的指標,因此person1和person2物件就共享了在全域性域中定義的同一個sayName()函式。這樣做確實解決了兩個函式做同一件事的問題,可是新問題又來了:在全域性作用域中定義的函式實際上只能對某個物件呼叫,這讓全域性作用域有點名不副實。而更讓人無法接受的是:如果物件需要定義很多方法,那麼就要定義很多個全域性函式,於是這個自定義的引用型別就絲毫沒有封裝性可言了。好在,這些問題可以通過使用原型模式來解決。

原型模式

我們建立的每個函式都有一個prototype(原型)屬性,這個屬性是一個指標,指向一個物件,而這個物件的用途是包含可以由特定型別的所有例項共享的屬性和方法。如果按照字面意思來理解,那麼prototype就是通過呼叫建構函式而建立的那個物件例項的原型物件。使用原型物件的好處是可以讓所有物件例項共享它所包含的屬性和方法。換句換說,不必在建構函式中定義物件例項的資訊,而是可以將這些資訊直接新增到原型物件中,如: 

    function Person(){}
     Person.prototype.name = "Nicholas";
     Person.prototype.age = 29;
     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中原型物件的性質。

理解原型物件

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

建立了自定義的建構函式之後,其原型物件預設只會取得constructor屬性;至於其他方法,則都是從Object繼承而來的。當呼叫建構函式建立一個新例項後,該例項的內部將包含一個指標(內部屬性),指向建構函式的原型物件。ECMA-262第5版中稱這個指標為[[Prototype]]。雖然在指令碼中沒有標準的方式訪問[[Prototype]],但Firefox、Safari和Chrome在每個物件上都支援一個屬性_proto_;而在其他實現中,這個屬性對指令碼是完全不可見的。不過,要明確的真正重要的一點就是,這個連線存在於例項與建構函式的原型物件之間,而不是存在於例項與建構函式之間。

以前面使用Person建構函式和Person.prototype建立例項的程式碼為例,下圖展示了各個物件之間的關係:


上圖展示了Person建構函式、Person的原型以及Person現有的兩個例項之間的關係。在此,Person.prototype指向了原型物件,而Person.prototype.constructor又指回了Person。原型物件中除了包含constructor屬性之外,還包括後來新增的其他屬性。Person的每個例項——person1和person2都包含一個內部屬性,該屬性僅僅指向了Person.prototype;換句話說,它們與建構函式沒有直接的關係。此外,要格外注意的是,雖然這兩個例項都不包含屬性和方法,但我們卻可以呼叫person1。sayName()。這是通過查詢物件屬性的過程來實現的。

雖然在所有實現中都無法訪問到[[Prototype]],但可以通過isPrototypeOf()方法來確定物件之間是否存在這種關係。從本質上講,如果[[Prototype]]指向呼叫isPrototypeOf()方法的物件(Person.prototype),那麼這個方法就返回true,如:

     alert(Person.prototype.isPrototypeOf(person1));//true
     alert(Person.prototype.isPrototypeOf(person2));//true
這裡,我們用原型物件的isPrototypeOf()方法測試了person1和person2。因為它們內部都有一個指向Person.prototype的指標,因此都返回了true。

ECMAScript 5增加了一個新方法,叫Object.getPrototypeOf(),在所有支援的實現中,這個方法返回[[Prototype]]的值,如:

     console.log(Object.getPrototypeOf(person1));

</pre><pre name="code" class="javascript">
     alert(Object.getPrototypeOf(person1) ==Person.prototype);//true
     alert(Object.getPrototypeOf(person1).name);//"Nicholas"

這裡前兩行程式碼只是確定Object.getPrototypeOf()返回的物件實際就是這個物件的原型。第三行程式碼取得了原型物件中name屬性的值,也就是“Nicholas”。使用Object.getPrototypeOf()可以方便地取得一個物件的原型,而在利用原型實現繼承的情況下是非常重要的。支援這個方法的瀏覽器有IE9+、Firefox3.5+、Safari5+、Opera12+和Chrome。

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

雖然可以通過物件例項訪問儲存在原型中的值,但卻不能通過物件例項重寫原型中的值。如果我們在例項中添加了一個屬性,而該屬性與例項原型中的一個屬性同名,那我們就在例項中建立該屬性,該屬性將會遮蔽原型中的那個屬性。如: 

    function Person(){}
     Person.prototype.name = "Nicholas";
     Person.prototype.age = 29;
     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.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.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()方法,什麼時候訪問的是例項屬性,什麼時候訪問的是原型屬性就一清二楚了。

原型與in操作符

有兩種方式使用in操作符:單獨使用和在for-in迴圈中使用。在單獨使用時,in操作符會在通過物件能夠訪問給定屬性時返回true,無論該屬性存在於例項中還是原型中,如:

     function Person(){}
     Person.prototype.name = "Nicholas";
     Person.prototype.age = 29;
     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(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(obj , pro){
         return !obj.hasOwnProperty(pro) && (pro in obj)
     }
     function Person(){}
     Person.prototype.name = "Nicholas";
     Person.prototype.age = 29;
     Person.prototype.sayName = function(){alert(this.name);};
     var person = new Person();
     alert(hasPrototypeProperty(person , "name"));//true
     person.name="Greg";
     alert(hasPrototypeProperty(person , "name"));//false
由於in操作符只要通過物件能夠訪問到屬性就返回true,hasOwnProperty()只在屬性存在於例項中時才返回true,因此只要in操作符返回true而hasOwnProperty()返回false,就可以確定屬性是原型中的屬性。

在使用for-in迴圈時,返回的是所有能夠通過物件訪問的、可列舉的屬性,其中既包括存在於例項中的屬性,也包括存在於原型中的屬性。遮蔽了原型中不可列舉屬性的例項屬性也會在for-in迴圈中返回,因為i根據規定,所有開發人員定義的屬性都是可列舉的——只有在IE8及更早版本中例外。

IE早期版本的實現中存在一個bug,即遮蔽不可列舉屬性的例項屬性不會出現在for-in迴圈中。如:  

   var obj = {
             toString:function(){
                 return "MyObject";
             }
     };
     for(var pro in obj){
         if(pro == "toString"){
             alert("Found toString");//在IE8及更低版本中不會顯示
         }
     }

當以上程式碼執行時,應該會顯示一個警告框,表名找到了toString()方法。這裡的物件obj定義了一個名為toString()的方法,該方法遮蔽了原型中(不可列舉)的toString()方法。在IE8及更低版本中,由於其實現認為原型的toString()方法被打上了[[Enumerable]]標記就應該跳過該屬性,結果就看不到警告框。該bug會影響預設不可列舉的所有屬性和方法,包括:hasOwnProperty()、PropertyIsEnumerable()、toLocaleString()、toString()和valueOf()。ECMAScript5也將constructor和prototype屬性的[[Enumerable]]特性設定為false,但並不是所有瀏覽器都照此實現。

要取得物件上所有可列舉的例項屬性,可以使用ECMAScript5的Object.keys()方法。這個方法接收一個物件作為引數,返回一個包含所有可列舉屬性的字串陣列。如:

     function Person(){}
     Person.prototype.name = "Nicholas";
     Person.prototype.age = 29;
     Person.prototype.sayName = function(){alert(this.name);};
     var keys = Object.keys(Person.prototype);
     alert(keys);//"name,age,sayName"
     var person1 = new Person();
     person1.name = "goser";
     person1.age = 24;
     var person1Keys = Object.keys(person1);
     alert(person1Keys);//"name,age"
這裡,變數keys中將儲存一個數組,陣列中是字串”name”、”age”和”sayName”。這個順序也是它們在for-in迴圈中出現的順序。如果是通過Person的例項呼叫,則Object.keys()返回的陣列只包含”name”和”age”這兩個例項屬性。

如果想要得到所有例項屬性,無論它是否可列舉,都可使用Object.getOwnPropertyNames()方法。

     var keys =Object.getOwnPropertyNames(Person.prototype);
     console.log(keys);//["constructor","name", "age", "sayName"]
注意結果中包含了不可列舉的constructor屬性。Object.keys()和Object.getOwnPropertyNames()方法都可以用來替代for-in迴圈。支援這兩個方法的瀏覽器有IE9+、Firefox4+、Safari5+、Opera12+和Chrome。

更簡單的原型語法

在前面的例子中,每天加一個屬性和方法就要敲一遍Person.prototype。為減少不必要的輸入,也為了從視覺上更好地封裝原型的功能,更常見的做法是用一個包含所有屬性和方法的物件字面量來重寫整個原型物件,如:

     function Person(){}
     Person.prototype = {
             name : "Nicholas",
             age : 29,
             sayName : function(){alert(this.name);}
     };
在上面的程式碼中,將Person.prototype設定為等於一個以物件字面量形式建立的新物件。最終結果相同,但有一個例外:constructor屬性不再指向Person了。前面曾經介紹過。沒建立一個函式,就會同時建立它的prototype物件,這個物件也會自動獲得constructor屬性。而我們在這裡使用的語法,本質上完全重寫了預設的prototype物件,因此constructor屬性也就變成了新物件的constructor屬性,不再指向Person函式,此時,儘管instanceof操作符還能返回正確的結果,但通過constructor已經無法確定物件的型別了,如: 
    var p = new Person();
     alert(p instanceof Object);//true
     alert(p instanceof Person);//true
     alert(p.constructor == Object);//true
     alert(p.constructor == Person);//false

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

     function Person(){}
     Person.prototype = {
             constructor : Person,
             name : "Nicholas",
             age : 29,
             sayName : function(){alert(this.name);}
     };
以上程式碼特意包含了一個constructor屬性,並將它的值設定為Person,從而確保了通過該屬效能夠訪問到適當的值。

注意,以這種方式重設constructor屬性會導致它的[[Enumerable]]特性被設定為true。預設情況喜愛,原生的constructor屬性是不可列舉的,因此如果你使用相容ECMAScript5的JavaScript引擎,可以試一試Object.defineProperty()。

原型的動態性

由於在原型中查詢值的過程是一次搜尋,因此我們對原型物件所做的任何修改都能夠立即從例項上反映出來——即使是先建立了例項後修改原型也照樣如此,如:

     var p = new Person();
     Person.prototype.sayName = function(){
         alert("[name=" + this.name + "; age=" + this.age + "]");
     };
     p.sayName();//[name=Nicholas; age=29]
以上程式碼,先建立了Person的一個例項,並將其儲存在p中。然後,下一條語句在Person.prototype中修改了sayName()方法。即使p例項是在修改方法之前建立的,但它仍然反映了修改後的方法。

儘管可以隨時為原型新增屬性和方法,並且修改能夠立即在所有物件例項中反映出來,但如果是重寫整個原型物件,那麼情況就不一樣了。我們知道,呼叫建構函式時會為例項新增一個指向最初原型的[[Prototype]]指標,而把原型修改為另一個物件就等於切斷了建構函式與最初原型之間的聯絡。請記住:例項中的指標僅指向原型,而不指向建構函式,如:

     function Person(){}
     var p = new Person();
     Person.prototype= {
             constructor : Person,
             name : "Nicholas",
             age : 29,
             sayName : function(){alert(this.name);}
     };
     p.sayName();//error(Uncaught TypeError:p.sayName is not a function)
在這個例子中,先建立了Person的一個例項,然後又重寫了其原型物件。然後再呼叫sayName()方法,此時發生了錯誤,因為p指向的原型中不包含以該名命名的屬性。

原生物件的原型

原型模式的重要性不僅體現在建立自定義型別方面,就連所有原生的引用型別,嗾使採用這種模式建立的。所有原生引用型別(Object、Array、String等)都在其建構函式的原型上定義了方法。例如:在Array.prototype中可以找到sort()方法,而在String.prototype中可以找到substring()方法。通過原生物件的原型,不僅可以取得所有預設方法的引用,而且可以定義新方法。可以像修改自定義物件的原型一樣修改原生物件的原型,因此可以隨時新增方法,如下面的程式碼中給基本包裝型別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基本包裝函式建立這個字串,因此通過mag就可以呼叫startsWith()方法。

儘管可以這樣做,但不推薦修改原生物件的原型。如果因某個實現中缺少某個方法,就在原生物件中新增這個方法,那麼當在另一個支援該方法的實現中執行程式碼時,就可能會導致命名衝突。而且,這樣做也可能會意外地重寫原生方法。

原型物件的問題

原型模式也不是沒有缺點。首先,它省略了為建構函式傳遞初始化引數這一環節,結果所有例項在預設情況下都取得相同的屬性值。雖然這會在某種程度上帶來一些不方便,但還不是原型的最大問題。原型模式的最大問題是由其共享的本性所導致的。

原型中所有屬性是被很多例項共享的,這種共享對於函式非常適合。對於那些包含基本值的屬性倒也說得過去,畢竟,通過在例項上新增一個同名屬性,可以隱藏原型中的對應屬性。然而,對於包含引用型別值的屬性來說,問題就比較突出了,如:

     function Person(){}
     Person.prototype = {
             constructor : Person,
             name : "Nicholas",
             age : 29,
             sayName : function(){alert(this.name);},
             friends : ["goser" , "greg"]
     };
     var person1 = new Person();
     var person2 = new Person();
     person1.friends.push("gat");
     alert(person1.friends);//"goser,greg,gat"
     alert(person2.friends);//"goser,greg,gat"
     alert(person1.friends == person2.friends);//true
在此,Person.prototype物件有一個名為friends的屬性,該屬性包含一個字串陣列。然後,建立了Person的兩個書立。接著,修改了person1.friends引用的陣列,向陣列中添加了一個字串。由於friends陣列存在於Person.prototype而非person1中,所以剛剛提到的修改也會通過person2.friends反映出來。加入我們的初衷就是這樣在所有例項中共享一個數組,那麼對這個結果我沒有話可說。可是,例項一般都是要有屬於自己的全部屬性的。而這個問題正式很少看到有人單獨使用原型模式的原因所在。

組合使用建構函式模式和原型模式

建立自定義型別的最常見方式,就是組合使用建構函式模式與原型模式。建構函式模式用於定義例項屬性,而原型模式用於定義方法和共享的屬性。結果,每個例項都會有自己的一份例項屬性的副本,但同時又共享著對方法的引用,最大限度地節省了記憶體。另外,這種混成模式還支援向建構函式傳遞引數;可謂是集兩種模式之長,下面的程式碼重寫了前面的例子: 

    function Person(name , age){
         this.name = name;
         this.age = age;
         this.friends = ["goser" , "greg"];
     }
     Person.prototype = {
             constructor : Person,
             sayName : function(){alert(this.name);},
     };
     var person1 = new Person("Nicholas" , 29);
     var person2 = new Person("Greg" , 21);
     person1.friends.push("gat");
     alert(person1.friends);//"goser,greg,gat"
     alert(person2.friends);//"goser,greg"
     alert(person1.friends == person2.friends);//false
     alert(person1.sayName == person2.sayName);//true

在這個例子中,例項屬性都是在建構函式中定義的,而由所有例項共享的屬性constructor和方法sayName()則是在原型中定義的。而修改了person1.friends,並不會影響到person2.friends,因為它們分別引用了不同的陣列。

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

動態原型模式

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

     function Person(name , age){
         this.name = name;
         this.age = age;
//--------------------------------------------//
         if(typeof this.sayName != "function"){
             Person.prototype.sayName = function(){
                alert(this.name);
             };
         }
//--------------------------------------------//
     }
     var p = new Person("Nicholas" , 29);
     p.sayName();
注意建構函式中使用//-……-//包圍的部分,只在sayName()方法不存在的情況下,才會將它新增到原型中。這段程式碼只會在初次呼叫建構函式時才會執行。此後,原型已經完成初始化,不需要再做什麼修改了。不過要記住,這裡對原型所做的修改,能夠立即在所有例項中得到反映。因此,這種方法確實可以說完美。其中,if語句檢查的可以是初始化之後應該存在的任何屬性或方法——不必用一大堆if語句檢查每個屬性和每個方法;只要檢查其中一個即可。對於採用這種模式建立的物件,還可以使用instanceof操作符確定它的型別。

使用動態原型模式時,不能使用物件字面量重寫原型。前面已經解釋過了,如果在已經建立了例項的情況下重寫原型,那麼就會切斷現有例項與新原型之間的聯絡。

寄生建構函式模式

通常,在前述的幾種模式都不適用的情況下,可以使用寄生建構函式模式。這種模式的基本思想是建立一個函式,該函式的作用僅僅是封裝建立物件的程式碼,然後再返回新建立的物件;但從表面上看,這個函式又很像是經典的建構函式,如:

     function Person(name , age){
         var obj = new Object();
         obj.name = name;
         obj.age = age;
         obj.sayName = function(){
            alert(this.name);
         };
         return obj;
     }
     var p = new Person("Nicholas" , 29);
p.sayName();
在這個例子中,Person函式建立了一個新物件,並以相應的屬性和方法初始化該物件,然後返回這個物件。除了使用new操作符並把使用的包裝函式叫做建構函式之外,這個模式跟工廠模式其實是一模一樣的。建構函式在不返回值的情況下,預設會返回新物件例項。而通過在建構函式的末尾新增一個return語句,可以重寫呼叫建構函式返回的值。

這個模式可以在特殊的情況下用來為物件建立建構函式。假設我們想建立一個具有額外方法的特殊陣列。由於不能直接修改Array建構函式,因此可以使用這個模式。如:

     function SArray(){
         var value = new Array();
         value.push.apply(value , arguments);
         value.toPipedString = function(){
             return this.join("|");
         };
         return value;
     }
     var colors = new SArray("red" , "blue" , "green");
     alert(colors.toPipedString());//"red|blue|green"
在這個例子中,我們建立了一個名叫SArray的建構函式。在這個函式內部,首先建立了一個數組,然後push()方法初始化了陣列的值。隨後,又給陣列例項添加了一個toPipedString()方法,該方法返回以豎線分割的陣列值。最後,將陣列以函式值的形式返回。接著,呼叫了SArray建構函式,向其傳入了用於初始化陣列的值,此後又呼叫了toPipedString()方法。

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

穩妥建構函式模式

道格拉斯·克羅克福德(DouglasCrockford)發明了JavaScript中的穩妥物件這個概念。所謂穩妥物件,指的是沒有公共屬性,而且其方法也不引用this的物件。穩妥物件最適合在一些安全的環境中(這些環境中會進位制使用this和new),或者在防止資料被其他應用程式(如Mashup程式)改動時使用。穩妥建構函式遵循與寄生建構函式類似的模式,但有兩點不同:一是新建立物件的視力方法不引用this;二是不使用new操作符呼叫建構函式。按照穩妥建構函式的要求,可以將前面的Person建構函式重寫如下:

     function Person(name , age){
         var obj = new Object();
         obj.name = name;
         obj.age = age;
         obj.sayName = function(){
            alert(name);
         };
         return obj;
     }
注意,在以這種模式建立的物件中,除了使用sayName()方法之外,沒有其他辦法訪問name的值。可以像下面使用穩妥的Person建構函式。
     var p = Person("Nicholas" , 29);
     p.sayName();
這樣,變數p中儲存的是一個穩妥物件,而除了呼叫sayName()方法外,沒有邊的方式可以訪問其資料成員。即使有其他程式碼會給這個物件新增方法和資料成員,但也不可能有別的辦法訪問傳入到建構函式中的原始資料。穩妥建構函式模式提供的這種安全性,使得它非常在某些安全執行環境——例如,ADsafe和Caja提供的環境下執行。

建立物件的方式小結

方式一:Object建構函式

    var person = new Object();
     person.name = "Nicholas";
     person.age = 29;
     person.sayName=function(){return this.name;};

方式二:物件字面量

var person = {
             name:"Nicholas",
             age:29,
             sayName:function(){
                 return this.name;
             }
     };
方式一、方式二缺點:使用同一個介面建立很多物件,會產生大量的重複程式碼。

方式三:工廠模式

     function createPerson(name , age){
         var obj = new Object();
         obj.name = name;
         obj.age = age;
         obj.sayName = function(){
             alert(this.name);
         };
         return obj;
     }
     var person1 = createPerson("Nicholas" , 29);
     var person2 = createPerson("Greg" , 21);
缺點:沒有解決物件識別的問題(即怎樣知道一個物件的型別)。

方式四:建構函式模式

functionPerson(name , age){
         this.name = name;
         this.age = age;
         this.sayName = function(){
             alert(this.name);
         };
     }
     var person1 = new Person("Nicholas" , 29);
     var person2 = new Person("Greg" , 21);
缺點:以這種方式建立函式,會導致不同的作用域鏈和識別符號解析,但建立Function新例項的機制仍然是相同的。因此,不同例項上的同名函式是不相等的,造成存在多個完成同樣功能的Function例項。

優化的建構函式模式

functionsayName(){
         alert(this.name);
     }
     function Person(name , age){
         this.name = name;
         this.age = age;
         this.sayName = sayName;
     }
缺點:雖然解決了兩個函式做同一件事的問題,但是在全域性作用域中定義的函式實際上只能對某個物件呼叫,這讓全域性作用域有點名不副實。而更讓人無法接受的是:如果物件需要定義很多方法,那麼就要定義很多個全域性函式,於是這個自定義的引用型別就絲毫沒有封裝性可言了。

方式五:原型模式

functionPerson(){}
     Person.prototype.name = "Nicholas";
     Person.prototype.age = 29;
     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
缺點:首先,它省略了為建構函式傳遞初始化引數這一環節,結果所有例項在預設情況下都取得相同的屬性值。最嚴重的問題是原型中所有屬性是被很多例項共享的,導致一個例項的修改會在所有例項中得到反映。

方式六:組合使用建構函式和原型模式

建立自定義型別的最常見方式,就是組合使用建構函式模式與原型模式

     function Person(name , age){
         this.name = name;
         this.age = age;
         this.friends = ["goser" , "greg"];
     }
     Person.prototype = {
             constructor : Person,
             sayName : function(){alert(this.name);},
     };

方式七:動態原型模式

    function Person(name , age){
         this.name = name;
         this.age = age;
//--------------------------------------------//
         if(typeof this.sayName != "function"){
             Person.prototype.sayName = function(){
                alert(this.name);
             };
         }
//--------------------------------------------//
     }
     var p = new Person("Nicholas" , 29);
     p.sayName();

特點:保持了同時使用建構函式和原型的優點。

方式八:寄生建構函式模式

    function Person(name , age){
         var obj = new Object();
         obj.name = name;
         obj.age = age;
         obj.sayName = function(){
            alert(this.name);
         };
         return obj;
     }
     var p = new Person("Nicholas" , 29);
p.sayName();

特點:這個模式可以在特殊的情況下用來為物件建立建構函式。

方式九:穩妥建構函式模式

     function Person(name , age){
         var obj = new Object();
         obj.name = name;
         obj.age = age;
         obj.sayName = function(){
            alert(name);
         };
         return obj;
     }
     var p = Person("Nicholas" , 29);
     p.sayName();
特點:新建立物件的視力方法不引用this;不使用new操作符呼叫建構函式。沒有公共屬性,而且其方法也不引用this的物件。最適合在一些安全的環境中(這些環境中會進位制使用this和new),或者在防止資料被其他應用程式(如Mashup程式)改動時使用。