JavaScript中的原型和繼承

jobbole發表於2014-05-04

請在此暫時忘記之前學到的物件導向的一切知識。這裡只需要考慮賽車的情況。是的,就是賽車。

最近我正在觀看 24 Hours of Le Mans ,這是法國流行的一項賽事。最快的車被稱為 Le Mans 原型車。這些車雖然是由“奧迪”或“標緻”這些廠商製造的,可它們並不是你在街上或速公路上所見到的那類汽車。它們是專為參加高速耐力賽事而製造出來的。

廠家投入鉅額資金,用於研發、設計、製造這些原型車,而工程師們總是努力嘗試將這項工程做到極致。他們在合金、生物燃料、制動技術、輪胎的化合物成分和安全特性上進行了各種實驗。隨著時間的推移,這些實驗中的某些技術經過反覆改進,隨之進入到車輛的主流產品線中。你所駕駛車輛的某些技術,有可能是在賽車原型上第一次亮相的。

你也可以說,這些主流車輛繼承了來自賽車的技術原型

到現在,我們就有討論 JavaScript 中的原型和繼承問題的基礎了。它雖然並不像你在 C++、Java 或 C# 中瞭解的經典繼承模式一樣,但這種方式同樣強大,並且有可能會更加靈活。

有關物件和類

JavaScript 中全是物件,這指的是傳統意義上的物件,也就是“一個包含了狀態和行為的單一實體”。例如,JavaScript 中的陣列是含有數個值,並且包含 push、reverse 和 pop 方法的物件。

現在問題是,push 這樣的方法是從何而來的呢?我們前面提到的那些靜態語言使用“類語法”來定義物件的結構,但是 JavaScript 是一個沒有“類語法”的語言,無法用 Array“類”的語法來定義每個陣列物件。而因為 JavaScript 是動態語言,我們可以在實際需要的情況下,將方法任意放置到物件上。例如下面的程式碼,就在二維空間中,定義了用來表示一個點的點物件,同時還定義了一個 add 方法。

但是上面的做法可擴充套件性並不好。我們需要確保每一個點物件都含有一個 add 方法,同時也希望所有點物件都共享同一個 add 方法的實現,而不是這個方法手工新增每一個點物件上。這就是原型發揮它作用的地方。

有關原型

在 JavaScript 中,每個物件都保持著一塊隱藏的狀態 —— 一個對另一個物件的引用,也被稱作原型。我們之前建立的陣列引用了一個原型物件,我們自行建立的點物件也是如此。上面說原型引用是隱藏的,但也有 ECMAScript(JavaScript 的正式名稱)的實現可以通過一個物件的__proto__屬性(例如谷歌瀏覽器)訪問到這個原型引用。從概念上講,我們可以將物件當作類似於 圖1 所表示的物件 —— 原型的關係。

 

1

展望未來,開發者將能夠使用 Object.getPrototypeOf 函式,代替__proto__屬性,取得物件原型的引用。在本文寫出的時候,已經可以在 Google Chrome,FIrefox 和 IE9 瀏覽器中使用 Object.getPrototypeOf 函式。更多瀏覽器在未來會實現此功能,因為它已經是 ECMAScript 標準的一部分了。我們可以使用下面的程式碼,來證明我們建立的 myArray 和點物件引用的是兩個不同的原型物件。

  1. Object.getPrototypeOf(point) != Object.getPrototypeOf(myArray);

對於本文的其餘部分,我將交叉使用 __proto__和Object.getPrototypeOf 函式,主要是因為 __proto__ 在圖和句子中更容易識別。需要記住的是它(__proto__)不是標準,而 Object.getPrototypeOf 函式才是檢視物件原型的推薦方法。

是什麼讓原型如此特別?

我們還沒有回答這個問題:陣列中 push 這樣的方法是從何而來的呢?答案是:它來源於 myArray 原型物件。圖 2 是 Chrome 瀏覽器中指令碼偵錯程式的螢幕截圖。我們已經呼叫 Object.getPrototypeOf 方法檢視 myArray 的原型物件。

 

2

注意 myArray 的原型物件中有許多方法,包括那些在程式碼示例中呼叫的 push、pop 和 reverse 方法。因此,原型物件中的確包括 push 方法,但是 myArray 方法如何引用到呢?

瞭解其工作原理的第一步,是要認識到原型並不是特別的。原型只是普通的物件。可以給原型新增方法,屬性,並把他們當作其他 JavaScript 物件一樣看待。然而,套用喬治·奧威爾的小說《動物農場》中“豬”的說法 —— 所有的物件應當是平等的,但有些物件(遵守規則的)比其他人更加平等。

JavaScript 中的原型物件的確是特殊的,因為他們遵從以下規則。當我們告訴 JavaScript 我們要呼叫一個物件的 push 方法,或讀取物件的 x 屬性時,執行時會首先查詢物件本身。如果執行時找不到想要的東西,它就會循著 __proto__ 引用和物件原型尋找該成員。當我們  呼叫 myArray 的 push 方法時,JavaScript 並沒有在 myArray 物件上發現 push 方法,而是在 myArray 的原型物件上找到了,於是 JavaScript 呼叫此方法(見圖 3)。

3

上面所描述的行為是指一個物件本身繼承了原型上的任何方法或屬性。JavaScript 中其實不需要使用類語法也能實現繼承。就像從賽車原型上繼承了相應的技術的車,一個 JavaScript 物件也可以從原型物件上繼承功能特性。

圖 3 還展示了每個陣列物件同時也可以維護自身的狀態和成員。在請求得到 myArray 的 length 屬性的情況下,JavaScript 會取得 myArray 中 length 屬性的值,而不會去讀取原型中的對應值。我們可以通過向物件上新增 push 這樣的方法來“重寫”push 方法。這樣就會有效地隱藏原型中的 push 方法實現。

共享原型

JavaScript 中原型的真正神奇之處是多個物件如何維持對同一個原型物件的引用。例如,如果我們建立了這樣的兩個陣列:

那麼這兩個陣列將共享同一個原型物件,而下面的程式碼計算結果為 true:

如果我們引用兩個陣列物件上的 push 方法,JavaScript 會去尋找原型上共享的 push 方法。

4

JavaScript 中的原型物件提供繼承功能,同時也就實現了該方法實現的共享。原型也是鏈式的。換句話說,因為原型物件只是一個物件,所以一個原型物件可以維持到另一個原型物件的引用。如果你重新審檢視 2 便可以看到,原型的 __proto__ 屬性是一個指向另一個原型的非空值。當 JavaScript 查詢像 push 方法這樣的成員時,它會循著原型引用鏈檢查每一個物件,直到找到該成員,或者抵達原型鏈的末端。原型鏈為繼承和共享開闢了一條靈活的途徑。

你可能會問的下一個問題是:我該如何設定那些自定義物件的原型引用呢?例如前面所使用的點物件,如何才能將 add 方法新增到原型物件中,並從多個點物件中繼承方法呢?在回答這個問題之前,我們需要看看函式。

有關函式

JavaScript 中的函式也是物件。這樣的表述帶來了幾個重要的結果,而我們並不會在本文中涉及所有的事項。這其中,能將一個函式賦值給一個變數,並且將一個函式作為引數傳遞給另一個函式的能力構成了現代 JavaScript 程式設計表達的基本正規化。

我們需要關注的是,函式本身就是物件,因此函式可以有自身的方法,屬性,並且引用一個原型物件。讓我們來討論下面的程式碼的含義。

程式碼中的第一行證明, JavaScript 中的陣列是函式。稍後我們將看到如何呼叫 Array 函式建立一個新的陣列物件。下一行程式碼,證明了 Array 物件使用與任何其他函式物件相同的原型,就像我們看到陣列物件間共享相同的原型一樣。最後一行程式碼證明了 Array 函式都有一個 prototype 屬性,而這個 prototype 屬性指向一個有效的物件。這個 prototype 屬性十分重要。

JavaScript 中的每一個函式物件都有 prototype 屬性。千萬不要混淆這個 prototype 屬性的 __proto__ 屬性。他們用途並不相同,也不是指向同一個物件。

Array.__proto__ 提供的是 陣列原型 – 請把它當作 Array 函式所繼承的物件。

而 Array.protoype,提供的的是 所有陣列的原型物件。也就是說,它提供的是像 myArray 這樣陣列物件的原型物件,也包含了所有陣列將會繼承的方法。我們可以寫一些程式碼來證明這個事實。

我們也可以使用這項新知識重繪之前的示意圖。

5

基於所知道的知識,請想象建立一個新的物件,並讓新物件表現地像陣列的過程。一種方法是使用下面的程式碼。

雖然這段程式碼很有趣,也能工作,可問題在於,並不是每一個 JavaScript 環境都支援可寫的 __proto__ 物件屬性。幸運的是,JavaScript 確實有一個建立物件內建的標準機制,只需要一個操作符,就可以建立新物件,並且設定新物件的 __proto__ 引用 – 那就是“new”操作符。

JavaScript 中的 new 操作符有三個基本任務。首先,它建立新的空物件。接下來,它將設定新物件的 __proto__ 屬性,以匹配所呼叫函式的原型屬性。最後,操作符呼叫函式,將新物件作為“this”引用傳遞。如果要擴充套件最後兩行程式碼,就會變成如下情況:

函式的 call 方法允許你在呼叫函式的情況下在函式內部指定“this”所引用的物件。當然,函式的作者在這種情況下需要實現這樣的函式。一旦作者建立了這樣的函式,就可以將其稱之為建構函式。

建構函式

建構函式和普通的函式一樣,但是具有以下兩個特殊性質。

  1. 通常建構函式的首字母是大寫的(讓識別建構函式變得更容易)。
  2. 建構函式通常要和 new 操作符結合,用來構造新物件。

Array 就是一個建構函式的例子。Array 函式需要和 new 操作符一起使用,而且 Array 的首字母是大寫的。JavaScript 將 Array 作為內建函式包括在內,而任何人都可以寫出自己的建構函式。事實上,我們最後可以為先前建立的點物件編寫出建構函式。

在上面的程式碼中,我們使用了 new 操作符和 Point 函式來構造點物件,這個物件帶有 x 屬性和 y 屬性和一個 add 方法。你可以將最後的結果想象成圖 6 的樣子。

6

現在的問題是我們的每個點物件中仍然有單獨的 add 方法。使用我們學到的原型和繼承的知識,我們更希望將點物件的 add 方法從每個點例項中轉移到 Point.prototype 中。要達到繼承 add 方法的效果,我們所需要做的,就是修改 Point.prototype 物件。

大功告成!我們剛剛在 JavaScript 中完成原型式的繼承模式!

7

總結

我希望這篇文章能夠幫助你揭開 JavaScript 原型概念的神祕面紗。開始看到的是原型怎樣讓一個物件從其他物件中繼承功能,然後看到怎樣結合 new 操作符和建構函式來構建物件。這裡所提到的,只是開啟物件原型力量和靈活性的第一步。本文鼓勵你自己發現學習有關原型和 JavaScript 語言的新資訊。

同時,請小心駕駛。你永遠不會知道這些行駛在路上的車輛會從他們的原型繼承到什麼(有缺陷)的技術。

相關文章