作者:名一的部落格
網址:http://segmentfault.com/a/1190000002989513
點選“閱讀原文”可檢視本文網頁版
對於 OO 語言,有一句話叫“Everything is object”,雖然 JavaScript 不是嚴格意義上的面向物件語言,但如果想要理解 JS 中的繼承,這句話必須時刻銘記於心。
JS 的語法非常靈活,所以有人覺得它簡單,因為怎麼寫都是對的;也有人覺得它難,因為很難解釋某些語法的設計,誰能告訴我為什麼 typeof null 是 object 而 typeof undefined 是 undefined 嗎?並且這是在 null == undefined 的前提下。很多我們自認為“懂”了的知識點,細細琢磨起來,還是會發現有很多盲點,“無畏源於無知”吧……
1. 簡單物件
既然是講繼承,自然是從最簡單的物件說起:
var dog = {
name: ‘tom’
}
這便是物件直接量了。每一個物件直接量都是 Object 的子類,即
dog instanceof Object; // true
2. 建構式
JS 中的建構式與普通函式並沒有什麼兩樣,只不過在呼叫時,前面加上了 new 關鍵字,就當成是建構式了。
function Dog(name) {
this.name = name;
}
var dog = new Dog(‘tom’);
dog instanceof Dog; // true
兩個問題,第一,不加 new 關鍵字有什麼後果?
那麼 Dog 函式中的 this 在背景關係(Context)中被解釋為全域性變數,具體在瀏覽器端的話是 window 物件,在 node 環境下是一個 global 物件。
第二,dog 的值是什麼?很簡單,undefined 。Dog 函式沒有傳回任何值,執行結束後,dog 的值自然是 undefined 。
關於 new 的過程,這裡也順便介紹一下,這個對後面理解原型(prototype)有很大的幫助:
- 建立一個空的物件,僅包含 Object 的屬性和方法。
- 將 prototype 中的屬性和方法建立一份取用,賦給新物件。
- 將 this 上的屬性和方法新建一份,賦給新物件。
- 傳回 this 物件,忽略 return 陳述句。
需要明確的是,prototype 上的屬性和方法是實體間共享的,this 上的屬性和方法是每個實體獨有的。
3. 引入 prototype
現在為 Dog 函式加上 prototype,看一個例子:
function Dog(name) {
this.name = name;
this.bark = function() {};
}
Dog.prototype.jump = function() {};
Dog.prototype.species = ‘Labrador’;
Dog.prototype.teeth = [‘1’, ‘2’, ‘3’, ‘4’];
var dog1 = new Dog(‘tom’),
dog2 = new Dog(‘jerry’);
dog1.bark !== dog2.bark; // true
dog1.jump === dog2.jump; // true
dog1.teeth.push(‘5’);
dog2.teeth; // [‘1’, ‘2’, ‘3’, ‘4’, ‘5’]
看到有註釋的那三行應該可以明白“取用”和“新建”的區別了。
那麼我們經常說到的“原型鏈”到底是什麼呢?這個術語出現在繼承當中,它用於表示物件實體中的屬性和方法來自於何處(哪個父類)。好吧,這是筆者的解釋。
– Object
bark: Dog/this.bark()
name: ‘tom’
– __proto__: Object
jump: Dog.prototype.jump()
species: ‘Labrador’
+ teeth: Array[4]
+ constructor: Dog()
+ __proto__: Object
上面的是 dog1 的原型鏈,不知道夠不夠直觀地描述“鏈”這個概念。
其中,bark 和 name 是定義在 this 中的,所以最頂層可以看到它倆。
然後,每一個物件都會有一個 __proto__ 屬性(IE 11+),它表示定義在原型上的屬性和方法,所以 jump、species 和 teeth 自然就在這兒了。
最後就一直向上找 __proto__ 中的屬性和方法。
4. 繼承的幾種實現
4.1 透過 call 或者 apply
繼承在程式設計中有兩種說法,一個叫 inherit,另一個是 extend 。前者是嚴格意義上的繼承,即存在父子關係,而後者僅僅是一個類擴充套件了另一個類的屬性和方法。那麼 call 和 apply 就屬於後者的範疇。怎麼說?
function Animal(gender) {
this.gender = gender;
}
function Dog(name, gender) {
Animal.call(this, gender);
this.name = name;
}
var dog = new Dog(‘tom’, ‘male’);
dog instanceof Animal; // false
雖然在 dog 物件中有 gender 屬性,但 dog 卻不是 Animal 型別。甚至,這種方式只能“繼承”父類在 this 上定義的屬性和方法,並不能繼承 Animal.prototype 中的屬性和方法。
4.2 透過 prototype 實現繼承
要實現繼承,必須包含“原型”的概念。下麵是很常用的繼承方式。
function Dog(name) {
Animal.call(this);
}
Dog.prototype = new Animal(); // 先假設 Animal 函式沒有引數
Dog.prototype.constructor = Dog;
var dog = new Dog(‘tom’);
dog instanceof Animal; // true
繼承的結果有兩個:一、獲得父類的屬性和方法;二、正確透過 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 實體中冗餘的屬性和方法。
function Animal(name) {
name.doSomething();
}
function Dog(name) {
Animal.call(this, name);
}
Dog.prototype = new Animal(); // 由於沒有傳入name變數,在呼叫Animal的建構式時,會出錯
Dog.prototype.constructor = Dog;
這個問題可以透過一個空物件來解決(改自 Douglas Crockford)。
function DummyAnimal() {}
DummyAnimal.prototype = Animal.prototype;
Dog.prototype = new DummyAnimal();
Dog.prototype.constructor = Dog;
他的原始方法是下麵的 object:
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
Dog.prototype = object(Animal.prototype);
Dog.prototype.constructor = Dog;
4.4 利用 __proto__ 實現繼承
現在就只剩下一個問題了,如何把冗餘屬性和方法去掉?
其實,從第 3 小節介紹原型的時候就提到了 __proto__ 屬性,instanceof 運運算元是透過它來判斷是否屬於某個型別的。
所以我們可以這麼繼承:
function Dog() {
Animal.call(this);
}
Dog.prototype = {
__proto__: Animal.prototype,
constructor: Dog
};
如果不考慮相容性的話,這應該是從 OO 的角度來看最貼切的繼承方式了。
4.5 複製繼承
這個方式也只能稱之為 extend 而不是 inherit,所以也沒必要展開說。
像 Backbone.Model.extend、jQuery.extend 或者 _.extend 都是複製繼承,可以稍微看一下它們是怎麼實現的。(或者等我自己再好好研究之後過來把這部分補上吧)
5. 個人小結
當我們在討論繼承的實現方式時,給我的感覺就像孔乙己在炫耀“茴香豆”的“茴”有幾種寫法一樣。繼承是 JS 中佔比很大的一塊內容,所以很多庫都有自己的實現方式,它們並沒有使用我認為的“最貼切”的方法,為什麼?JS 就是 JS,它生來就設計得非常靈活,所以我們為什麼不利用這個特性,而非得將 OO 的做法強加於它呢?
透過繼承,我們更多的是希望獲得父類的屬性和方法,至於是否要保證嚴格的父類/子類關係,很多時候並不在乎,而複製繼承最能體現這一點。對於基於原型的繼承,會在程式碼中看到各種用 function 定義的型別,而複製繼承更通用,它只是將一個物件的屬性和方法複製(擴充套件)到另一個物件而已,並不關心原型鏈是什麼。
當然,在我鼓吹複製繼承多麼多麼好時,基於原型的繼承自然有它不可取代的理由。所以具體問題得具體分析,當具體的使用場景沒定下來時,就不存在最好的方法。
個人見解,能幫助大家更加理解繼承一點就最好,如果有什麼不對的,請多多指教!