繼承的實現方式及原型概述

發表於2015-07-15

對於 OO 語言,有一句話叫“Everything is object”,雖然 JavaScript 不是嚴格意義上的面嚮物件語言,但如果想要理解 JS 中的繼承,這句話必須時刻銘記於心。

JS 的語法非常靈活,所以有人覺得它簡單,因為怎麼寫都是對的;也有人覺得它難,因為很難解釋某些語法的設計,誰能告訴我為什麼 typeof null 是 object 而 typeof undefined 是 undefined 嗎?並且這是在 null == undefined 的前提下。很多我們自認為“懂”了的知識點,細細琢磨起來,還是會發現有很多盲點,“無畏源於無知”吧……

1. 簡單物件

既然是講繼承,自然是從最簡單的物件說起:

這便是物件直接量了。每一個物件直接量都是 Object 的子類,即

2. 建構函式

JS 中的建構函式與普通函式並沒有什麼兩樣,只不過在呼叫時,前面加上了 new 關鍵字,就當成是建構函式了。

兩個問題,第一,不加 new 關鍵字有什麼後果?

那麼 Dog 函式中的 this 在上下文(Context)中被解釋為全域性變數,具體在瀏覽器端的話是 window 物件,在 node 環境下是一個 global 物件。

第二,dog 的值是什麼?很簡單,undefined 。Dog 函式沒有返回任何值,執行結束後,dog 的值自然是 undefined 。

關於 new 的過程,這裡也順便介紹一下,這個對後面理解原型(prototype)有很大的幫助:

  1. 建立一個空的物件,僅包含 Object 的屬性和方法。
  2. 將 prototype 中的屬性和方法建立一份引用,賦給新物件。
  3. 將 this 上的屬性和方法新建一份,賦給新物件。
  4. 返回 this 物件,忽略 return 語句。

需要明確的是,prototype 上的屬性和方法是例項間共享的,this 上的屬性和方法是每個例項獨有的。

3. 引入 prototype

現在為 Dog 函式加上 prototype,看一個例子:

看到有註釋的那三行應該可以明白“引用”和“新建”的區別了。

那麼我們經常說到的“原型鏈”到底是什麼呢?這個術語出現在繼承當中,它用於表示物件例項中的屬性和方法來自於何處(哪個父類)。好吧,這是筆者的解釋。

上面的是 dog1 的原型鏈,不知道夠不夠直觀地描述“鏈”這個概念。

  1. 其中,bark 和 name 是定義在 this 中的,所以最頂層可以看到它倆。
  2. 然後,每一個物件都會有一個 __proto__ 屬性(IE 11+),它表示定義在原型上的屬性和方法,所以 jump、species 和 teeth 自然就在這兒了。
  3. 最後就一直向上找 __proto__ 中的屬性和方法。

4. 繼承的幾種實現

4.1 通過 call 或者 apply

繼承在程式設計中有兩種說法,一個叫 inherit,另一個是 extend 。前者是嚴格意義上的繼承,即存在父子關係,而後者僅僅是一個類擴充套件了另一個類的屬性和方法。那麼 call 和 apply 就屬於後者的範疇。怎麼說?

雖然在 dog 物件中有 gender 屬性,但 dog 卻不是 Animal 型別。甚至,這種方式只能“繼承”父類在 this 上定義的屬性和方法,並不能繼承 Animal.prototype 中的屬性和方法。

4.2 通過 prototype 實現繼承

要實現繼承,必須包含“原型”的概念。下面是很常用的繼承方式。

繼承的結果有兩個:一、獲得父類的屬性和方法;二、正確通過 instanceof 的測試。

prototype 也是物件,它是建立例項時的裝配機,這個在前面有提過。new Animal() 的值包含 Animal 例項所有的屬性和方法,既然它賦給了 Dog 的 prototype,那麼 Dog 的例項自然就獲得了父類的所有屬性和方法。

並且,通過這個例子可以知道,改變 Dog 的 prototype 屬性可以改變 instanceof 的測試結果,也就是改變了父類。

然後,為什麼要在 Dog 的建構函式中呼叫 Animal.call(this)?

因為 Animal 中可能在 this 上定義了方法和函式,如果沒有這句話,那麼所有的這一切都會給到 Dog 的 prototype 上,根據前面的知識我們知道,prototype 中的屬性和方法在例項間是共享的。

我們希望將這些屬性和方法依然保留在例項自身的空間,而不是共享,因此需要重寫一份。

至於為什麼要修改 constructor,只能說是為了正確的顯示原型鏈吧,它並不會影響 instanceof 的判斷。或者有其他更深的道理我並不知道……

4.3 利用空物件實現繼承

上面的繼承方式已經近乎完美了,除了兩點:

一、Animal 有構造引數,並且使用了這些引數怎麼辦?
二、在 Dog.prototype 中多了一份定義在 Animal 例項中冗餘的屬性和方法。

這個問題可以通過一個空物件來解決(改自 Douglas Crockford)。

他的原始方法是下面的 object:

4.4 利用 __proto__ 實現繼承

現在就只剩下一個問題了,如何把冗餘屬性和方法去掉?

其實,從第 3 小節介紹原型的時候就提到了 __proto__ 屬性,instanceof 運算子是通過它來判斷是否屬於某個型別的。

所以我們可以這麼繼承:

如果不考慮相容性的話,這應該是從 OO 的角度來看最貼切的繼承方式了。

4.5 拷貝繼承

這個方式也只能稱之為 extend 而不是 inherit,所以也沒必要展開說。

像 Backbone.Model.extend、jQuery.extend 或者 _.extend 都是拷貝繼承,可以稍微看一下它們是怎麼實現的。(或者等我自己再好好研究之後過來把這部分補上吧)

5. 個人小結

當我們在討論繼承的實現方式時,給我的感覺就像孔乙己在炫耀“茴香豆”的“茴”有幾種寫法一樣。繼承是 JS 中佔比很大的一塊內容,所以很多庫都有自己的實現方式,它們並沒有使用我認為的“最貼切”的方法,為什麼?JS 就是 JS,它生來就設計得非常靈活,所以我們為什麼不利用這個特性,而非得將 OO 的做法強加於它呢?

通過繼承,我們更多的是希望獲得父類的屬性和方法,至於是否要保證嚴格的父類/子類關係,很多時候並不在乎,而拷貝繼承最能體現這一點。對於基於原型的繼承,會在程式碼中看到各種用 function 定義的型別,而拷貝繼承更通用,它只是將一個物件的屬性和方法拷貝(擴充套件)到另一個物件而已,並不關心原型鏈是什麼。

當然,在我鼓吹拷貝繼承多麼多麼好時,基於原型的繼承自然有它不可取代的理由。所以具體問題得具體分析,當具體的使用場景沒定下來時,就不存在最好的方法。

個人見解,能幫助大家更加理解繼承一點就最好,如果有什麼不對的,請多多指教!

相關文章