什麼是繼承?
大多數人使用繼承不外乎是爲了獲得這兩點好處,代碼的抽象和代碼的複用。
代碼的抽象就不用說了,交通工具和汽車這類的例子數不勝數,在傳統的OO語言中(比如Java),代碼的抽象更多的是使用接口(interface)來實現,而使用繼承更多地是爲了代碼的複用(雖然現在強調使用組合而不是使用繼承)。
怎麼複用的?打個比方,class A 繼承了 class B,class A便擁有了class B 的public 和 protected類型的變量和方法,用最簡單的方法去想,便是 class B 將 這些屬性和方法直接copy給class A,這樣便實現了繼承。
因此我們可以這樣說,繼承實際上是一種類與類之間的copy行爲。
JavaScript中的繼承
在JavaScript中沒有類的概念,只有對象。雖然現在人們經常使用class關鍵字,這讓JavaScript看起來似乎是擁有了”類”,可表面看到的不一定是本質,class只是一塊糖,嚼碎了才知道里面其實還是原型鏈那一套。因此,JavaScript中的繼承只是對象與對象之間的繼承。反觀繼承的本質,繼承便是讓子類擁有父類的一些屬性和方法,那麼在JavaScript中便是讓一個對象擁有另一個對象的屬性和方法。
所以,這給我了我們一條十分清晰的思路,JavaScript中如何實現繼承?只需讓一個對象擁有另一個對象的屬性和方法,這就實現了。
利用Mixin
既然讓一個對象擁有另一個對象的屬性和方法,首先想到的便是利用Mixin的粗暴方式,直接將對象的屬性和方法強制copy到另一個對象。
就像這樣
function mixin(subObj, parentObj) {
for (var prop in parentObj) {
if (!(prop in subObj)) {
subObj[prop] = parentObj[prop]
}
}
}
當然也可以用ES6中的更優雅的Object.assign。
這段代碼就實現了最簡單的從一個對象複製屬性和方法到另一個對象。然而這種方法有一個缺陷,如果父對象的屬性是引用類型,比如一個對象或者數組,那麼修改子對象的時候勢必會對父對象也造成修改,這顯然不可接受。一種想法是採用深度克隆,然而又可能會有循環引用的問題。
所以,這種繼承方式,比較適合對簡單對象的拓展,不太適合更復雜的繼承。
利用原型鏈
首先來說一下什麼是原型,原型在JavaScript中,其實就是某個對象的一個屬性。只不過這個屬性很特殊,對於外界一般是不可見(在chrome中可以通過__proto__獲取),我們一般把它叫作[[Prototype]]。這裏和函數的prototype屬性很相似但卻是兩個東西,後面會提到。
那麼什麼是原型鏈呢,顧名思義就像這樣:
obj1.[[Prototype]] ===> obj2.[[Prototype]] ===> obj3.[[Prototype]]…. ===> Object.prototype
某一對象的原型屬性中保存着另一個對象,以此類推,好像鏈子一樣串起來。
鏈的終點是Object.prototype對象,因此Object.prototype沒有原型。當我們構建一個對象,這個對象的默認的原型就是Object.prototype
在chrome中驗證一下:
var a = {}
Object.prototype === a.__proto__ // true
那麼我們如何用原型鏈實現繼承呢?這要歸功於JavaScript中的委託機制。
當我們獲取一個對象的某個屬性時,比如a.b,會默認調用一個內置的[[Get]]方法,這個[[Get]]方法的算法就是:
在當前對象裏查找,找不到則委託給當前對象的[[Prototype]],再找不到則委託給[[Prototype]]的[[Prototype]],直到Object.prototype中也沒找到,則返回undefined。
因此,我們想讓對象a擁有對象b的屬性和方法,即對象a繼承對象b,只需要把b賦值給a的[[Prototype]],利用屬性查找的委託機制,實現了a也”擁有”了b的屬性和方法,而且當a中有和b中的同名屬性時,由於”屏蔽作用”,只有a中的屬性會被優先獲取到,實現了override,看起來相當完美。
new 和 “構造函數”
前面提到,[[Prototype]]是個內置隱藏屬性,雖然在chrome可以通過__proto__訪問,但是其設計本意是不可被讀取和修改的,那麼我們如何利用原型鏈來建立繼承關係?
JavaScript提供了new關鍵字。
通常,在類似Java這樣的OO語言中,new被用來實例化一個類,然而在JavaScript中,new僅僅是一個函數調用的方式!
JavaScript中的函數也很奇怪,每一個函數都有一個默認的prototype屬性,這個不同於對象的[[Prototype]]屬性,函數的prototype是故意暴露出來的,而且這個屬性還不爲空,還有prototype還有另一個屬性叫constructor,這個constructor竟然又引用回來了這個函數本身!於是我們看到的效果是這樣的:
用new來調用函數有什麼不同的呢?new其實做了三件事:
- 創建一個新對象
- 將這個新對象的[[Prototype]]連接到調用函數的prototype上
- 綁定調用函數的this並調用
用代碼來表示就是:
function New(fn) {
var tmp = {}
tmp.__proto__ = fn.prototype
fn.call(tmp)
return tmp
}
可以看到,new幫我們把對象的[[Prototype]]連接到了函數的prototype上。
到這兒,思路就清晰了,怎麼讓對象a和對象b的[[Prototype]]相連實現a繼承b?
只需把a的”構造函數”的[[Prototype]]連接到b就行了。
來實現一下:
function A() {
}
var b = {
show: function() {
console.log('這是來自b的方法')
}
}
A.prototype = b
// 這裏修復了原先的 constructor
A.prototype.constructor = A
var a = new A()
a.show() // 這是來自b的方法
更簡單的Object.create
ES5中提供的Object.create更簡單粗暴,可以直接創建一個對象並將這個對象的[[Prototype]]指向傳入的對象
var b = {c: 1}
var a = Object.create(b)
console.log(a.c) // 1
模擬類繼承
在JavaScript中沒有類的概念,雖然從ES6開始擁有了class關鍵字,但其背後仍然是原型鏈作支撐,所以這裏還是用最本質的原型來模擬”類”的繼承。這纔是JavaScript的本來面目!
/**
* 實現 A 繼承 B
*/
function B(b) {
this.b = b
}
function A(a, b) {
// 調用B並綁定this
B.call(this, b)
this.a = a
}
A.prototype = Object.assign({}, B.prototype)
A.prototype.constructor = A
var c = new A(1, 2)
console.log(c.a) // 1
// c 擁有了只有B的實例才擁有的 b 屬性
console.log(c.b) // 2
總結
簡單來說,繼承即是copy和複用,JavaScript的繼承其實就是利用原型鏈的查找和委託來實現屬性和方法的複用,new關鍵字和”構造函數”只是連接原型鏈的工具,這樣的工具還有Object.create。