1. 程式人生 > >js程式碼複用

js程式碼複用

原文連結:https://github.com/TooBug/javascript.patterns/blob/master/chapter6.markdown

程式碼複用模式

程式碼複用是一個既重要又有趣的話題。如果你面對自己或者別人已經寫好的程式碼,而這些程式碼又是經過測試的、可維護的、可擴充套件的、有文件的,這時候你只想寫儘量少且可以被複用的程式碼就是一個再自然不過的想法。

當我們說到程式碼複用的時候,想到的第一件事就是繼承,本章會有很大篇幅講述這個話題,你將看到好多種方法來實現“類式(classical)”和一些其它方式的繼承。但是,最最重要的事情,是你需要記住終極目標——程式碼複用。繼承是達到這個目標的一種方法,但是不是唯一的。在本章,你將看到怎樣基於其它物件來構建新物件,怎樣使用混元,以及怎樣在不使用繼承的情況下只複用你需要的功能。

在做程式碼複用的工作的時候,謹記Gang of Four在書中給出的關於物件建立的建議:“優先使用物件建立而不是類繼承”。(譯註:《設計模式:可複用面向物件軟體的基礎》(Design Patterns: Elements of Reusable Object-Oriented Software)是一本設計模式的經典書籍,該書作者為Erich Gamma、Richard Helm、Ralph Johnson和John Vlissides,被稱為“Gang of Four”,簡稱“GoF”。)

類式繼承 vs 現代繼承模式

在討論JavaScript的繼承這個話題的時候,經常會聽到“類式繼承”的概念,那我們先看一下什麼是類式(classical)繼承。classical一詞並不是來自某些古老的、固定的或者是被廣泛接受的解決方案,而僅僅是來自單詞“class”。(譯註:classical也有“經典”的意思。)

很多程式語言都有原生的類的概念,以此作為物件的藍本。在這些語言中,每個物件都是一個指定類的例項(instance),並且(以Java為例)一個物件不能在不存在對應的類的情況下存在。在JavaScript中,因為沒有類,所以類的例項的概念沒什麼意義。JavaScript的物件僅僅是簡單的鍵值對,這些鍵值對都可以動態建立或者是改變。

但是JavaScript擁有建構函式(constructor functions),並且有語法和使用類非常相似的new運算子。

在Java中你可能會這樣寫:

Person adam = new Person();

在JavaScript中你可以這樣:

var adam = new Person();

除了Java是強型別語言需要給adam新增型別Person外,其它的語法看起來是一樣的。JavaScript的建構函式呼叫方式看起來讓人感覺Person()是一個類,但事實上,Person()僅僅是一個函式。語法上的相似使得非常多的開發者陷入對JavaScript類的思考,並且給出了很多模擬類的繼承方案。這樣的實現方式,我們叫它“類式繼承”。順便也提一下,所謂“現代”繼承模式是指那些不需要你去想類這個概念的模式。

當需要給專案選擇一個繼承模式時,有不少的備選方案。你應該儘量選擇那些現代繼承模式,除非團隊已經覺得“無類不歡”。

本章先討論類式繼承,然後再關注現代繼承模式。

類式繼承的期望結果

實現類式繼承的目標是基於建構函式Child()來建立一個物件,然後從另一個建構函式Parent()獲得屬性。

儘管我們是在討論類式繼承,但還是儘量避免使用“類”這個詞。“建構函式”或者“constructor”雖然更長,但是更準確,不會讓人迷惑。通常情況下,應該努力避免在跟團隊溝通的時候使用“類”這個詞,因為在JavaScript中,很可能每個人都會有不同的理解。

下面是定義兩個建構函式Parent()Child()的例子:

//Parent建構函式
function Parent(name) {
	this.name = name || 'Adam';
}

//給原型增加方法
Parent.prototype.say = function () {
	return this.name;
};

//空的Child建構函式
function Child(name) {}

//繼承
inherit(Child, Parent);

上面的程式碼定義了兩個建構函式Parent()Child()say()方法被新增到了Parent()構建函式的原型(prototype)中,inherit()函式完成了繼承的工作。inherit()函式並不是原生提供的,需要自己實現。讓我們來看一看比較常見的實現它的幾種方法。

類式繼承1——預設模式

最常用的一種模式是使用Parent()建構函式來建立一個物件,然後把這個物件設為Child()的原型。這是可複用的inherit()函式的第一種實現方法:

function inherit(C, P) {
	C.prototype = new P();
}

需要強調的是原型(prototype屬性)應該指向一個物件,而不是函式,所以它需要指向由被繼承的建構函式建立的例項(物件),而不是建構函式自己。換句話說,請注意new運算子,有了它這種模式才可以正常工作。

之後在應用中使用new Child()建立物件的時候,它將通過原型擁有Parent()例項的功能,像下面的例子一樣:

var kid = new Child();
kid.say(); // "Adam"

跟蹤原型鏈

在這種模式中,子物件既繼承了(父物件的)“自有屬性”(新增給this的例項屬性,比如name),也繼承了原型中的屬性和方法(比如say())。

我們來看一下在這種繼承模式中原型鏈是怎麼工作的。為了討論方便,我們假設物件是記憶體中的一塊空間,它包含資料和指向其它空間的引用。當使用new Parent()建立一個物件時,這樣的一塊空間就被分配了(圖6-1中的2號),它儲存著name屬性的資料。如果你嘗試訪問say()方法(比如通過(new Parent).say()),2號空間中並沒有這個方法。但是在通過隱藏的連結__proto__指向Parent()構建函式的原型prototype屬性時,就可以訪問到包含say()方法的1號空間(Parent.prototype)了。所有的這一塊都是在幕後發生的,不需要任何額外的操作,但是知道它是怎樣工作的有助於讓你明白你正在訪問或者修改的資料在哪,這是很重要的。注意,__proto__在這裡只是為了解釋原型鏈而存在,這個屬性在語言本身中是不可用的,儘管有一些環境提供了(比如Firefox)。

圖6-1 Parent()建構函式的原型鏈

圖6-1 Parent()建構函式的原型鏈

現在我們來看一下在使用inherit()函式之後再使用var kid = new Child()建立一個新物件時會發生什麼。見圖6-2。

圖6-2 繼承後的原型鏈

圖6-2 繼承後的原型鏈

Child()建構函式是空的,也沒有屬性新增到Child.prototype上,這樣,使用new Child()創建出來的物件都是空的,除了有隱藏的連結__proto__。在這個例子中,__proto__指向在inherit()函式中建立的new Parent()物件。

現在使用kid.say()時會發生什麼?3號物件沒有這個方法,所以通過原型鏈找到2號。2號物件也沒有這個方法,所以也通過原型鏈找到1號,剛好有這個方法。接下來say()方法引用了this.name,這個變數也需要解析,於是沿原型鏈查詢的過程又走了一遍。在這個例子中,this指向3號物件,它沒有name屬性,然後2號物件被訪問,並且有name屬性,值為“Adam”。

最後,我們看一點額外的東西,假如我們有如下的程式碼:

var kid = new Child();
kid.name = "Patrick";
kid.say(); // "Patrick"

圖6-3展現了這個例子的原型鏈:

圖6-3 繼承並且給子物件新增屬性後的原型鏈

圖6-3 繼承並且給子物件新增屬性後的原型鏈

設定kid.name並沒有改變2號物件的name屬性,但是卻直接在3號物件上添加了自有的name屬性。當kid.say()執行時,say()方法會依次在3號物件中找,然後是2號,最後到1號,像前面說的一樣。但是這一次在找this.name(和kid.name一樣)時很快,因為這個屬性在3號物件中就被找到了。

如果通過delete kid.name的方式移除新新增的屬性,那麼2號物件的name屬性就將被暴露出來並且在查詢的時候被找到。

這種模式的缺點

這種模式的一個缺點是既繼承了(父物件的)“自有屬性”,也繼承了原型中的屬性。大部分情況下你可能並不需要“自有屬性”,因為它們更可能是為例項物件新增的,並不用於複用。

一個在建構函式上常用的規則是,用於複用的成員(譯註:屬性和方法)應該被新增到原型上。

在使用這個inherit()函式時另外一個不便是它不能夠讓你傳引數給子建構函式,這些引數有可能是想再傳給父建構函式的。考慮下面的例子:

var s = new Child('Seth');
s.say(); // "Adam"

這並不是我們期望的結果。事實上傳遞引數給父建構函式是可能的,但這樣需要在每次需要一個子物件時再做一次繼承,很不方便,因為需要不斷地建立父物件。

類式繼承2——借用建構函式

下面這種模式解決了從子物件傳遞引數到父物件的問題。它借用了父物件的建構函式,將子物件繫結到this,同時傳入引數:

function Child(a, c, b, d) {
	Parent.apply(this, arguments);
}

使用這種模式時,只能繼承在父物件的建構函式中新增到this的屬性,不能繼承原型上的成員。

使用借用建構函式的模式,子物件通過複製的方式繼承父物件的成員,而不是像類式繼承1中那樣通過引用的方式。下面的例子展示了這兩者的不同:

//父建構函式
function Article() {
	this.tags = ['js', 'css'];
}
var article = new Article();

//BlogPost通過類式繼承1(預設模式)從article繼承
function BlogPost() {}
BlogPost.prototype = article;
var blog = new BlogPost();
//注意你不需要使用`new Article()`,因為已經有一個例項了

//StaticPage通過借用建構函式的方式從Article繼承
function StaticPage() {
	Article.call(this);
}
var page = new StaticPage();

alert(article.hasOwnProperty('tags')); // true
alert(blog.hasOwnProperty('tags')); // false
alert(page.hasOwnProperty('tags')); // true

在上面的程式碼片段中,Article()被用兩種方式分別繼承。預設模式使blog可以通過原型鏈訪問到tags屬性,所以它自己並沒有tags屬性,hasOwnProperty()返回falsepage物件有自己的tags屬性,因為它是使用借用建構函式的方式繼承,複製(而不是引用)了tags屬性。

注意在修改繼承後的tags屬性時的不同表現:

blog.tags.push('html');
page.tags.push('php');
alert(article.tags.join(', ')); // "js, css, html"

在這個例子中,blog物件修改了tags屬性,同時,它也修改了父物件,因為實際上blog.tagsarticle.tags是引向同一個陣列。而對pages.tags的修改並不影響父物件article,因為pages.tags在繼承的時候是一份獨立的拷貝。

原型鏈

我們來看一下當我們使用熟悉的Parent()和Child()建構函式和這種繼承模式時原型鏈是什麼樣的。為了使用這種繼承模式,Child()有明顯變化:

//父建構函式
function Parent(name) {
	this.name = name || 'Adam';
}

//在原型上新增方法
Parent.prototype.say = function () {
	return this.name;
};

//子建構函式
function Child(name) {
	Parent.apply(this, arguments);
}

var kid = new Child("Patrick");
	kid.name; // "Patrick"
typeof kid.say; // "undefined"

如果看一下圖6-4,就能發現new Child()物件和Parent()之間不再有連結。這是因為Child.prototype根本就沒有被使用,它指向一個空物件。使用這種模式,kid擁有了自有的name屬性,但是並沒有繼承say()方法,如果嘗試呼叫它的話會出錯。這種繼承方式只是一種一次性地將父物件的屬性複製為子物件的屬性,並沒有__proto__連結。

圖6-4 使用借用建構函式模式時沒有被關聯的原型鏈

圖6-4 使用借用建構函式模式時沒有被關聯的原型鏈

利用借用建構函式模式實現多繼承

使用借用建構函式模式,可以通過借用多個建構函式的方式來實現多繼承:

function Cat() {
	this.legs = 4;
	this.say = function () {
		return "meaowww";
	}
}

function Bird() {
	this.wings = 2;
	this.fly = true;
}

function CatWings() {
	Cat.apply(this);
	Bird.apply(this);
}

var jane = new CatWings();
console.dir(jane);

結果如圖6-5,任何重複的屬性都會以最後的一個值為準。

圖6-5 在Firebug中檢視CatWings物件

圖6-5 在Firebug中檢視CatWings物件

借用建構函式的利與弊

這種模式的一個明顯的弊端就是無法繼承原型。如前面所說,原型往往是新增可複用的方法和屬性的地方,這樣就不用在每個例項中再建立一遍。

這種模式的一個好處是獲得了父物件自有成員的拷貝,不存在子物件意外改寫父物件屬性的風險。

那麼,在上一個例子中,怎樣使一個子物件也能夠繼承原型屬性呢?怎樣能使kid可以訪問到say()方法呢?下一種繼承模式解決了這個問題。

類式繼承3——借用並設定原型

綜合以上兩種模式,首先借用父物件的建構函式,然後將子物件的原型設定為父物件的一個新例項:

function Child(a, c, b, d) {
	Parent.apply(this, arguments);
}
Child.prototype = new Parent();

這樣做的好處是子物件獲得了父物件的自有成員,也獲得了父物件中可複用的(在原型中實現的)方法。子物件也可以傳遞任何引數給父建構函式。這種行為可能是最接近Java的,子物件繼承了父物件的所有東西,同時可以安全地修改自己的屬性而不用擔心修改到父物件。

一個弊端是父建構函式被呼叫了兩次,所以不是很高效。最後,(父物件的)自有屬性(比如這個例子中的name)也被繼承了兩次。

我們來看一下程式碼並做一些測試:

//父建構函式
function Parent(name) {
	this.name = name || 'Adam';
}

//在原型上新增方法
Parent.prototype.say = function () {
	return this.name;
};

//子建構函式
function Child(name) {
	Parent.apply(this, arguments);
}
Child.prototype = new Parent();

var kid = new Child("Patrick");
kid.name; // "Patrick"
kid.say(); // "Patrick"
delete kid.name;
kid.say(); // "Adam"

跟前一種模式不一樣,現在say()方法被正確地繼承了。可以看到name也被繼承了兩次,在刪除掉自己的拷貝後,在原型鏈上的另一個就被暴露出來了。

圖6-6展示了這些物件之間的關係。這些關係有點像圖6-3中展示的,但是獲得這種關係的方法是不一樣的。

圖6-6 除了繼承“自己的屬性”外,原型鏈也被保留了

圖6-6 除了繼承“自己的屬性”外,原型鏈也被保留了

類式繼承4——共享原型

不像前一種類式繼承模式需要呼叫兩次父建構函式,下面這種模式根本不會涉及到呼叫父建構函式的問題。

一般的經驗是將可複用的成員放入原型中而不是this。從繼承的角度來看,則是任何應該被繼承的成員都應該放入原型中。這樣你只需要設定子物件的原型和父物件的原型一樣即可:

function inherit(C, P) {
	C.prototype = P.prototype;
}

這種模式的原型鏈很短並且查詢很快,因為所有的物件實際上共享著同一個原型。但是這樣也有弊端,那就是如果子物件或者在繼承關係中的某個地方的任何一個子物件修改這個原型,將影響所有的繼承關係中的父物件。(譯註:指會影響到所有從這個原型中繼承的物件所依賴的共享原型上的成員。)

如圖6-7,子物件和父物件共享同一個原型,都可以訪問say()方法。但是,子物件不繼承name屬性。

圖6-7 (父子物件)共享原型時的關係

圖6-7 (父子物件)共享原型時的關係

類式繼承5——臨時建構函式

下一種模式通過打斷父物件和子物件原型的直接連結解決了共享原型時的問題,同時還從原型鏈中獲得其它的好處。

下面是這種模式的一種實現方式,F()函式是一個空函式,它充當了子物件和父物件的代理。F()prototype屬性指向父物件的原型。子物件的原型是這個空函式的一個例項:

function inherit(C, P) {
	var F = function () {};
	F.prototype = P.prototype;
	C.prototype = new F();
}

這種模式有一種和預設模式(類式繼承1)明顯不一樣的行為,因為在這裡子物件只繼承原型中的屬性(圖6-8)。

圖6-8 使用臨時(代理)建構函式F()實現類式繼承

圖6-8 使用臨時(代理)建構函式F()實現類式繼承

這種模式通常情況下都是一種很棒的選擇,因為原型本來就是存放複用成員的地方。在這種模式中,父建構函式新增到this中的任何成員都不會被繼承。

我們來建立一個子物件並且檢查一下它的行為:

var kid = new Child();

如果你訪問kid.name將得到undefined。在這個例子中,name是父物件自己的屬性,而在繼承的過程中我們並沒有呼叫new Parent(),所以這個屬性並沒有被建立。當訪問kid.say()時,它在3號物件中不可用,所以在原型鏈中查詢,4號物件也沒有,但是1號物件有,它在記憶體中的位置會被所有從Parent()建立的建構函式和子物件所共享。

儲存父類(Superclass)

在上一種模式的基礎上,還可以新增一個指向原始父物件的引用。這很像其它語言中訪問超類(superclass)的情況,有時候很方便。

我們將這個屬性命名為“uber”,因為“super”是一個保留字,而“superclass”則可能誤導別人認為JavaScript擁有類。下面是這種類式繼承模式的一個改進版實現:

function inherit(C, P) {
	var F = function () {};
	F.prototype = P.prototype;
	C.prototype = new F();
	C.uber = P.prototype;
}

重置建構函式引用

這個近乎完美的模式上還需要做的最後一件事情就是重置建構函式(constructor)的指向,以便未來在某個時刻能被正確地使用。

如果不重置建構函式的指向,那所有的子物件都會認為Parent()是它們的建構函式,而這個結果完全沒有用。使用前面的inherit()的實現,你可以觀察到這種行為:

// Parent,Child,實現繼承
function Parent() {}
function Child() {}
inherit(Child, Parent);

// 測試
var kid = new Child();
kid.constructor.name; // "Parent"
kid.constructor === Parent; // true

constructor屬性很少被用到,但是在執行時檢查物件很方便。你可以重新將它指向期望的建構函式而不影響功能,因為這個屬性更多是“資訊性”的。(譯註:即它更多的時候是在提供資訊而不是參與到函式功能中。)

最終,這種類式繼承的Holy Grail版本看起來是這樣的:

function inherit(C, P) {
	var F = function () {};
	F.prototype = P.prototype;
	C.prototype = new F();
	C.uber = P.prototype;
	C.prototype.constructor = C;
}

類似這樣的函式也存在於YUI庫(也許還有其它庫)中,它將類式繼承的方法帶給了沒有類的語言。如果你決定使用類式繼承,那麼這是最好的方法。

“代理函式”或者“代理建構函式”也是指這種模式,因為臨時建構函式是被用作獲取父建構函式原型的代理。

一種常見的對Holy Grail模式的優化是避免每次需要繼承的時候都建立一個臨時(代理)建構函式。事實上建立一次就足夠了,以後只需要修改它的原型即可。你可以用一個即時函式來將代理函式儲存到閉包中:

var inherit = (function () {
	var F = function () {};
	return function (C, P) {
		F.prototype = P.prototype;
		C.prototype = new F();
		C.uber = P.prototype;
		C.prototype.constructor = C;
	}
}());

Klass

有很多JavaScript類庫模擬了類,創造了新的語法糖。這些類庫具體的實現方式可能會不一樣,但是基本上都有一些共性,包括:

  • 有一個約定好的方法,如initialize_init或者其它相似的名字,會被自動呼叫,來充當類的建構函式
  • 類可以從其它類繼承
  • 在子類中可以訪問到父類(superclass)

我們在這裡做一點變化,在本章的這部分自由地使用“class”這個詞,因為主題就是模擬類。

為避免討論太多細節,我們來看一下JavaScript中一種模擬類的實現。首先,看一下這種方案將如何被使用?

var Man = klass(null, {
	__construct: function (what) {
		console.log("Man's constructor");
		this.name = what;
	},
	getName: function () {
		return this.name;
	}
});

這種語法糖的形式是一個名為klass()的函式。在一些其它的實現方式中,它可能是Klass()建構函式或者是增強的Object.prototype,但是在這個例子中,我們讓它只是一個簡單的函式。

這個函式接受兩個引數:一個被繼承的類和通過物件字面量提供的新類的實現。受PHP的影響,我們約定類的建構函式必須是一個名為__construct()的方法。在前面的程式碼片段中,建立了一個名為Man的新類,並且它不繼承任何類(意味著繼承自Object)。Man類有一個在__construct()建立的自有屬性name和一個方法getName()。這個類是一個建構函式,所以下面的程式碼將正常工作(並且看起來像類例項化的過程):

var first = new Man('Adam'); // logs "Man's constructor"
first.getName(); // "Adam"

現在我們來擴充套件這個類,建立一個SuperMan類:

var SuperMan = klass(Man, {
	__construct: function (what) {
		console.log("SuperMan's constructor");
	},
	getName: function () {
		var name = SuperMan.uber.getName.call(this);
		return "I am " + name;
	}
});

這裡,klass()的第一個引數是將被繼承的Man類。值得注意的是,在getName()中,父類的getName()方法首先通過SuperMan類的uber靜態屬性被呼叫。我們來測試一下:

var clark = new SuperMan('Clark Kent');
clark.getName(); // "I am Clark Kent"

第一行在console中記錄了“Man's constructor”,然後是“Superman's constructor”,在一些語言中,父類的建構函式在子類建構函式被呼叫的時候會自動執行,這個特性也被模擬了。

instanceof運算子測試返回希望的結果:

clark instanceof Man; // true
clark instanceof SuperMan; // true

最後,我們來看一下klass()函式是怎樣實現的:

var klass = function (Parent, props) {

	var Child, F, i;
	
	// 1. 建構函式
	Child = function () {
		if (Child.uber && Child.uber.hasOwnProperty("__construct")) {
			Child.uber.__construct.apply(this, arguments);
		}
		if (Child.prototype.hasOwnProperty("__construct")) {
			Child.prototype.__construct.apply(this, arguments);
		}
	};
	
	// 2. 繼承
	Parent = Parent || Object;
	F = function () {};
	F.prototype = Parent.prototype;
	Child.prototype = new F();
	Child.uber = Parent.prototype;
	Child.prototype.constructor = Child;

	// 3. 新增方法實現
	for (i in props) {
		if (props.hasOwnProperty(i)) {
			Child.prototype[i] = props[i];
		}
	}
	
	// 返回“類”
	return Child;
};

這個klass()實現有三個明顯的部分:

  1. 建立Child()建構函式,這也是最後返回的將被作為類使用的函式。在這個函式裡面,如果__construct()方法存在的話將被呼叫,同樣,如果父類的__construct()存在,也將被呼叫(通過使用靜態屬性uber)。也可能存在uber沒有定義的情況——比如從Object繼承,前例中Man類即是如此。
  2. 第二部分主要完成繼承。只是簡單地使用前面章節討論過的Holy Grail類式繼承模式。只有一個東西是新的:如果Parent沒有傳值的話,設定ParentObject
  3. 最後一部分是真正定義類的地方,遍歷需要實現的方法(如例子中的__constructor()getName()),並將它們新增到Child()的原型中。

什麼時候使用這種模式呢?其實,最好是能避免則避免,因為它帶來了在這門語言中不存在的完整的類的概念,會讓人疑惑。使用它需要學習新的語法和新的規則,也就是說,如果你或者你的團隊習慣於使用類並且對原型感到不習慣,這種模式可能是一個可以探索的方向。這種模式允許你完全忘掉原型,好處就是你可以使用像其它語言那樣的(變種)語法。

原型繼承

現在,讓我們從一個叫作“原型繼承”的模式來討論沒有類的現代繼承模式。在這種模式中,沒有任何類牽涉進來,一個物件繼承自另外一個物件。你可以這樣理解它:你有一個想複用的物件,然後你想建立第二個物件,並且獲得第一個物件的功能。下面是這種模式的用法:

// 需要繼承的物件
var parent = {
	name: "Papa"
};

// 新物件
var child = object(parent);

// 測試
alert(child.name); // "Papa"

在這個程式碼片段中,有一個已經存在的使用物件字面量建立的物件叫parent,我們想建立一個和parent有相同的屬性和方法的物件叫childchild物件使用object()函式建立。這個函式在JavaScript中並不存在(不要與建構函式Object()混淆),所以我們來看看怎樣定義它。

與Holy Grail類式繼承相似,可以使用一個空的臨時建構函式F(),然後設定F()的原型為parent物件。最後,返回一個臨時建構函式的新例項。

function object(o) {
	function F() {}
	F.prototype = o;
	return new F();
}

圖6-9展示了使用原型繼承時的原型鏈。這樣建立的child總是一個空物件,它沒有自有屬性但通過原型鏈(__proto__)擁有父物件的所有功能。

圖6-9 原型繼承模式

圖6-9 原型繼承模式

討論

在原型繼承模式中,parent不一定需要使用物件字面量來建立(儘管這是一種常用的方式),也可以使用建構函式來建立。注意,如果你這樣做,那麼自有屬性和原型上的屬性都將被繼承:

// 父建構函式
function Person() {
	// 自有屬性
	this.name = "Adam";
}
// 原型上的屬性
Person.prototype.getName = function () {
	return this.name;
};

// 使用Person()建立一個新物件
var papa = new Person();
// 繼承
var kid = object(papa);

// 測試:自有屬性和原型上的屬性都被繼承了
kid.getName(); // "Adam"

也可以使用這種模式的一個變種,只繼承已存在的建構函式的原型物件。記住,物件繼承自物件,而不管父物件是怎麼建立的。這是前面例子的一個修改版本:

// 父建構函式
function Person() {
	// 自有屬性
	this.name = "Adam";
}
// 原型上的屬性
Person.prototype.getName = function () {
	
};

// 繼承
var kid = object(Person.prototype);

typeof kid.getName; // "function",因為它在原型中
typeof kid.name; // "undefined",因為只有原型中的成員被繼承了

ECMAScript5中的原型繼承

在ECMAScript5中,原型繼承已經正式成為語言的一部分。這種模式使用Object.create()方法來實現。換句話說,你不再需要自己去寫類似object()的函式,它是語言原生的部分了:

var child = Object.create(parent);

Object.create()接收一個額外的引數——一個物件。這個額外物件中的屬性將被作為自有屬性新增到返回的子物件中。這讓我們可以很方便地將繼承和建立子物件在一個方法呼叫中實現。例如:

var child = Object.create(parent, {
	age: { value: 2 } // ES5中的屬性描述符
});
child.hasOwnProperty("age"); // true

你可能也會發現原型繼承模式已經在一些JavaScript類庫中實現了,比如,在YUI3中,它是Y.Object()方法:

YUI().use('*', function (Y) {
	var child = Y.Object(parent);
});

通過複製屬性繼承

讓我們來看一下另外一種繼承模式——通過複製屬性繼承。在這種模式中,一個物件通過簡單地複製另一個物件來獲得功能。下面是一個簡單的實現這種功能的extend()函式:

function extend(parent, child) {
	var i;
	child = child || {};
	for (i in parent) {
		if (parent.hasOwnProperty(i)) {
			child[i] = parent[i];
		}
	}
	return child;
}

這是一個簡單的實現,僅僅是遍歷了父物件的成員然後複製它們。在這個實現中,child是可選引數,如果它沒有被傳入一個已有的物件,那麼一個全新的物件將被建立並返回:

var dad = {name: "Adam"};
var kid = extend(dad);
kid.name; // "Adam"

上面給出的實現叫作物件的“淺拷貝”(shallow copy),與之相對,“深拷貝”是指檢查準備複製的屬性本身是否是物件或者陣列,如果是,也遍歷它們的屬性並複製。如果使用淺拷貝的話(因為在JavaScript中物件是按引用傳遞),如果你改變子物件的一個屬性,而這個屬性恰好是一個物件,那麼你也會改變父物件。實際上這對方法來說可能很好(因為函式也是物件,也是按引用傳遞),但是當遇到其它的物件和陣列的時候可能會有些意外情況。考慮這種情況:

var dad = {
	counts: [1, 2, 3],
	reads: {paper: true}
};
var kid = extend(dad);
kid.counts.push(4);
dad.counts.toString(); // "1,2,3,4"
dad.reads === kid.reads; // true

現在讓我們來修改一下extend()函式以便實現深拷貝。你需要做的事情只是檢查一個屬性的型別是否是物件,如果是,則遞迴遍歷它的屬性。另外一個需要做的檢查是這個物件是真的物件還是陣列,可以使用第三章討論過的陣列檢查方式。最終深拷貝版的extend()是這樣的:

function extendDeep(parent, child) {
	var i,
		toStr = Object.prototype.toString,
		astr = "[object Array]";

	child = child || {};
	
	for (i in parent) {
		if (parent.hasOwnProperty(i)) {
			if (typeof parent[i] === "object") {
				child[i] = (toStr.call(parent[i]) === astr) ? [] : {};
				extendDeep(parent[i], child[i]);
			} else {
				child[i] = parent[i];
			}
		}
	}
	return child;
}

現在測試時這個新的實現給了我們物件的真實拷貝,所以子物件不會修改父物件:

var dad = {
	counts: [1, 2, 3],
	reads: {paper: true}
};
var kid = extendDeep(dad);

kid.counts.push(4);
kid.counts.toString(); // "1,2,3,4"
dad.counts.toString(); // "1,2,3"

dad.reads === kid.reads; // false
kid.reads.paper = false;
kid.reads.web = true;
dad.reads.paper; // true

通過複製屬性繼承的模式很簡單且應用很廣泛。例如Firebug(JavaScript寫的Firefox擴充套件)有一個方法叫extend()做淺拷貝,jQuery的extend()方法做深拷貝。YUI3提供了一個叫作Y.clone()的方法,它建立一個深拷貝並且通過繫結到子物件的方式複製函式。(本章後面將有更多關於繫結的內容。)

這種模式並不高深,因為根本沒有原型牽涉進來,而只跟物件和它們的屬性有關。

混元(Mix-ins)

既然談到了通過複製屬性來繼承,就讓我們順便多說一點,來討論一下“混元”模式。除了前面說的從一個物件複製,你還可以從任意多數量的物件中複製屬性,然後將它們混在一起組成一個新物件。

實現很簡單,只需要遍歷傳入的每個引數然後複製它們的每個屬性:

function mix() {
	var arg, prop, child = {};
	for (arg = 0; arg < arguments.length; arg += 1) {
		for (prop in arguments[arg]) {
			if (arguments[arg].hasOwnProperty(prop)) {
				child[prop] = arguments[arg][prop];
			}
		}
	}
	return child;
}

現在我們有了一個通用的混元函式,我們可以傳遞任意數量的物件進去,返回的結果將是一個包含所有傳入物件屬性的新物件。下面是用法示例:

var cake = mix(
	{eggs: 2, large: true},
	{butter: 1, salted: true},
	{flour: "3 cups"},
	{sugar: "sure!"}
);

圖6-10展示了在Firebug的控制檯中用console.dir(cake)展示出來的混元后cake物件的屬性。

圖6-10 在Firebug中檢視cake物件

圖6-10 在Firebug中檢視cake物件

如果你習慣了某些將混元作為原生部分的語言,那麼你可能期望修改一個或多個父物件時也影響子物件。但在這個實現中這是不會發生的事情。這裡我們只是簡單地遍歷、複製自有屬性,並沒有與父物件有任何連結。

借用方法

有時候會有這樣的情況:你希望使用某個已存在的物件的一兩個方法,你希望能複用它們,但是又真的不希望和那個物件產生繼承關係,因為你只希望使用你需要的那一兩個方法,而不繼承那些你永遠用不到的方法。得益於函式的call()apply()方法,可以通過借用方法模式實現它。在本書中,你其實已經見過這種模式了,甚至在本章extendDeep()的實現中也有用到。

在JavaScript中函式也是物件,它們有一些有趣的方法,比如call()apply()。這兩個方法的唯一區別是後者接受一個引數陣列以傳入正在呼叫的方法,而前者只接受一個一個的引數。你可以使用這兩個方法來從已有的物件中借用方法:

// call()示例
notmyobj.doStuff.call(myobj, param1, p2, p3);
// apply()示例
notmyobj.doStuff.apply(myobj, [param1, p2, p3]);

在這個例子中有一個物件myobj,而且notmyobj有一個用得著的方法叫doStuff()。你可以簡單地臨時借用doStuff()方法,而不用處理繼承然後得到一堆myobj中無關的方法。

你傳一個物件和任意的引數,這個被借用的方法會將this繫結到你傳遞的物件上。簡單地說,你的物件會臨時假裝成另一個物件以使用它的方法。這就像實際上獲得了繼承但又免除了“繼承稅”(譯註:指不需要的屬性和方法)。

例:從陣列借用

這種模式的一種常見用法是從陣列借用方法。

陣列有很多很有用但是一些“類陣列”物件(如arguments)不具備的方法。所以arguments可以借用陣列的方法,比如slice()。這是一個例子:

function f() {
	var args = [].slice.call(arguments, 1, 3);
	return args;
}
	
// 示例
f(1, 2, 3, 4, 5, 6); // returns [2,3]

在這個例子中,有一個空陣列被建立了,因為要借用它的方法。也可以使用一種看起來程式碼更長的方法來做,那就是直接從陣列的原型中借用方法,使用Array.prototype.slice.call(...)。這種方法程式碼更長一些,但是不用建立一個空陣列。

借用並繫結

當借用方法的時候,不管是通過call()/apply()還是通過簡單的賦值,方法中的this指向的物件都是基於呼叫的表示式來決定的。但是有時候最好的使用方式是將this的值鎖定或者提前繫結到一個指定的物件上。

我們來看一個例子。這是一個物件one,它有一個say()方法:

var one = {
	name: "object",
	say: function (greet) {
		return greet + ", " + this.name;
	}
};

// 測試
one.say('hi'); // "hi, object"

現在另一個物件two沒有say()方法,但是它可以從one借用:

var two = {
	name: "another object"
};

one.say.apply(two, ['hello']); // "hello, another object"

在這個例子中,say()方法中的this指向了twothis.name是“another object”。但是如果在某些場景下你將函式賦值給了全域性變數或者是將這個函式作為回撥,會發生什麼?在客戶端程式設計中有非常多的事件和回撥,所以這種情況經常發生:

// 賦值給變數,this會指向全域性物件
var say = one.say;
say('hoho'); // "hoho, undefined"

// 作為回撥
var yetanother = {
	name: "Yet another object",
	method: function (callback) {
		return callback('Hola');
	}
};
yetanother.method(one.say); // "Holla, undefined"

在這兩種情況中say()中的this都指向了全域性物件,所以程式碼並不像我們想象的那樣正常工作。要修復(繫結)一個方法的物件,我們可以用一個簡單的函式,像這樣:

function bind(o, m) {
	return function () {
		return m.apply(o, [].slice.call(arguments));
	};
}

這個bind()函式接受一個物件o和一個方法m,然後把它們繫結在一起,再返回另一個函式。返回的函式通過閉包可以訪問到om,也就是說,即使在bind()返回之後,內層的函式仍然可以訪問到om,而om會始終指向原來的物件和方法。讓我們用bind()來建立一個新函式:

var twosay = bind(two, one.say);
twosay('yo'); // "yo, another object"

正如你看到的,儘管twosay()是作為一個全域性函式被建立的,但this並沒有指向全域性物件,而是指向了通過bind()傳入的物件two。不論如何呼叫twosay()this將始終指向two

繫結是奢侈的,你需要付出的代價是一個額外的閉包。

Function.prototype.bind()

ECMAScript5在Function.prototype中添加了一個方法叫bind(),使用時和apply()/call()一樣簡單。所以你可以這樣寫:

var newFunc = obj.someFunc.bind(myobj, 1, 2, 3);

這意味著將someFunc()myobj綁定了,並且還傳入了someFunc()的前三個引數。這也是一個在第4章討論過的部分應用的例子。

讓我們來看一下當你的程式跑在低於ES5的環境中時如何實現Function.prototype.bind()

if (typeof Function.prototype.bind === "undefined") {
	Function.prototype.bind = function (thisArg) {
		var fn = this,
		slice = Array.prototype.slice,
		args = slice.call(arguments, 1);
		
		return function () {
			return fn.apply(thisArg, args.concat(slice.call(arguments)));
		};
	};
}

這個實現可能看起來有點熟悉,它使用了部分應用,將傳入bind()的引數串起來(除了第一個引數),然後在被呼叫時傳給bind()返回的新函式。這是用法示例:

var twosay2 = one.say.bind(two);
twosay2('Bonjour'); // "Bonjour, another object"

在這個例子中,除了繫結的物件外,我們沒有傳任何引數給bind()。下一個例子中,我們來傳一個用於部分應用的引數:

var twosay3 = one.say.bind(two, 'Enchanté');
twosay3(); // "Enchanté, another object"

##小結

在JavaScript中,繼承有很多種方案可以選擇,在本章中你看到了很多類式繼承和現代繼承的方案。學習和理解不同的模式是有好處的,因為這可以增強你對這門語言的掌握能力。

但是,也許在開發過程中繼承並不是你經常面對的一個問題。一部分是因為這個問題已經被使用某種方式或者某個你使用的類庫解決了,另一部分是因為你不需要在JavaScript中建立很長很複雜的繼承鏈。在靜態強型別語言中,繼承可能是唯一可以複用程式碼的方法,但在JavaScript中有更多更簡單更優化的方法,包括借用方法、繫結、複製屬性、混元等。

記住,程式碼複用才是目標,繼承只是達成這個目標的一種手段。