1. 程式人生 > >JavaScript面向物件(4)——最佳繼承模式(從深拷貝、多重繼承、構造器借用,到組合寄生式繼承)

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繼承的實現模式了。