雖然在 ES6 中,已經出了 class
的語法,貌似好像不用瞭解 ES5 中的這些老東西了,但是越深入學習,你會發現理解這些模式的重要性。
在本文中,我會描述 7 種常用的創建自定義類型的模式:工廠模式、構造函數模式、原型模式、組合使用構造函數模式、動態原型模式、寄生構造函數模式、穩妥構造函數模式。分別給出他們的示例代碼,並分析他們的利弊,方便讀者選擇具體的方式來構建自己的自定義類型。
最後,我會指出 ES6 中的 class
語法,本質上其實還是利用了組合使用構造函數模式進行創建自定義類型。
1. 工廠模式
廢話不多說,先上工廠模式的實例代碼:
function createPerson(name, age, job){
var o = new Object(); // 創建對象
o.name = name; // 賦予對象細節
o.age = age; // 賦予對象細節
o.job = job; // 賦予對象細節
o.sayName = function(){ // 賦予對象細節
alert(this.name);
};
return o; // 返回該對象
}
var person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");
優點:解決了創建多個相似對象的問題;
缺點:沒有解決對象識別的問題(即不知道這個對象是什麼類型),對於對象的方法沒有做到複用。
2. 構造函數模式
function Person(name, age, job){
this.name = name; // 對象的所有細節全部掛載在 this 對象下面
this.age = age;
this.job = job;
this.sayName = function(){
alert(this.name);
};
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
說到構造函數模式就不得不提到 new
操作符了。我們來看看 new
這個操作符到底做了什麼:
- 創建一個對象;
- 將構造函數內的 this 指向這個新創建的對象,同時將該函數的 prototype 的引用掛載在新對象的原型下;
- 執行函數內的細節,也就是將屬性和方法掛載在新對象下;
- 隱式的返回新創建的對象。
優點:解決了對象識別的問題;
缺點:對於自定義類型的方法每次都要新創建一個方法函數實例,沒有做到函數複用。如果把所有方法函數寫到父級作用域中,是做到了函數複用,但同時方法函數只能在父級作用域的某個類型中進行調用,這對於父級作用域有點名不副實,同時對於自定義引用類型沒有封裝性可言。
3. 原型模式
function Person(){
}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person1 = new Person();
person1.sayName(); //"Nicholas"
var person2 = new Person();
person2.sayName(); //"Nicholas"
alert(person1.sayName == person2.sayName); //true
理解要點:
- 無論什麼時候,只要創建了一個新函數,就會根據一組特定規則爲該函數創建一個 prototype 屬性,這個屬性指向函數的原型對象。
- 在默認情況下,所有原型對象都會自動獲得一個 constructor 屬性,這個屬性包含一個指向 prototype 屬性所在函數的指針。至於原型中的其他方法則都是從 Object 繼承而來。
- 當調用構造函數創建了一個新實例後,該實例的內部將包含一個指針 [[prototype]](內部屬性) ,指向構造函數的原型對象。
- 當調用構造函數創建一個新實例後,該實例的實例環境,即構造函數,會針對原型對象上的非引用類型的原型屬性,在構造函數中自動構建相應的實例環境屬性。也就是說,之後根據構造函數創建的實例,它的實例屬性中的非引用類型屬性,都仍是根據構造函數中的實例環境屬性創建的。
但是爲減少不必要的輸入,也爲了從視覺上更好地封裝原型的功能,更常見的做法是用一個包含所有屬性和方法的對象字面量來重寫整個原型對象。如下所示:
function Person(){
}
Person.prototype = {
name : "Nicholas",
age : 29,
job: "Software Engineer",
sayName : function () {
alert(this.name);
}
};
但是這種寫法,其本質上完全重寫了默認的 prototype 對象,因此 constrctor 屬性也就變成了新對象的 constructor 屬性(指向 Object 構造函數),不在指向 Person 函數。儘管此時,instanceOf
操作符還能返回正確的結果。
如果 constructor 屬性真的很重要,可以像下面這樣特意將它設置回適當的值:
function Person(){
}
Person.prototype = {
constructor : Person,
name : "Nicholas",
age : 29,
job: "Software Engineer",
sayName : function () {
alert(this.name);
}
};
注意,以這種方式重設 constructor 屬性會導致他的 [[Enumerable]] 特性被設置爲 true 。默認情況下,原生的 constructor 屬性是不可枚舉的,因此,如果你使用兼容 ECMAScript 5 的 JavaScript 引擎,你可以試試 Object.defineProperty()
方法:
function Person(){
}
Person.prototype = {
name : "Nicholas",
age : 29,
job : "Software Engineer",
sayName : function () {
alert(this.name);
}
};
//重設構造函數,只適用於 ECMAScript 5 兼容的瀏覽器
Object.defineProperty( Person.prototype, "constructor", {
enumerable: false,
value: Person
});
注意,重寫原型對象會切斷新原型與已經存在的對象實例之間的聯繫;它們引用的仍然是最初的原型。
優點:對自定義類型的方法解決了函數複用的問題。
缺點:
- 不能爲構造函數傳遞初始化參數;
- 原型模式中實現了對於包含引用類型值的屬性的共享,這就意味着一個實例中修改了該引用類型值,所有實例的該屬性都會被修改!!!
4. 組合使用構造函數模式和原型模式
在組合使用構造函數模式和原型模式中,構造函數模式用於定義實例屬性,而原型模式用於定義方法和共享的屬性,而且還支持向構造函數傳遞參數。如以下示例代碼所示:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.friends = ["Shelby", "Court"];
}
Person.prototype = {
sayName : function(){
alert(this.name);
}
}
Object.defineProperty( Person.prototype, "constructor", {
enumerable: false,
value: Person
);
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
person1.friends.push("Van");
alert(person1.friends); //"Shelby,Count,Van"
alert(person2.friends); //"Shelby,Count"
alert(person1.friends === person2.friends); //false
alert(person1.sayName === person2.sayName); //true
優點:能爲構造函數傳遞初始化參數;該複用複用,不該複用的沒複用。
缺點:封裝性不好,構造函數和原型分別獨立於父級作用域進行申明。
5. 動態原型模式(推薦)
該模式把所有信息都封裝在構造函數中,通過構造函數來實現初始化原型 (僅在必要的情況下),又保持了同時使用構造函數和原型的優點。請看以下示例代碼:
function Person(name, age, job){
//屬性
this.name = name;
this.age = age;
this.job = job;
//方法
if (typeof this.sayAge != "function"){ // 此處應該永遠去判斷新添加的屬性和方法
Person.prototype.sayName = function(){
alert(this.name);
};
Person.prototype.sayAge = function(){
alert(this.age);
};
}
}
var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName();
if 語句檢查的可以是初始化之後應該存在的任何屬性或方法——不必用一大堆 if 語句檢查每個屬性和每個方法;只要檢查其中一個即可。
注意,使用動態原型模式時,不能使用對象字面量重寫原型。前面已經解釋過了,如果已經創建的實例的情況下重寫原型,那麼就會切斷新原型與現有實例之間的聯繫。
優點:封裝性非常好;還可使用 instanceOf
操作符確定它的類型。
缺點:無。
6. 寄生構造函數模式
除了使用 new
操作符並把使用的包裝函數叫做構造函數之外,這個模式跟工廠模式其實是一模一樣的。請看以下代碼:
function Person(name, age, job){
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function(){
alert(this.name);
};
return o;
}
var friend = new Person("Nicholas", 29, "Software Engineer");
friend.sayName(); //"Nicholas"
在使用 new
操作符下,構造函數在不返回值的情況下,默認會返回新對象實例。而通過在構造函數的末尾添加一個 return
語句,可以重寫調用構造函數時返回的值。
缺點:沒有解決對象識別的問題(即不知道這個對象是什麼類型),不能依賴 instanceOf
操作符來確定對象類型;對於對象的方法沒有做到複用。
7. 穩妥構造函數模式
先來了解下穩妥對象:指的是沒有公共屬性,而且其方法也不引用 this 的對象。穩妥對象最適合在一些安全的環境中 (這些環境中會禁止使用 this 和 new),或者再防止數據被其他應用程序 (如 Mashup 程序) 改動時使用。穩妥構造函數遵循與寄生構造函數類似的模式,但有兩點不同:一是新創建對象的實例方法不引用 this;二是不使用 new
操作符調用構造函數。以下爲示例代碼:
function Person(name, age, job){
var o = new Object(); //創建要返回的對象
//可以在這裏定義私有變量和函數
o.sayName = function(){ //添加方法
alert(name);
};
return o; //返回對象
}
var friend = Person("Nicholas", 29, "Software Engineer");
friend.sayName(); //"Nicholas"
其原理就是利用閉包,保有對私有變量和私有方法的引用。
優點:不可能有別的方法訪問到傳入到構造函數中的原始數據。
缺點:沒有解決對象識別的問題(即不知道這個對象是什麼類型),不能依賴 instanceOf
操作符來確定對象類型;對於對象的方法沒有做到複用。
8. ES6 中的 class
咱們這塊以 class
實例來展開講述:
class Parent {
name = "qck";
sex = "male";
//實例變量
sayHello(name){
console.log('qck said Hello!',name);
}
constructor(location){
this.location = location;
}
}
我們來看看這段代碼通過 babel 編譯後的 _createClass
函數:
var _createClass = function () {
function defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
// 對屬性進行數據特性設置
descriptor.enumerable = descriptor.enumerable || false; // enumerable設置
descriptor.configurable = true; // configurable設置
if ("value" in descriptor) descriptor.writable = true; // 如果有value,那麼可寫
Object.defineProperty(target, descriptor.key, descriptor); // 調用defineProperty() 進行屬性設置
}
}
return function (Constructor, protoProps, staticProps) {
// 設置到第一個 Constructor 的 prototype 中
if (protoProps) defineProperties(Constructor.prototype, protoProps);
// 設置 Constructor 的 static 類型屬性
if (staticProps) defineProperties(Constructor, staticProps);
return Constructor;
};
}();
首先該方法是一個自執行函數,接收的一參是構造函數本身,二參是爲構造函數的原型對象需要添加的方法或者屬性,三參是需要爲構造函數添加的靜態屬性對象。從這個函數就可以看出 class
在創建自定義類型時,用了原型模式。
我們看看編譯後的結果是如何調用 _createClass
的:
var Parent = function () { // 這裏是自執行函數
_createClass(Parent, [{ // Parent的實例方法,通過修改Parent.prototype來完成
key: "sayHello",
value: function sayHello(name) {
console.log('qck say Hello!', name);
}
}]);
function Parent(location) { //在Parent構造函數中添加實例屬性
_classCallCheck(this, Parent);
this.name = "qck";
this.sex = "male";
this.location = location;
}
return Parent;
}();
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
這裏調用 _createClass
的地方就證實了我們剛纔的想法——確實應用了原型模式:我們的 class
上的方法,其實是通過修改該類 (實際上是函數) 的 prototype 來完成的。
而通過返回的構造函數,我們可以發現:實例屬性還是通過構造函數方式來添加的。
最後,我們來看看 _classCallCheck
方法,它其實是一層校驗,保證了我們的實例對象是特定的類型。
所以,綜上所述,ES6 中的 class
只是個語法糖,它本質上還是用組合使用構造函數模式創建自定義類型的,這也就是爲什麼我們要學上面那些知識的初衷。