我們創建的每個函數都有一個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中原型對象的性質。
無論什麼時候,只要創建了一個新函數,就會根據一組特定的規則為該函數創建一個prototype屬性,這個屬性指向函數的原型對象。在默認情況下,所有原型對象都會獲得一個constructor(構造函數)屬性,這個屬性包含一個指向prototype屬性所在函數的指針。就拿前面的例子來說,Person.prototype.constructor指向Person。而通過這個構造函數,我們還可以繼續為原型對象添加其它屬性和方法。
創建了自定義的構造函數之後,其原型對象默認只會取得constructor屬性;至於其它方法,則都是從Object繼承而來的。當調用構造函數創建一個新實例後,該實例的內部將包含一個指針(內部屬性),指向構造函數的原型對象。在很多實現中,這個內部屬性的名字是_proto_,而且通過腳本可以訪問到(在firefox、Safari、Chrome和Flash的ActionScript中,都可以通過腳本訪問_proto_);而在其它實現中,這個屬性對腳本則是完全不可見的。不過,要明確的真正重要的一點,就是這個連接存在與實例與構造函數的原型對象之間,而不是存在於實例於構造函數之間。
雖然在某些實現中無法訪問到內部的_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.ae = 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 = 20; 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.jog = "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")); //flase
通過使用hasOwnProperty()方法,什麼時候訪問的是實例屬性,什麼時候訪問的是原型屬性就一清二楚了。調用person1.hasOwnProperty(“name”)時,只有當person1重寫name屬性後才會返回true,因為只有這時候name才是一個實例屬性,而非原型屬性。
有兩種方式使用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 person1); //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]]標記,但這並不是所有浏覽器共同的做法。
讀者大概注意到了,前面例子中每添加一個屬性和方法就要敲一遍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已經無法確定對象的類型了,如下所示:
function Person() { } Person.prototype = { name: "Nicholas", age: 29, job: "Software Engineer", sayName: function () { alert(this.name); } }; 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); } }; var person = new Person();
以上代碼特意包含了一個constructor屬性,並將它的值設置為Person,從而確保了通過該屬性能夠訪問到適當的值。
alert(person instanceof Object); //true alert(person instanceof Person); //true alert(person.constructor == Person); //true alert(person.constructor == Object); //false
由於在原型中查找值的過程是一次搜索,因此我們對原型對象所做的任何修改都能夠立即從實例上反映出來——即使是西安創建了實例後修改原型也照樣如此。請看下面的例子:
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指向的原型中不包含以改名字命名的屬性。
圓心模式的重要性不僅體現在創建自定義類型方面,就連所有原生的引用類型,都是采用這種模式創建的。所有原生引用類型(Object、Array、String、等等)都在其構造函數的原型上定義了方法。例如,在Array.prototype中可以找到sort()方法,而在String.prototype中可以找到substring()方法,如下所示:
alert(typeof Array.prototype.sort); //"function" alert(typeof String.prototype.substring); //"function"
通過原生對象的原則,不僅可以取得所有默認方法的引用,而且也可以定義新方法。可以向修改自定義對象的原型一樣修改原生對象的原型,因此尅隨時添加方法。下面的代碼就給基本包裝類型String添加了yield名為startsWith()的方法:
String.prototype.startsWith = function (text) { return this.indexOf(text) == 0; }; var msg = "Hello world!"; alert(msg.startsWith("Hello")); //true
這裡定義的startsWith()方法會在傳入的文本位於yield字符串開始時返回true。既然方法被添加給了String.pprototype,那麼當前環境中的所有字符串就都可以調用它。由於msg是字符串。而且後台會調用String基本保皇函數創建這個字符串,因此通過msg就可以調用startsWith()方法。
盡管可以這樣做,但我們不推薦在產品化的程序中修改原生對象的原型。如果因某個實現中缺少某個方法,就在原生對象的原型中添加這個方法,那麼當在另一個支持該方法的實現中運行代碼時,就可能會導致命名沖突。而且,這樣做也可能意外地重寫原生方法。
原型模式也不是沒有缺點。首先,它省略了為構造函數傳遞初始化參數這一環節,結果所有實例在默認情況下都將取得相同的屬性值。雖然這會在某種程度上帶來一些不方便,但還不是原型的最大問題。原型模式的最大問題是由其共享的本性所導致的。
原型中所有屬性是被很多實例共享的,這種共享對於函數非常合適。對於那些包含基本值的屬性倒也說的過去,畢竟,通過在實例上添加一個同名屬性,可以隱藏原型中的對應屬性。然而,對於包含引用類型的屬性來說,問題就比較突出了。來看下面的例子:
function Person() {} Person.prototype = { constructor: Person, name: "Nicholas", age: 2, 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); //ture
在此,Person.prototype對象有一個名為friends的屬性,該屬性包含一個字符串數組。然後,創建了Person的兩個實例。接著,修改了person1.friends引用數組,向數組中添加了一個字符串。由於firends數組存在與Person.prototype而非person1中,所以剛剛提到的修改也會通過person2.friends反映出來。假如我們的初衷就是像這樣在所有實例中共享一個數組,那麼對這個結果我沒有話可說。可是,實例一般都是要有屬於自己的全部屬性的。而這個問題正是我們很少看到有人單獨使用原型模式的原因所在。