JavaScript 是以物件為基礎,以函數為模型,以原型為繼承的物件導向開發模式。本節將詳細介紹定義 JavaScript 型別的方法,以及實現繼承的常用模式。
構造原型
直接使用 prototype 原型設計類的繼承存在兩個問題。
-
由於建構函式事先宣告,而原型屬性在類結構宣告之後才被定義,因此無法通過建構函式向原型動態傳遞引數。這樣範例化物件都是一個模樣,沒有個性。要改變原型屬性值,則所有範例都會受到干擾。
-
當原型屬性的值為參照型別資料時,如果在一個物件範例中修改該屬性值,將會影響所有的範例。
範例1
簡單定義 Book 型別,然後範例化。
function Book () {}; //宣告建構函式
Book.prototype.o = {x : 1, y : 2}; //建構函式的原型屬性o是一個物件
var book1 = new Book (); //範例化物件book1
var book2 = new Book (); //範例化物件book2
console.log(book1.o.x); //返回1
console.log(book2.o.x); //返回1
book2.o.x = 3; //修改範例化物件book2中的屬性x的值
console.log(book1.o.x); //返回3
console.log(book2.o.x); //返回3
由於原型屬性 o 為一個參照型的值,所以所有範例的屬性 o 的值都是同一個物件的參照,一旦 o 的值發生變化,將會影響所有範例。
構造原型正是為了解決原型模式而誕生的一種混合設計模式,它把建構函式模式與原型模式混合使用,從而避免了上述問題的發生。
實現方法:對於可能會相互影響的原型屬性,並且希望動態傳遞引數的屬性,可以把它們獨立出來使用建構函式模式進行設計。對於不需要個性設計、具有共性的方法或屬性,則可以使用原型模式來設計。
範例2
遵循上述設計原則,把其中兩個屬性設計為建構函式模式,設計方法為原型模式。
function Book (title, pages) { //建構函式模式設計
this.title = title;
this.pages = pages;
}
Book.prototype.what = function () { //原型模式設計
console.log(this.title + this.pages);
};
var book1 = new Book("JavaScript 程式設計", 160);
var book2 = new Book("C語言程式設計", 240);
console.log(book1.title);
console.log(book2.title);
構造原型模式是 ECMAScript 定義類的推薦標準。一般建議使用建構函式模式定義所有屬性,使用原型模式定義所有方法。這樣所有方法都只建立一次,而每個範例都能夠根據需要設定屬性值。這也是使用最廣的一種設計模式。
動態原型
根據物件導向的設計原則,型別的所有成員應該都被封裝在類結構體內。例如:
function Book (title, pages) { //建構函式模式設計
this.title = title;
this.pages = pages;
Book.prototype.what = function () { //原型模式設計,位於類的內部
console.log(this.title + this.pages);
};
}
但當每次範例化時,類 Book 中包含的原型方法就會被重複建立,生成大量的原型方法,浪費系統資源。可以使用 if 判斷原型方法是否存在,如果存在就不再建立該方法,否則就建立方法。
function Book (title, pages) {
this.title = title;
this.pages = pages;
if (typeof Book.isLock == "undefined") { //建立原型方法的鎖,如果不存在則建立
Book.prototype.what = function () {
console.log(this.title + this.pages);
};
Book.isLock = true; //建立原型方法後,把鎖鎖上,避免重複建立
}
}
var book1 = new Book("JavaScript 程式設計", 160);
var book2 = new Book("C語言程式設計", 240);
console.log(book1.title);
console.log(book2.title);
typeof Book.isLock 表示式能夠檢測該屬性值的型別,如果返回為 undefined 字串,則不存在該屬性值,說明沒有建立原型方法,並允許建立原型方法,設定該屬性的值為 true,這樣就不用重複建立原型方法。這裡使用類名 Book,而沒有使用 this,這是因為原型是屬於類本身的,而不是物件範例的。
動態原型模式與構造原型模式在效能上是等價的,使用者可以自由選擇,不過構造原型模式應用比較廣泛。
工廠模式
工廠模式是定義型別的最基本方法,也是 JavaScript 最常用的一種開發模式。它把物件範例化簡單封裝在一個函數中,然後通過呼叫函數,實現快速、批次生產範例物件。
範例1
下面範例設計一個 Car 型別:包含汽車顏色、驅動輪數、百公里油耗 3 個屬性,同時定義一個方法,用來顯示汽車顏色。
function Car (color, drive, oil) { //汽車類
var _car = new Object(); //臨時物件
_car.color = color; //初始化顏色
_car.drive = drive; //初始化驅動輪數
_car.oil = oil; //初始化百公里油耗
_car.showColor = function () { //方法,提示汽車顏色
console.log(this.color);
};
return _car; //返回範例
}
var car1 = Car("red", 4, 8);
var car2 = Car("blue", 2, 6);
car1.showColor(); //輸出“red”
car2.showColor(); //輸出“blue”
上面程式碼是一個簡單的工廠模式型別,使用 Car 類可以快速建立多個汽車範例,它們的結構相同,但是屬性不同,可以初始化不同的顏色、驅動輪數和百公里油耗。
範例2
在型別中,方法就是一種行為或操作,它能夠根據初始化引數完成特定任務,具有共性。因此,可以考慮把方法置於 Car() 函數外面,避免每次範例化時都要建立一次函數,讓每個範例共用同一個函數。
function showColor () { //公共方法,提示汽車顏色
console.log(this.color);
};
function Car (color, drive, oil) { //汽車類
var _car = new Object(); //臨時物件
_car.color = color; //初始化顏色
_car.drive = drive; //初始化驅動輪數
_car.oil = oil; //初始化百公里油耗
_car.showColor = showColor; //參照外部函數
return _car; //返回範例
}
在上面這段重寫的程式碼中,在函數 Car() 之前定義了函數 showColor()。在 Car() 內部,通過參照外部 showColor() 函數,避免了每次範例化時都要建立一個新的函數。從功能上講,這樣解決了重複建立函數的問題;但是從語意上講,該函數不太像是物件的方法。
類繼承
類繼承的設計方法:在子類中呼叫父類別建構函式。
在 JavaScript 中實現類繼承,需要注意以下 3 個技術問題。
-
在子類中,使用 apply 呼叫父類別,把子類建構函式的引數傳遞給父類別父類別建構函式。讓子類繼承父類別的私有屬性,即 Parent.apply(this, arguments); 程式碼行。
-
在父類別和子類之間建立原型鏈,即 Sub.prototype = new Parent(); 程式碼行。通過這種方式保證父類別和子類是原型鏈上的上下級關係,即子類的 prototype 指向父類別的一個範例。
-
恢復子類的原型物件的建構函式,即 Sub.prototype.constructor=Sub;語句行。當改動 prototype 原型時,就會破壞原來的 constructor 指標,所以必須重置 constructor。
範例1
下面範例演示了一個三重繼承的案例,包括基礎類別、父類別和子類,它們逐級繼承。
//基礎類別Base
function Base (x) { //建構函式Base
this.get = function () { //私有方法,獲取引數值
return x;
}
}
Base.prototype.has = function () { //原型方法,判斷get()方法返回值是否為0
return ! (this.get() == 0);
}
//父類別Parent
function Parent () { //建構函式Parent
var a = []; //私有陣列a
a = Array.apply(a, arguments); //把引數轉換為陣列
Base.call(this, a.length); //呼叫Base類,並把引數陣列長度傳遞給它
this.add = function () { //私有方法,把引數陣列補加到陣列a中並返回
return a.push.apply(a, arguments)
}
this.geta = function () { //私有方法,返回陣列a
return a;
}
}
Parent.prototype = new Base(); //設定Parent原型為Base的範例,建立原型鏈
Parent.prototype.constructor = Parent; //恢復Parent類原型物件的構造器
Parent.prototype.str = function (){ //原型方法,把陣列轉換為字串並返回
return this.geta().toString();
}
//子類Sub
function Sub () { //建構函式
Parent.apply(this, arguments); //呼叫Parent類,並把引數陣列長度傳遞給它
this.sort = function () { //私有方法,以字元順序對陣列進行排序
var a = this.geta(); //獲取陣列的值
a.sort.apply(a, arguments); //呼叫陣列排序方法 sort()對陣列進行排序
}
}
Sub.prototype = new Parent(); //設定Sub原型為Parent範例,建立原型鏈
Sub.prototype.constructor = Sub; //恢復Sub類原型物件的構造器
//父類別Parent的範例繼承類Base的成員
var parent = new Parent (1, 2, 3, 4); //範例化Parent類
console.log(parent.get()); //返回4,呼叫Base類的方法get()
console.log(parent.has()); //返回true,呼叫Base類的方法has()
//子類Sub的範例繼承類Parent和類Base的成員
var sub = new Sub (30, 10, 20, 40); //範例化Sub類
sub.add(6, 5); //呼叫Parent類方法add(),補加陣列
console.log(sub.geta()); //返回陣列30,10,20,40,6,5
sub.sort(); //排序陣列
console.log(sub.geta()); //返回陣列10,20,30,40,5,6
console.log(sub.get()); //返回4,呼叫Base類的方法get()
console.log(sub.has()); //返回true,呼叫Base類的方法has()
console.log(sub.str()); //返回10,20,30,40,5,6
【設計思路】
設計子類 Sub 繼承父類別 Parent,而父類別 Parent 又繼承基礎類別 Base。Base、Parent、Sub 三個類之間的繼承關係是通過在子類中呼叫的建構函式來維護的。
例如,在 Sub 類中,Parent.apply(this, arguments); 能夠在子類中呼叫父類別,並把子類的引數傳遞給父類別,從而使子類擁有父類別的所有屬性。
同理,在父類別中,Base.call(this, a.length); 把父類別的引數長度作為值傳遞給基礎類別,並進行呼叫,從而實現父類別擁有基礎類別的所有成員。
從繼承關係上看,父類別繼承了基礎類別的私有方法 get(),為了確保能夠繼承基礎類別的原型方法,還需要為它們建立原型鏈,從而實現原型物件的繼承關係,方法是新增語句行 Parent.prototype=new Base();。
同理,在子類中新增語句 Sub.prototype=new Parent();,這樣通過原型鏈就可以把基礎類別、父類別和子類串連在一起,從而實現子類能夠繼承父類別屬性,還可以繼承基礎類別的屬性。
範例2
下面嘗試把類繼承模式封裝起來,以便規範程式碼應用。
function extend (Sub, Sup) { //類繼承封裝函數
var F = function () {}; //定義一個空函數
F.prototype = Sup.prototype; //設定空函數的原型為父類別的原型
Sub.prototype = new F (); //範例化空函數,並把父類別原型參照傳給給子類
Sub.prototype.constructor = Sub; //恢復子類原型的構造器為子類自身
Sub.sup = Sup.prototype; //在子類定義一個私有屬性儲存父類別原型
//檢測父類別原型構造器是否為自身
if (Sup.prototype.constructor == Object.prototype.constructor) {
Sup.prototype.constructor = Sup; //類繼承封裝函數
}
}
【操作步驟】
1) 定義一個封裝函數。設計入口為子類和父類別物件,函數功能是子類能夠繼承父類別的所有原型成員,不涉及出口。
function extend (Sub, Sup) { //類繼承封裝函數
//其中引數Sub表示子類,Sup表示父類別
}
2) 在函數體內,首先定義一個空函數 F,用來實現功能中轉。設計它的原型為父類別的原型,然後把空函數的範例傳遞給子類的原型,這樣就避免了直接範例化父類別可能帶來的系統負荷。因為在實際開發中,父類別的規模可能會很大,如果範例化,會佔用大量記憶體。
3) 恢復子類原型的構造器為子類自己。同時,檢測父類別原型構造器是否與 Object 的原型構造器發生耦合。如果是,則恢復它的構造器為父類別自身。
下面定義兩個類,嘗試把它們系結為繼承關係。
function A (x) { //建構函式A
this.x = x; //私有屬性x
this.get = function () { //私有方法get()
return this.x;
}
}
A.prototype.add = function () { //原型方法add()
return this.x + this.x;
}
A.prototype.mul = function () { //原型方法mul()
return this.x * this.x;
}
function B (x) { //建構函式B
A.call (this.x); //在函數體內呼叫建構函式A,實現內部資料系結
}
extend (B, A); //呼叫封裝函數,把A和B的原型捆綁在一起
var f = new B (5); //範例化類B
console.log(f.get()); //繼承類A的方法get(),返回5
console.log(f.add()); //繼承類A的方法add(),返回10
console.log(f.mul()); //繼承類A的方法mul(),返回25
在函數類封裝函數中,有這麼一句 Sub.sup=Sup.prototype;,在上面程式碼中沒有被利用,那麼它有什麼作用呢?為了解答這個問題,先看下面的程式碼。
extend (B, A);
B.prototype.add = function () { //為B類定義一個原型方法
return this.x + "" + this.x;
}
上面的程式碼是在呼叫封裝函數之後,再為 B 類定義了一個原型方法,該方法名與基礎類別中原型方法 add() 同名,但是功能不同。如果此時測試程式,會發現子類 B 定義的原型方法 add() 將會覆蓋父類別 A 的原型方法 add()。
console.log(f.add()); //返回字串55,而不是數值10
如果在 B 類的原型方法 add() 中呼叫父類別的原型方法 add(),避免程式碼耦合現象發生。
B.prototype.add = function () { //定義子類B的原型方法add()
return B.sup.add.call(this); //在函數內部呼叫父類別方法add()
}