從本質認識JavaScript的原型繼承和類繼承

發表於2016-04-06

JavaScript發展到今天,和其他語言不一樣的一個特點是,有各種各樣的“繼承方式”,或者稍微準確一點的說法,叫做有各種各樣的基於prototype的模擬類繼承實現方式。

在ES6之前,JavaScript沒有類繼承的概念,因此使用者為了程式碼複用的目的,只能參考其他語言的“繼承”,然後用prototype來模擬出對應的實現,於是有了各種繼承方式,比如《JavaScript高階程式設計》上說的 原型鏈,借用建構函式,組合繼承,原型式繼承,寄生式繼承,寄生組合式繼承 等等

那麼多繼承方式,讓第一次接觸這一塊的小夥伴們內心有點崩潰。然而,之所以有那麼多繼承方式,其實還是因為“模擬”二字,因為我們在說繼承的時候不是在研究prototype本身,而是在用prototype和JS特性來模擬別的語言的類繼承。

我們現在拋開這些種類繁多的繼承方式,來看一下prototype的本質和我們為什麼要模擬類繼承。

原型繼承

“原型” 這個詞本身源自心理學,指神話、宗教、夢境、幻想、文學中不斷重複出現的意象,它源自民族記憶和原始經驗的集體潛意識。

所以,原型是一種抽象,代表事物表象之下的聯絡,用簡單的話來說,就是原型描述事物與事物之間的相似性.

想象一個小孩子如何認知這個世界:

當小孩子沒見過老虎的時候,大人可能會教他,老虎啊,就像是一隻大貓。如果這個孩子碰巧常常和鄰居家的貓咪玩耍,那麼她不用去動物園見到真實的老虎,就能想象出老虎大概是長什麼樣子。

照貓畫虎

這個故事有個更簡單的表達,叫做“照貓畫虎”。如果我們用JavaScript的原型來描述它,就是:

很顯然,“照貓畫虎”(或者反過來“照虎畫貓”,也可以,取決孩子於先認識老虎還是先認識貓)是一種認知模式,它讓人類兒童不需要在腦海裡重新完全構建一隻老虎的全部資訊,而可以通過她熟悉的貓咪的“複用”得到老虎的大部分資訊,接下來她只需要去到動物園,去觀察老虎和貓咪的不同部分,就可以正確認知什麼是老虎了。這段話用JavaScript可以描述如下:

所以,原型可以通過描述兩個事物之間的相似關係來複用程式碼,我們可以把這種複用程式碼的模式稱為原型繼承。

類繼承

幾年之後,當時的小孩子長大了,隨著她的知識結構不斷豐富,她認識世界的方式也發生了一些變化,她學會了太多的動物,有喵喵叫的貓,百獸之王獅子,優雅的叢林之王老虎,還有豺狼、大象等等。

這時候,單純的相似性的認知方式已經很少被使用在如此豐富的知識內容裡,更加嚴謹的認知方式——分類,開始被更頻繁使用。

分類

這時候當年的小孩會說,貓和狗都是動物,如果她碰巧學習的是專業的生物學,她可能還會說貓和狗都是脊索門哺乳綱,於是,相似性被“類”這一種更高程度的抽象表達取代,我們用JavaScript來描述:

原型繼承和類繼承

所以,原型繼承和類繼承是兩種認知模式,本質上都是為了抽象(複用程式碼)。相對於類,原型更初級且更靈活。因此當一個系統內沒有太多關聯的事物的時候,用原型明顯比用類更靈活便捷。

原型繼承的便捷性表現在系統中物件較少的時候,原型繼承不需要構造額外的抽象類和介面就可以實現複用。(如系統裡只有貓和狗兩種動物的話,沒必要再為它們構造一個抽象的“動物類”)

原型繼承的靈活性還表現在複用模式更加靈活。由於原型和類的模式不一樣,所以對複用的判斷標準也就不一樣,例如把一個紅色皮球當做一個太陽的原型,當然是可以的(反過來也行),但顯然不能將“恆星類”當做太陽和紅球的公共父類(倒是可以用“球體”這個類作為它們的公共父類)。

既然原型本質上是一種認知模式可以用來複用程式碼,那我們為什麼還要模擬“類繼承”呢?在這裡面我們就得看看原型繼承有什麼問題——

原型繼承的問題

由於我們剛才前面舉例的貓和老虎的構造器沒有引數,因此大家很可能沒發現問題,現在我們試驗一個有引數構造器的原型繼承:

上面這段程式碼裡面我們看到我們用 Vector2D 的例項作為 Vector3D 的原型,在 Vector3D 的構造器裡面我們還可以呼叫 Vector2D 的構造器來初始化 x、y。

但是,如果認真研究上面的程式碼,會發現一個小問題,在中間描述原型繼承的時候:

我們其實無引數地呼叫了一次 Vector2D 的構造器!

這一次呼叫是不必要的,而且,因為我們的 Vector2D 的構造器足夠簡單並且沒有副作用,所以我們這次無謂的呼叫除了稍稍消耗了效能之外,並不會帶來太嚴重的問題。

但在實際專案中,我們有些元件的構造器比較複雜,或者操作DOM,那麼這種情況下無謂多呼叫一次構造器,顯然是有可能造成嚴重問題的。

於是,我們得想辦法克服這一次多餘的構造器呼叫,而顯然,我們發現我們可以不必要這一次多餘的呼叫:

上面的程式碼中,我們通過建立一個空的構造器T,引用父類Class的prototype,然後返回new T( ),來巧妙地避開Class構造器的執行。這樣,我們確實可以繞開父類構造器的呼叫,並將它的呼叫時機延遲到子類例項化的時候(本來也應該這樣才合理)。

這樣,我們解決了父類構造器延遲構造的問題之後,原型繼承就比較適用了,並且這樣簡單處理之後,使用起來還不會影響 instanceof 返回值的正確性,這是與其他模擬方式相比最大的好處。

模擬類繼承

最後,我們利用這個原理還可以實現比較完美的類繼承:

可以比較一下ES6的類繼承:

總結

原型繼承和類繼承是兩種不同的認知模式,原型繼承在物件不是很多的簡單應用模型裡比類繼承更加靈活方便。然而JavaScript的原型繼承在語法上有一個構造器額外呼叫的問題,我們只要通過 createObjWithoutConstructor 來延遲構造器的呼叫,就能解決這個問題。

相關文章