JavaScript面向物件(4)——最佳繼承模式(從深拷貝、多重繼承、構造器借用,到組合寄生式繼承)
很多同學甚至在相當長的時間裡,都忽略了JavaScript也可以進行面向物件程式設計這個事實。一方面是因為,在入門階段我們所實現的各種頁面互動功能,都非常順理成章地使用過程式程式設計解決了,我們只需要寫一些方法,然後將事件繫結在頁面中的DOM節點上便可以完成。尤其像我這類一開始C++這類語言沒好好學,第一門主力語言就是JavaScript的同學來說,過程化程式設計的思維似乎更加根深蒂固。另一方面,就算是對於Java、C++等語言的程式設計師來說,JavaScript的面向物件也是一個異類:JavaScript中沒有class的概念(在ES5及之前版本中沒有,ES6會單獨介紹),其基於prototype的繼承模式也與傳統面嚮物件語言不同,而JavaScript的弱型別特性更會令這裡面的很多人抓狂。當然,在熟悉了之後,這種靈活性也會帶來很多好處。總之,封裝、繼承、多型、聚合這些面向物件的基本特性JavaScript都有其自己的實現方式,這些知識的學習是從入門級JS程式設計師進階的必經之路。
一、基於物件工作模式的繼承
我們知道,JavaScript中建立物件主要有兩種方式:建構函式與物件直接量。上一篇中介紹的三種繼承方法也都是基於建構函式進行工作的,這種方法更類似與Java式的繼承方式,建構函式和原型物件就相當於Java中的類了。 然而,JavaScript中終究是沒有類的概念的,一切的核心還是物件。下面介紹的就是這類方法:
1、淺拷貝
//淺拷貝 function extend(p){ var obj = {}; for(var i in p) obj[i] = p[i]; obj.father = p; return obj; } var fatherObj = { name: 'father', toString: function(){return this.name;} } var a = extend(fatherObj); a.name = 'aaa'; a.toString(); // 'aaa'
這個繼承函式的唯一引數是父物件(注意這裡接受的是父物件,也就是父類的例項物件。上一節的拷貝法中接受的是父類的建構函式物件),將父物件的全部屬性拷貝至子物件中,並在子物件中新增father屬性以方便引用父物件。當然了,由於是直接拷貝,父物件中值為物件的屬性依然是以引用的方式拷貝的,在子物件中修改此類屬性會影響到父物件。 下面是這種方法得到的a物件的結構
2、深拷貝
//深拷貝 function deepCopy(p, c){ var c = c || {}; for( var i in p){ if(typeof p[i] === 'object') { c[i] = (p[i].constructor === Array) ? [] : {}; deepCopy(p[i], c[i]); }else if(typeof p[i] === 'function'){ c[i] = p[i].prototype.constructor; }else c[i] = p[i]; } return c; } var fatherObj = { name: 'father', hobby: ['football','basketball'], toString: function(){return this.hobby} } //測試 var a = deepCopy(fatherObj); console.log(a.toString()); // ['football','basketball'] console.log(a.hobby === fatherObj.hobby); //false console.log(a.toString === fatherObj.toString); //false
相對於之前的淺拷貝,深拷貝則是對於物件做了特殊的處理:在遍歷父物件屬性是,一旦發現該物件為物件屬性,遞迴呼叫自身將該物件進行復制。另外,由於函式物件無法直接通過屬性遍歷的方法進行深拷貝,這裡通過訪問方法物件的原型物件的constructor屬性並將其進行賦值這個小技巧,完成了屬性的深拷貝。這個方法由於在處理物件深拷貝時需要遞迴呼叫,沒有在方法內新增父物件的引用,在使用的時候可以手動進行新增或者對這個方法進行二次封裝。
拷貝與深拷貝其實也是聚合的實現了,將其他物件的屬性拿過來擴充套件自身物件。若是兩物件為父級子級關係,則為繼承;若是兩物件同級擴充套件,則可以視作聚合。其核心點就是深拷貝。
3、通過直接設定原型物件進行繼承
//直接設定原型物件
function extend(p) {
function F(){};
F.prototype = p;
var c = new F();
c.father = p;
return c;
}
這個方法接受父物件為唯一引數,並將父物件設定為臨時構造器的原型物件,構造出子物件,完成繼承。Object物件中包含了create方法,功能與這個大概一致,都是接受一個物件作為引數,返回以該物件為原型物件的新物件,MDN中有詳細的解釋:MDN:Object.create()4、多重繼承
顯然,JavaScript不可能為多重繼承提供語法單元。但是對於JavaScript這類語言來說,模擬出多重繼承也是非常容易的。這裡提供了一種基於物件拷貝的多重繼承實現:
//多重繼承
function multiple(){
var c = {},
stuff,
len = arguments.length;
c['father'] = [];
for(var j = 0;j < len;j++){
stuff = arguments[j];
for(var i in stuff) c[i] = stuff[i];
c['father'].push(stuff);
}
return c;
}
JavaScript中實參的個數可以多於形參,利用這個特性我們可以方便的處理任意數量個引數。這裡的方法就可以從任意個物件中繼承屬性,將這些屬性拷貝至新物件中,並將父物件的引用新增值father屬性中,將構造完成的子物件返回。 同樣的,可以輕鬆地將這個方法改寫成現有物件之間的繼承://多重繼承2
function multiple(/*第一個引數為子物件,其餘為父物件*/){
var c = arguments[0],
stuff,
len = arguments.length;
c['father'] = [];
for(var j = 1;j < len;j++){
stuff = arguments[j];
for(var i in stuff) c[i] = stuff[i];
c['father'].push(stuff);
}
return c;
}
當然了,若遇到同名屬性,會按照先後次序覆蓋。二、構造函數借用
還有一類很重要的繼承實現方式,稱為構造器借用(構造函數借用)。這裡是利用了call()或apply()方法在子物件建構函式中呼叫父物件的建構函式。
//構造器借用
function Animal(age){
this.age = age;
}
Animal.prototype.getAge = function(){ return this.age + ' years old.'};
function Bird(){
Animal.apply(this, arguments);
}
Bird.prototype.className = 'Bird';
var a = new Bird(10);
console.log( a.className ); // 'Bird'
console.log( a.age); // 10
console.log( a.getAge ); // undefined
在這種繼承模式中,子物件不會繼承父物件的原型屬性,只會將父物件在建構函式中定義的屬性重建在自身屬性中。並且遇到值為物件的屬性時,也會獲得一個新值,而不是父級該值的引用。同時,對子物件所做的任何修改都不會影響父物件。 三、找出最佳的繼承方法
1、寄生式繼承
這個方法其實是對原型物件法的升級,將繼承後物件的擴充套件也封裝進方法中。“這樣在建立物件的函式中直接吸收其他物件的功能,進行擴充套件並返回,好像所有工作都是自己做的”,便是寄生式繼承名字的由來了。這裡直接使用了Object.create()方法,也可以用上文中給出的方法。
//寄生式繼承
function extend(p){
var c = Object.create(p);
//在此對c進行擴充套件,新增子物件的自有屬性和方法
//......
//......
return c;
}
2、組合繼承
這個方法是構造器借用法的延伸。由於構造器借用法無法繼承原型屬性,無法實現函式複用。便在該方法上做了簡單改動: BIrd.prototype = new Animal()
//組合繼承
function Animal(age){
this.age = age;
}
Animal.prototype.getAge = function(){ return this.age + ' years old.'};
function Bird(){
Animal.apply(this, arguments);
}
Bird.prototype.className = 'Bird';
var a = new Bird(10);
console.log( a.className ); // 'Bird'
console.log( a.age ); // 10
console.log( a.getAge ); // '10 years old.'
然而,這種方式也有個明顯的缺點。在繼承的過程中,父物件的建構函式會被呼叫兩次:apply方法會呼叫一次,隨後呼叫子物件建構函式時又會呼叫一次。父物件的自身屬性實際上被繼承了兩次:
function Animal(age){
this.age = age;
}
function Bird(){
Animal.apply(this, arguments);
}
Bird.prototype = new Animal(100)
Bird.prototype.className = 'Bird';
var a = new Bird(200);
console.log(a.age); // 200
console.log(a.__proto__.age); // 100
delete a.age;
console.log(a.age); // 100
從a物件的結構中可以清晰的看出,其自身重建了父級屬性age,又從原型中繼承了age,該屬性被繼承了兩次。
將繼承原型的方式從本例中的方法替換為 JavaScript面向物件(3)——原型與基於建構函式的繼承模式(原型鏈) 中的最後一種方法可以更正雙重繼承的問題。然而由於該方法本身的問題與侷限性,這還不是最佳的方案。
3、最佳繼承方法: 組合寄生式繼承
說了這麼多,終於該引出最佳方法了:組合寄生式繼承法。這裡直接搬出紅寶書裡的經典原始碼:
function inherit(subType, superType){
var protoType = Object.create(superType.prototype);
protoType.constructor = subType;
subType.prototype = protoType;
}
該方法接受子類和父類建構函式作為引數,構造出子類建構函式的原型物件,完成原型的繼承,再配合組合式繼承法的其餘部分:function inherit(subType, superType){
var protoType = Object.create(superType.prototype);
protoType.constructor = subType;
subType.prototype = protoType;
}
function Animal(age){
this.age = age;
}
Animal.prototype.getAge = function (){ return '11'};
function Bird(){
Animal.apply(this, arguments);
}
inherit(Bird, Animal);
Bird.prototype.className = 'Bird';
Bird.prototype.getName = function(){ return '22'};
這便是目前公認最佳的JavaScript繼承的實現模式了。