JavaScript發展到今天,和其他語言不一樣的一個特點是,有各種各樣的“繼承方式”,或者稍微準確一點的說法,叫做有各種各樣的基於prototype的模擬類繼承實現方式。
在ES6之前,JavaScript沒有類繼承的概念,因此使用者為了程式碼複用的目的,只能參考其他語言的“繼承”,然後用prototype來模擬出對應的實現,於是有了各種繼承方式,比如《JavaScript高階程式設計》上說的 原型鏈,借用建構函式,組合繼承,原型式繼承,寄生式繼承,寄生組合式繼承 等等
那麼多繼承方式,讓第一次接觸這一塊的小夥伴們內心有點崩潰。然而,之所以有那麼多繼承方式,其實還是因為“模擬”二字,因為我們在說繼承的時候不是在研究prototype本身,而是在用prototype和JS特性來模擬別的語言的類繼承。
我們現在拋開這些種類繁多的繼承方式,來看一下prototype的本質和我們為什麼要模擬類繼承。
原型繼承
“原型” 這個詞本身源自心理學,指神話、宗教、夢境、幻想、文學中不斷重複出現的意象,它源自民族記憶和原始經驗的集體潛意識。
所以,原型是一種抽象,代表事物表象之下的聯絡,用簡單的話來說,就是原型描述事物與事物之間的相似性.
想象一個小孩子如何認知這個世界:
當小孩子沒見過老虎的時候,大人可能會教他,老虎啊,就像是一隻大貓。如果這個孩子碰巧常常和鄰居家的貓咪玩耍,那麼她不用去動物園見到真實的老虎,就能想象出老虎大概是長什麼樣子。
這個故事有個更簡單的表達,叫做“照貓畫虎”。如果我們用JavaScript的原型來描述它,就是:
1 2 3 4 5 |
function Tiger(){ //... } Tiger.prototype = new Cat(); //老虎的原型是一隻貓 |
很顯然,“照貓畫虎”(或者反過來“照虎畫貓”,也可以,取決孩子於先認識老虎還是先認識貓)是一種認知模式,它讓人類兒童不需要在腦海裡重新完全構建一隻老虎的全部資訊,而可以通過她熟悉的貓咪的“複用”得到老虎的大部分資訊,接下來她只需要去到動物園,去觀察老虎和貓咪的不同部分,就可以正確認知什麼是老虎了。這段話用JavaScript可以描述如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function Cat(){ } //小貓喵喵叫 Cat.prototype.say = function(){ return "喵"; } //小貓會爬樹 Cat.prototype.climb = function(){ return "我會爬樹"; } function Tiger(){ } Tiger.prototype = new Cat(); //老虎的叫聲和小貓不同,但老虎也會爬樹 Tiger.prototype.say = function(){ return "嗷"; } |
所以,原型可以通過描述兩個事物之間的相似關係來複用程式碼,我們可以把這種複用程式碼的模式稱為原型繼承。
類繼承
幾年之後,當時的小孩子長大了,隨著她的知識結構不斷豐富,她認識世界的方式也發生了一些變化,她學會了太多的動物,有喵喵叫的貓,百獸之王獅子,優雅的叢林之王老虎,還有豺狼、大象等等。
這時候,單純的相似性的認知方式已經很少被使用在如此豐富的知識內容裡,更加嚴謹的認知方式——分類,開始被更頻繁使用。
這時候當年的小孩會說,貓和狗都是動物,如果她碰巧學習的是專業的生物學,她可能還會說貓和狗都是脊索門哺乳綱,於是,相似性被“類”這一種更高程度的抽象表達取代,我們用JavaScript來描述:
1 2 3 4 5 6 7 8 9 10 11 12 |
class Animal{ eat(){} say(){} climb(){} ... } class Cat extends Animal{ say(){return "喵"} } class Dog extends Animal{ say(){return "汪"} } |
原型繼承和類繼承
所以,原型繼承和類繼承是兩種認知模式,本質上都是為了抽象(複用程式碼)。相對於類,原型更初級且更靈活。因此當一個系統內沒有太多關聯的事物的時候,用原型明顯比用類更靈活便捷。
原型繼承的便捷性表現在系統中物件較少的時候,原型繼承不需要構造額外的抽象類和介面就可以實現複用。(如系統裡只有貓和狗兩種動物的話,沒必要再為它們構造一個抽象的“動物類”)
原型繼承的靈活性還表現在複用模式更加靈活。由於原型和類的模式不一樣,所以對複用的判斷標準也就不一樣,例如把一個紅色皮球當做一個太陽的原型,當然是可以的(反過來也行),但顯然不能將“恆星類”當做太陽和紅球的公共父類(倒是可以用“球體”這個類作為它們的公共父類)。
既然原型本質上是一種認知模式可以用來複用程式碼,那我們為什麼還要模擬“類繼承”呢?在這裡面我們就得看看原型繼承有什麼問題——
原型繼承的問題
由於我們剛才前面舉例的貓和老虎的構造器沒有引數,因此大家很可能沒發現問題,現在我們試驗一個有引數構造器的原型繼承:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function Vector2D(x, y){ this.x = x; this.y = y; } Vector2D.prototype.length = function(){ return Math.sqrt(this.x * this.x + this.y * this.y); } function Vector3D(x, y, z){ Vector2D.call(this, x, y); this.z = z; } Vector3D.prototype = new Vector2D(); Vector3D.prototype.length = function(){ return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); } var p = new Vector3D(1, 2, 3); console.log(p.x, p.y, p.z, p.length(), p instanceof Vector2D); |
上面這段程式碼裡面我們看到我們用 Vector2D 的例項作為 Vector3D 的原型,在 Vector3D 的構造器裡面我們還可以呼叫 Vector2D 的構造器來初始化 x、y。
但是,如果認真研究上面的程式碼,會發現一個小問題,在中間描述原型繼承的時候:
1 |
Vector3D.prototype = new Vector2D(); |
我們其實無引數地呼叫了一次 Vector2D 的構造器!
這一次呼叫是不必要的,而且,因為我們的 Vector2D 的構造器足夠簡單並且沒有副作用,所以我們這次無謂的呼叫除了稍稍消耗了效能之外,並不會帶來太嚴重的問題。
但在實際專案中,我們有些元件的構造器比較複雜,或者操作DOM,那麼這種情況下無謂多呼叫一次構造器,顯然是有可能造成嚴重問題的。
於是,我們得想辦法克服這一次多餘的構造器呼叫,而顯然,我們發現我們可以不必要這一次多餘的呼叫:
1 2 3 4 5 |
function createObjWithoutConstructor(Class){ function T(){}; T.prototype = Class.prototype; return new T(); } |
上面的程式碼中,我們通過建立一個空的構造器T,引用父類Class的prototype,然後返回new T( ),來巧妙地避開Class構造器的執行。這樣,我們確實可以繞開父類構造器的呼叫,並將它的呼叫時機延遲到子類例項化的時候(本來也應該這樣才合理)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function Vector2D(x, y){ this.x = x; this.y = y; } Vector2D.prototype.length = function(){ return Math.sqrt(this.x * this.x + this.y * this.y); } function Vector3D(x, y, z){ Vector2D.call(this, x, y); this.z = z; } Vector3D.prototype = createObjWithoutConstructor(Vector2D); Vector3D.prototype.length = function(){ return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); } var p = new Vector3D(1, 2, 3); console.log(p.x, p.y, p.z, p.length(), p instanceof Vector2D); |
這樣,我們解決了父類構造器延遲構造的問題之後,原型繼承就比較適用了,並且這樣簡單處理之後,使用起來還不會影響 instanceof 返回值的正確性,這是與其他模擬方式相比最大的好處。
模擬類繼承
最後,我們利用這個原理還可以實現比較完美的類繼承:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
(function(global){"use strict" Function.prototype.extend = function(props){ var Super = this; //父類建構函式 //父類原型 var TmpCls = function(){ } TmpCls.prototype = Super.prototype; var superProto = new TmpCls(); //父類構造器wrapper var _super = function(){ return Super.apply(this, arguments); } var Cls = function(){ if(props.constructor){ //執行建構函式 props.constructor.apply(this, arguments); } //繫結 this._super 的方法 for(var i in Super.prototype){ _super[i] = Super.prototype[i].bind(this); } } Cls.prototype = superProto; Cls.prototype._super = _super; //複製屬性 for(var i in props){ if(i !== "constructor"){ Cls.prototype[i] = props[i]; } } return Cls; } function Animal(name){ this.name = name; } Animal.prototype.sayName = function(){ console.log("My name is "+this.name); } var Programmer = Animal.extend({ constructor: function(name){ this._super(name); }, sayName: function(){ this._super.sayName(name); }, program: function(){ console.log("I\"m coding..."); } }); //測試我們的類 var animal = new Animal("dummy"), akira = new Programmer("akira"); animal.sayName();//輸出 ‘My name is dummy’ akira.sayName();//輸出 ‘My name is akira’ akira.program();//輸出 ‘I"m coding...’ })(this); |
可以比較一下ES6的類繼承:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
(function(global){"use strict" //類的定義 class Animal { //ES6中新型構造器 constructor(name) { this.name = name; } //例項方法 sayName() { console.log("My name is "+this.name); } } //類的繼承 class Programmer extends Animal { constructor(name) { //直接呼叫父類構造器進行初始化 super(name); } sayName(){ super.sayName(); } program() { console.log("I\"m coding..."); } } //測試我們的類 var animal = new Animal("dummy"), akira = new Programmer("akira"); animal.sayName();//輸出 ‘My name is dummy’ akira.sayName();//輸出 ‘My name is akira’ akira.program();//輸出 ‘I"m coding...’ })(this); |
總結
原型繼承和類繼承是兩種不同的認知模式,原型繼承在物件不是很多的簡單應用模型裡比類繼承更加靈活方便。然而JavaScript的原型繼承在語法上有一個構造器額外呼叫的問題,我們只要通過 createObjWithoutConstructor 來延遲構造器的呼叫,就能解決這個問題。