我們在對象創建模式中討論過,對象創建的模式就是定義對象模板的方式。有了模板以后,我們就可以輕松地創建多個結構相同的對象了。
繼承就是對象創建模式的擴展,我們需要在舊模板的基礎上,添加新的特性,使之成為一個新的模板。
為了更加迎合程序員的“直覺”,本篇文章有時使用“父類”來指代“父對象的模板”。但是讀者應該明確:JavaScript沒有類系統。
1. 純原型鏈繼承
純原型鏈繼承的思想就是讓所有需要繼承的屬性都在原型鏈上(而不會在實例對象自己身上)。
原型鏈是實現繼承的重要工具,以下是單純使用原型鏈實現繼承的方式:
function SuperType() {
this.property = 1;
}
// 將方法放在原型上,以便所有實例共用,避免重復定義
SuperType.prototype.getSuperValue = function() {
return this.property;
};
function SubType() {
this.subproperty = 2;
}
SubType.prototype = new SuperType();
// 將方法放在原型上,以便所有實例共用,避免重復定義
SubType.prototype.getSubValue = function() {
return this.subproperty;
};
var instance = new SubType();
console.log(instance.getSuperValue()); // 1
console.log(instance instanceof SubType); // true
console.log(instance instanceof SuperType); // true
雖然上面使用了構造函數與new關鍵字,但是在純原型鏈繼承中,繼承屬性的原理與它們一點關系也沒有,父類的所有屬性都在原型鏈上。使用構造函數只是為了代碼簡潔,同時還可以讓我們使用instanceof
進行對象識別(詳見上一篇文章)。
實際上,我們可以不使用構造函數與new關鍵字來實現純原型鏈繼承:
// 定義父對象的原型
var superPrototype = {
getSuperValue: function() {
return this.property;
}
};
// 定義父對象的工廠方法
function createSuper() {
var superInstance = Object.create(superPrototype);
superInstance.property = 1;
return superInstance;
}
// 定義子對象的原型
var subPrototype = createSuper();
subPrototype.getSubValue = function() {
return this.subproperty;
};
// 定義子對象的工廠方法
function createSub() {
var subInstance = Object.create(subPrototype);
subInstance.subproperty = 2;
return subInstance;
}
// 創建子對象
var instance = createSub();
console.log(instance.getSuperValue()); // 1
console.log(instance.getSubValue()); // 2
通過這種方式實現純原型鏈繼承的作用完全相同,原型鏈也完全相同,只不過不能使用instanceof和constructor指針了(因為它們需要構造函數)。
所有需要繼承的屬性都在原型鏈上(而不會在實例對象自己身上),這是純原型鏈繼承與后面繼承方式的最大不同。
純原型鏈繼承的缺陷
- 在使用構造函數的實現方式中,我們無法給父類的構造函數傳遞參數。也就是上例中的這條語句:
SubType.prototype = new SuperType();
,這句話是在創建子對象之前就執行的,所以我們無法在實例化的時候決定父類構造函數的參數。 - 如果不選擇構造函數的那種實現方式,會出現與創建對象模式相同的對象識別問題。
- 原型鏈的改變會影響所有已經構造出的實例。這是一種靈活性,也是一種危險。如果我們不小心通過實例對象改變了原型鏈上的屬性,會影響所有的實例對象。
- 父類可能有一些屬性不適合共享,但是純原型鏈繼承強迫所有父類屬性都要共享。比如People是Student的父類,People的name屬性顯然是每個子對象都應該獨享的。如果使用純原型鏈繼承,我們必須在每次得到子類對象以后手動給子對象添加name屬性。
2. 純構造函數繼承
這種技術通過在子類構造函數中調用父類的構造函數,使所有需要繼承的屬性都定義在實例對象上。
function SuperType(fatherName) {
this.fatherName = fatherName;
this.fatherArray = ["red", "blue", "green"];
}
function SubType(childName, fatherName) {
SuperType.call(this, fatherName);
this.childName = childName;
}
var child1 = new SubType('childName1', 'fatherName1');
var child2 = new SubType('childName2', 'fatherName2');
child1.fatherArray.push("black");
console.log(child1.fatherArray); //"red,blue,green,black"
console.log(child2.fatherArray); //"red,blue,green"
由上例可知,純構造函數繼承不會出現純原型鏈繼承的問題:
- 不存在“原型鏈的改變影響所有已經構造出的實例”的問題。這是因為不管是子類的屬性還是父類的屬性,在子對象上都有一個副本,因此改變一個對象的屬性不會影響另一個對象。
- 可以在構造子對象實例的時候給父類構造函數傳遞參數。在純構造函數繼承中,父類構造函數是在子類構造函數中調用的,每次調用時,傳遞給父構造函數的參數可以通過子構造函數的參數控制。
純構造函數繼承的缺陷
- 與創建對象的構造函數模式相同,低效率,函數重復定義,無法復用。
- 如果子類要使用這種繼承方式,父類必須也要使用這種繼承方式。因為使用這種方式的前提就是所有需要繼承的屬性都在父構造函數上。如果父類有一些屬性是通過原型鏈繼承來的,那么子類僅僅通過調用父構造函數無法得到這些屬性。
- 對象識別功能不全。無法使用 instanceof 來判斷某個對象是否繼承自某個父類。但是還是有基本的對象識別功能:可以使用 instanceof 來判斷某個對象是不是某個類的實例。
3. 組合繼承
組合繼承就是同時使用原型鏈和構造函數繼承:
- 對于那些適合共享的屬性(一般是函數),將它們放在原型鏈上。
- 對于需要每個子對象獨享的屬性,在構造函數中定義。
// 定義父類
function SuperType(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
};
// 定義子類
function SubType(name, age) {
// 這里調用了父對象的構造函數(構造函數繼承)
SuperType.call(this, name);
this.age = age;
}
// 這里創建了一個父對象來當作原型(原型鏈繼承)
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
console.log(this.age);
};
var instance1 = new SubType("Nicholas", 29);
var instance2 = new SubType("Greg", 27);
instance1.colors.push("black");
console.log(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29
console.log(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27
使用原型鏈的時候,我們要作出合理的選擇:
- 哪些屬性是每個實例對象獨享的,需要在每次實例化的時候添加到實例對象上。
- 哪些屬性是所有實例共享的,只需要定義在原型對象上。這可以減少資源的浪費。
組合繼承的代碼與純原型鏈繼承的代碼看起來很相似,它們的核心區別在于:組合繼承要在子類構造函數中調用父類構造函數。
組合繼承避免了兩者的缺陷,結合了兩者的優點,并且可以使用instanceof進行對象識別,因此組合繼承在JavaScript中經常被使用。
組合繼承的缺陷
- 得到一個子對象要執行兩次父構造函數,重復定義屬性。在組合繼承中,我們要執行兩次構造函數:
- 在定義原型鏈的時候創建原型對象
- 在子類的構造函數中調用父構造函數來初始化新對象的屬性。
調用兩次構造函數的結果就是重復定義了父類的屬性,第一次定義在原型鏈上,第二次定義在子對象上。
如果你在Chrome控制臺中打印上面例子的instance2,你會發現,在原型對象和子對象上都有"name"和"colors"屬性。
這樣的重復定義顯然不夠優雅,后面的寄生組合式繼承解決了這個問題。
4. 原型式繼承
原型式繼承的核心是以父對象為原型直接創建子對象。
原型式繼承(Prototypal Inheritance)是由Douglas Crockford在他的一篇文章Prototypal Inheritance in JavaScript中提出的。js是一種基于原型的語言,然而Douglas卻發現,當時沒有操作符能夠方便地以一個對象為原型,創建一個新對象。js的原型的天性被構造函數給掩蓋了,因為構造函數被很多人當作“類”來使用。因此Douglas提出一個簡單的函數,以父對象為原型直接創建子對象,沒有類、沒有構造函數、沒有new操作符,只有對象繼承對象,回歸js的本質:
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
在原型式繼承被提出的時候,Object.create方法還沒有引入。ES5引入的Object.create()方法實際就是object函數的規范化。
有了object函數以后,為了能夠方便地創建對象,你可能需要使用工廠模式,來為得到的子對象增加屬性:
// 父對象
var superObject = {
superValue:1,
showSuperValue: function() {
console.log(this.superValue);
}
};
// 子對象的工廠方法
function createSub() {
var instance = Object.create(superObject);
instance.subValue = 2;
return instance;
}
var subObject = createSub();
subObject.showSuperValue(); // 1
我們有時不一定要大量制造子對象,此時就沒必要定義一個工廠函數了,直接使用Object.create()就好。原型式繼承不一定要定義一個工廠函數,只要以父對象為原型直接創建子對象,就是一種原型式繼承。
原型式繼承是一種純原型鏈繼承,因為原型式繼承也將所有要繼承的屬性放在了原型鏈上,我們在純原型鏈繼承中的第二種實現方式,實際上也是原型式繼承。
如果要給子對象添加函數,不要在工廠函數中定義它,而要將函數放在原型對象上,避免低效率代碼。我們在純原型鏈繼承中的第二種實現方式就是一個值得學習的例子。
5. 寄生式繼承
寄生式繼承的思想是增強父對象,得到子對象。新特性“寄生”在父對象上。
寄生式繼承使用工廠函數封裝了以下步驟:
- 使用父對象的創建方法(工廠函數或構造函數),創建父對象
- 增強父對象(給它增加屬性)
- 返回這個對象,它就是子對象
function Super() {
this.superProperty = 1;
}
Super.prototype.sayHi = function() {
console.log('hi');
};
function subFactory() {
var instance = new Super();
instance.subProperty = 2;
instance.sayGoodBye = function() {
console.log('goodbye');
};
return instance;
}
var sub = subFactory();
繼承得到的屬性可能在子對象上,也可能在子對象的原型上,這取決于父對象的屬性在不在原型鏈上。
寄生式繼承的缺陷
- 如果在工廠函數中為對象添加函數(如上面的例子),那么會出現與純構造函數繼承一樣的低效率問題,函數重復定義。
- 不能使用進行對象識別,因為子對象并不是通過構造函數創建的。
6. 寄生組合式繼承
我們前面說過,雖然組合繼承很常用,但是它也有自己的不足:調用兩次父構造函數,重復定義父屬性。第一次是在定義原型鏈的時候創建原型對象,第二次是在子類的構造函數中調用父構造函數來初始化新對象的屬性。以下是我們給出過的組合式繼承:
// 定義父類
function SuperType(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
};
// 定義子類
function SubType(name, age) {
// 第二次調用父構造函數
SuperType.call(this, name);
this.age = age;
}
// 第一次調用父構造函數
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
console.log(this.age);
};
var instance1 = new SubType("Nicholas", 29);
var instance2 = new SubType("Greg", 27);
// instance的本身和原型對象上都有“name”和“colors”屬性
instance1.colors.push("black");
console.log(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29
console.log(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27
我們顯然應該舍棄其中的一次。
可以舍棄第二次調用嗎?不行,那樣就變成純原型鏈繼承了,你可以回去看看這種繼承的缺陷。
既然如此,我們只能舍棄第一次調用了。第一次調用構造函數只是為了獲得父對象原型鏈上的屬性,父對象實例上的屬性交給第二次調用來添加了。實際上我們不需要創建一個父對象也可以獲得父對象的原型鏈:
也就是說,我們可以把
SubType.prototype = new SuperType();
改成
SubType.prototype = Object.create(SuperType.prototype);
創建上圖中的藍色空對象,并將它作為子類的原型。子對象一樣可以繼承到父對象原型鏈上的所有屬性!
因此,寄生組合式繼承的代碼就是這樣:
// 定義父類
function SuperType(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
};
// 定義子類
function SubType(name, age) {
// 獲得父對象實例上的屬性
SuperType.call(this, name);
this.age = age;
}
// 獲得父對象原型鏈上的屬性
SubType.prototype = Object.create(SuperType.prototype);
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
console.log(this.age);
};
var instance1 = new SubType("Nicholas", 29);
var instance2 = new SubType("Greg", 27);
instance1.colors.push("black");
console.log(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29
console.log(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27
現在我們在Chrome控制臺查看instance2的原型,發現原型鏈上已經沒有“name”和“colors”屬性了。寄生組合式繼承十分完美!
為什么不直接
SubType.prototype = SuperType.prototype;
呢?因為我們后面還要在SubType.prototype上添加子對象的共享函數,如果使用Object.create創建一個新對象,會改變父對象的原型鏈!
寄生組合式繼承真的與“寄生式繼承”有關嗎?對于這一點我持懷疑態度。按照《JavaScript高級程序設計(第3版)》的說法,因為繼承的過程可以封裝成以下函數:
function inheritPrototype(subType, superType) {
var prototype = Object.create(superType.prototype); // 創建對象
prototype.constructor = subType; // 增強對象
subType.prototype = prototype; // 指定對象
}
而作者認為這個函數是一種“寄生式繼承”:prototype“寄生式繼承”自superType.prototype。
我認為這是一種原型式繼承。不能因為在工廠函數中獲取對象、增強對象,就認為它是寄生式繼承。原型式繼承一樣可以用工廠函數來封裝。原型式繼承與寄生式繼承的區別在于獲取對象的方式:
- 通過object函數或Object.create,以父對象為原型直接創建一個空對象。這是原型式繼承。
- 通過父類的構造函數或工廠函數獲得對象。這是寄生式繼承。
在Douglas Crockford的文章Prototypal Inheritance in JavaScript中,在介紹原型式繼承的時候他說到:
"For convenience, we can create functions which will call the object function for us, and provide other customizations such as augmenting the new objects with privileged functions. I sometimes call these maker functions. If we have a maker function that calls another maker function instead of calling the object function, then we have a parasitic inheritance pattern."
原型式繼承一樣可以使用工廠函數。當調用另一個工廠函數而不是object函數的時候,原型式繼承才變成寄生式繼承。
名字叫什么并不重要,能夠將這些模式中的編程思想融會貫通就夠了。將來我們不一定有機會寫“形式完全規范”的某種模式,但是其中的思想肯定會在一些零零散散的地方用上。