通過原型這種機制,JavaScript 中的對象從其他對象繼承功能特性;這種繼承機制與經典的面向對象編程語言的繼承機制不同。本文將探討這些差別,解釋原型鏈如何工作,并了解如何通過 prototype
屬性向已有的構造器添加方法
預備知識: | 基本的計算機素養,對 HTML 和 CSS 有基本的理解,熟悉 JavaScript 基礎(參見 First steps 和 Building blocks)以及面向對象的JavaScript (OOJS) 基礎(參見 Introduction to objects)。 |
---|---|
目標: | 理解 JavaScript 對象原型、原型鏈如何工作、如何向 prototype 屬性添加新的方法。 |
JavaScript 常被描述為一種基于原型的語言 (prototype-based language)——每個對象擁有一個原型對象,對象以其原型為模板、從原型繼承方法和屬性。原型對象也可能擁有原型,并從中繼承方法和屬性,一層一層、以此類推。這種關系常被稱為原型鏈 (prototype chain),它解釋了為何一個對象會擁有定義在其他對象中的屬性和方法。
準確地說,這些屬性和方法定義在 Object 的構造器函數之上,而非對象實例本身。
在傳統的 OOP 中,首先定義“類”,此后創建對象實例時,類中定義的所有屬性和方法都被復制到實例中。在 JavaScript 中并不如此復制——而是在對象實例和它的構造器之間建立一個連接(作為原型鏈中的一節),以后通過上溯原型鏈,在構造器中找到這些屬性和方法。
以上描述很抽象;我們先看一個例子。
讓我們回到 Person()
構造器的例子。請把這個例子載入瀏覽器。如果你還沒有看完上一篇文章并寫好這個例子,也可以使用 oojs-class-further-exercises.html 中的例子(亦可參考源代碼)。
本例中我們將定義一個構造器函數:
function Person(first, last, age, gender, interests) { // 屬性與方法定義 };
然后創建一個對象實例:
var person1 = new Person('Bob', 'Smith', 32, 'male', ['music', 'skiing']);
在 JavaScript 控制臺輸入 'person1.
',你會看到,瀏覽器將根據這個對象的可用的成員名稱進行自動補全:
在這個列表中,你可以看到定義在 person1
的原型對象、即 Person()
構造器中的成員—— name
、age
、gender
、interests
、bio
、greeting
。同時也有一些其他成員—— watch
、valueOf
等等——這些成員定義在 Person()
構造器的原型對象、即 Object
之上。下圖展示了原型鏈的運作機制。
那么,調用 person1
的“實際定義在 Object
上”的方法時,會發生什么?比如:
person1.valueOf()
這個方法僅僅返回了被調用對象的值。在這個例子中發生了如下過程:
person1
對象是否具有可用的 valueOf()
方法。person1
對象的原型對象(即 Person
)是否具有可用的 valueof()
方法。Person()
構造器的原型對象(即 Object
)是否具有可用的 valueOf()
方法。Object
具有這個方法,于是該方法被調用,注意:必須重申,原型鏈中的方法和屬性沒有被復制到其他對象——它們被訪問需要通過前面所說的“原型鏈”的方式。
注意:沒有官方的方法用于直接訪問一個對象的原型對象——原型鏈中的“連接”被定義在一個內部屬性中,在 JavaScript 語言標準中用 [[prototype]]
表示(參見 ECMAScript)。然而,大多數現代瀏覽器還是提供了一個名為 __proto__
(前后各有2個下劃線)的屬性,其包含了對象的原型。你可以嘗試輸入 person1.__proto__
和 person1.__proto__.__proto__
,看看代碼中的原型鏈是什么樣的!
那么,那些繼承的屬性和方法在哪兒定義呢?如果你查看 Object
參考頁,會發現左側列出許多屬性和方法——大大超過我們在 person1
對象中看到的繼承成員的數量。某些屬性或方法被繼承了,而另一些沒有——為什么呢?
原因在于,繼承的屬性和方法是定義在 prototype
屬性之上的(你可以稱之為子命名空間 (sub namespace) )——那些以 Object.prototype.
開頭的屬性,而非僅僅以 Object.
開頭的屬性。prototype
屬性的值是一個對象,我們希望被原型鏈下游的對象繼承的屬性和方法,都被儲存在其中。
于是 Object.prototype.watch()、
Object.prototype.valueOf()
等等成員,適用于任何繼承自 Object()
的對象類型,包括使用構造器創建的新的對象實例。
Object.is()
、Object.keys()
,以及其他不在 prototype
對象內的成員,不會被“對象實例”或“繼承自 Object()
的對象類型”所繼承。這些方法/屬性僅能被 Object()
構造器自身使用。
注意:這看起來很奇怪——構造器本身就是函數,你怎么可能在構造器這個函數中定義一個方法呢?其實函數也是一個對象類型,你可以查閱 Function()
構造器的參考文檔以確認這一點。
prototype
屬性。回到先前的例子,在 JavaScript 控制臺輸入: Person.prototype
prototype
屬性初始為空白?,F在嘗試: Object.prototype
你會看到 Object
的 prototype
屬性上定義了大量的方法;如前所示,繼承自 Object
的對象都可以使用這些方法。
JavaScript 中到處都是通過原型鏈繼承的例子。比如,你可以嘗試從 String
、Date
、Number
和 Array
全局對象的原型中尋找方法和屬性。它們都在原型上定義了一些方法,因此當你創建一個字符串時:
var myString = 'This is my string.';
myString
立即具有了一些有用的方法,如 split()
、indexOf()
、replace()
等。
重要:prototype
屬性大概是 JavaScript 中最容易混淆的名稱之一。你可能會認為,這個屬性指向當前對象的原型對象,其實不是(還記得么?原型對象是一個內部對象,應當使用 __proto__
訪問)。prototype
屬性包含(指向)一個對象,你在這個對象中定義需要被繼承的成員。
create()
我們曾經講過如何用 Object.create()
方法創建新的對象實例。
var person2 = Object.create(person1);
create()
實際做的是從指定原型對象創建一個新的對象。這里以 person1
為原型對象創建了 person2
對象。在控制臺輸入: person2.__proto__
結果返回 person1
對象。
每個對象實例都具有 constructor
屬性,它指向創建該實例的構造器函數。
person1.constructorperson2.constructor
都將返回 Person()
構造器,因為該構造器包含這些實例的原始定義。
一個小技巧是,你可以在 constructor
屬性的末尾添加一對圓括號(括號中包含所需的參數),從而用這個構造器創建另一個對象實例。畢竟構造器是一個函數,故可以通過圓括號調用;只需在前面添加 new
關鍵字,便能將此函數作為構造器使用。
var person3 = new person1.constructor('Karen', 'Stephenson', 26, 'female', ['playing drums', 'mountain climbing']);
person3.name.firstperson3.ageperson3.bio()
正常工作。通常你不會去用這種方法創建新的實例;但如果你剛好因為某些原因沒有原始構造器的引用,那么這種方法就很有用了。
此外,constructor
屬性還有其他用途。比如,想要獲得某個對象實例的構造器的名字,可以這么用:
instanceName.constructor.name
具體地,像這樣:
person1.constructor.name
從我們從下面這個例子來看一下如何修改構造器的 prototype
屬性。
prototype
屬性添加一個新的方法: Person.prototype.farewell = function() { alert(this.name.first + ' has left the building. Bye for now!');}
person1.farewell();
你會看到一條警告信息,其中還顯示了構造器中定義的人名;這很有用。但更關鍵的是,整條繼承鏈動態地更新了,任何由此構造器創建的對象實例都自動獲得了這個方法。
再想一想這個過程。我們的代碼中定義了構造器,然后用這個構造器創建了一個對象實例,此后向構造器的 prototype
添加了一個新的方法:
function Person(first, last, age, gender, interests) { // 屬性與方法定義};var person1 = new Person('Tammi', 'Smith', 32, 'neutral', ['music', 'skiing', 'kickboxing']);Person.prototype.farewell = function() { alert(this.name.first + ' has left the building. Bye for now!');}
但是 farewell()
方法仍然可用于 person1
對象實例——舊有對象實例的可用功能被自動更新了。這證明了先前描述的原型鏈模型。這種繼承模型下,上游對象的方法不會復制到下游的對象實例中;下游對象本身雖然沒有定義這些方法,但瀏覽器會通過上溯原型鏈、從上游對象中找到它們。這種繼承模型提供了一個強大而可擴展的功能系統。
注意:如果運行樣例時遇到問題,請參閱 oojs-class-prototype.html 樣例(也可查看即時運行)。
你很少看到屬性定義在 prototype 屬性中,因為如此定義不夠靈活。比如,你可以添加一個屬性:
Person.prototype.fullName = 'Bob Smith';
但這不夠靈活,因為人們可能不叫這個名字。用 name.first
和 name.last
組成 fullName
會好很多:
Person.prototype.fullName = this.name.first + ' ' + this.name.last;
然而,這么做是無效的,因為本例中 this
引用全局范圍,而非函數范圍。訪問這個屬性只會得到 undefined undefined
。但這個語句若放在先前定義的 prototype
的方法中則有效,因為此時語句位于函數范圍內,從而能夠成功地轉換為對象實例范圍。你可能會在 prototype
上定義常屬性 (constant property) (指那些你永遠無需改變的屬性),但一般來說,在構造器內定義屬性更好。
譯者注:關于 this
關鍵字指代(引用)什么范圍/哪個對象,這個問題超出了本文討論范圍。事實上,這個問題有點復雜,如果現在你沒能理解,也不用擔心。
事實上,一種極其常見的對象定義模式是,在構造器(函數體)中定義屬性、在 prototype
屬性上定義方法。如此,構造器只包含屬性定義,而方法則分裝在不同的代碼塊,代碼更具可讀性。例如:
// 構造器及其屬性定義function Test(a,b,c,d) { // 屬性定義};// 定義第一個方法Test.prototype.x = function () { ... }// 定義第二個方法Test.prototype.y = function () { ... }// 等等……
在 Piotr Zalewa 的 school plan app 樣例中可以看到這種模式。
本文介紹了 JavaScript 對象原型,包括原型鏈如何允許對象之間繼承特性、prototype
屬性、如何通過它來向構造器添加方法,以及其他有關主題。
下一篇文章中,我們將了解如何在兩個自定義的對象間實現功能的繼承。