面向對象的程式設計之JS創建對象的9種模式及其優缺點

1.new Object ()

var person = new Object();
person.name = "Echo";
person.age = 18;
person.say = function(){
    console.log(this.name);
}

2.字面式創建對象

var person ={
    name: "Echo",
    age: 18,
    say: function(){
         console.log(this.name);
    }
};

3.工廠模式

雖然Object構造函數或對象字面量都可以用來創建單個對象,但這些方式有個明顯的缺點:使用一個介面創建很多對象,會產生大量的重複程式碼。為解決這個問題,開始使用工廠模式的一種變體。

這種模式抽象了創建具體對象的過程,用函數來封裝以特定介面創建對象的細節。

function createPerson(name, age, job){
	var o = new Object();
    o.name = name; 
    o.age = age; 
    o.job = job;
	o.sayName = function(){
		console.log(this.name);
    };
	return o;
};
var person1 = createPerson("Echo",18,"Software Engineer");
var person2 = createPerson("yya",17,"Doctor");

createPerson()函數能夠根據接受的參數來構建一個包含所有必要資訊的Person對象。可以無數次地調用這個函數,而每次它都會返回一個包含三個屬性一個方法的對象。工廠模式雖然解決了創建多個相似對象的問題,但卻沒有解決對象識別的問題,即怎樣知道一個對象的類型。

4.構造函數模式

構造函數可用來創建特定類型的對象。像Object和Array這樣的原生構造函數。在運行時會自動出現在執行環境中。此外,也可以創建自定義的構造函數,從而定義自定義對象類型的屬性和方法。可以使用構造函數模式將上述例子重寫如下。

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
	this.sayName = function(){
		console.log(this.name);
    };
};
var person1 = new Person("Nicho1as",18,"Software Engineer") ;
var person2 = new Person("yya",17,"Doctor");

Person()函數取代了createPerson()函數。Person()中的程式碼除了與createPerson()中相同的部分外,還存在以下不同之處:

  • 沒有顯式的創建對象;
  • 直接將屬性和方法賦給了this對象;
  • 沒有return語句;
  • 數首字母大寫;

按照慣例,構造函數始終都應該以一個大寫字母開頭,而非構造函數則應該以一個小寫字母開頭。主要是為了區別於其他函數,因為構造函數本身也是函數,只不過可以用來創建對象而已。
經典:要創建Person的新實例,必須使用new操作符。以這種方式調用構造函數實際上會經歷以下4個步驟:

  1. 創建一個新對象;
  2. 將構造函數的作用域賦給新對象(因此this就指向了這個新對象);
  3. 執行構造函數中的程式碼(為這個新對象添加屬性);
  4. 返回新對象.

person1和person2是Person的一個不同的實例。都存在一個constructor(構造函數)屬性,該屬性指向 Person。對象的constructor屬性最初是用來標識對象類型的。這個例子中創建的所有對象既是Object的實例,同時也是Person的實例,通過instanceof操作符可以得到驗證。

person1.constructor == Person;  //true
person2.constructor == Person;  //true

person1 instanceof Object;  //true
person1 instanceof Person;  //true
person2 instanceof Object;  //true
person2 instanceof Person;  //true

創建自定義的構造函數意味著將來可以將它的實例標識為一種特定的類型,而這正是構造函數模式勝過工廠模式的地方。

4.1.將構造函數當作函數

構造函數與其他函數的唯一區別,就在於調用它們的方式不同。不過,構造函數畢竟也是函數,不存在定義構造函數的特殊語法。任何函數,只要通過new操作符來調用那它就可以作為構造任何雨數。如果不通過new操作符來調用,那它跟普通函數也不會有什麼兩樣。
Person()函數可以通過下列任何一種方式來調用:

function Person(name, age, job){
    this.name = name;
    this.age = age;
    this.job = job;
	this.sayName = function(){
		console.log(this.name);
    };
};

// 1.作為構造函數使用
var person = new Person("Echo",18,"Software Engineer"); 
person.sayName(); // Echo

// 2.作為普通函數調用
Person("yya",17,"Doctor");// 添加到window,
window.sayName(); // yya

// 3.在另一個對象的作用域中調用 
var o = new Object();
Person.call(o,"Kristen",25,"Nurse");
o.sayName(); // Kristen 
  1. 作為構造函數調用:使用new操作符來創建一個新對象。
  2. 作為普通函數調用:不使用new操作符,屬性和方法都被添加給window對象上。全局作用域中調用一個函數時,this對象總是指向window對象。
  3. 在另一個對象的作用域中調用:可以使用call()或者apply()在某個特殊對象的作用域中調用Person()函數。因此調用後o就擁有了所有屬性和方法。

4.2.構造函數的問題

使用構造函數的主要問題:就是每個方法都要在每個實例上重新創建一遍。因此每個Person實例都包含一個不同的Function實例,不同實例上的同名函數是不相等的,以這種方式創建函數,會導致不同的作用域鏈和標識符解析。
然而,創建兩個完成同樣任務的Function實例的確沒有必要;可像下面這樣,通過把函數定義轉移到構造函數外部來解決這個問題。

function Person(name, age, job){
   this.name = name;
   this.age = age; 
   this.job = job;
   this.say = sayName;
};
function sayName(){
   console.log(this.name);
}
var personl = new Person("Echo", 18,"Software Engineer"); 
var person2 =  new Person("yya",17,"Doctor");

personl.sayName();    // Echo
person2.sayName()     // yya

將sayName()函數的定義到了構造函數外部。而在構造函數內部,將say屬性設置成等於全局的sayName函數。這樣一來,由於say包含的是一個指向函數的指針,因此person1和person2對象就共享了在全局作用域中定義的同一個sayName()函數。

這樣做確實解決了兩個函數做同一件事的問題,可是新問題又來了:在全局作用域中定義的函數實際上只需要被某個對象調用,這讓全局作用域有點名不副實。而更讓人無法接受的是:如果對象需要定義很多方法,那麼就要定義很多個全局函數,此時這個自定義的引用類型就絲毫沒有封裝性可言。好在,這些問題可以通過使用原型模式來解決。

5.原型模式

創建的每個函數都有一個prototype原型屬性,是一個指針指向一個對象,而這個對象的用途是包含特定類型的所有實例共享的屬性和方法。那麼prototype就是通過調用構造函數而創建的那個對象實例的原型對象。使用原型對象的好處是可以讓所有對象實例共享它所包含的屬性和方法。不必在構造函數中定義對象實例的資訊,而是可以將這些資訊直接添加到原型對象中。

function Person(){};

Person.prototype.name = "Echo"; 
Person.prototype.age = 18;
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function(){
	console.log(this.name);
};
var person1 = new Person();
var person2 = new Person();
person1.sayName(); // Echo  
person2.sayName(); // Echo 
console.log(person1.sayName == person2.sayName); // true

將所有屬性和方法直接添加到了 Person的prototype上,構造函數變成了空函數。即便如此,也仍然可以通過調用構造函數來創建新對象,且新對象會具有相同的性和方法。這些屬性和方法是由所有實例共享的。

5.1.理解原型對象

只要創建了一個新函數,就會根據一組特定的規則為該函數創建一個prototype屬性。這個屬性指向函數的原型對象。在默認情況下,所有原型對象都會自動獲得一個constructor構造函數屬性,這個屬性是一個指向prototype屬性所在函數的指針。
創建了自定義的構造函數之後,其原型對象默認只會取得constructor屬性;至於其他方法,則都是從object 繼承而來的。當調用構造函數創建一個新實例後,該實例的內部將包含一個內部屬性指針,指向構造數的原型對象。ECMA-262第5版中管這個指針叫[[Prototype]]。雖然在腳本中沒有標準的方式訪問[[Prototype]],但Firefox、Safari 和Chrome 在每個對象上都支援一個屬性__proto__,這個連接存在於實例與構造函數的原型對象之間,而不是存在於實例與構造函數之間。

Person.prototype指向了原型對象,Person.prototype.constructor又指回了 Person。

原型對象中除了包含 constructor屬性之外,還包含後來添加的其他屬性。Person的每個實例都包含一個內部屬性,該屬性指向了Person.prototype。

雖然所有實現中都無法直接訪問[[Prototype]],但可以通過isPrototypeOf()方法來確定對象之間是否存在原型關係。從本質上講,如果對象實例的__proto__指向調用isPrototypeof()方法的對象的原型對象,那麼就返回true。

console.log(Person.prototype.isPrototypeOf(person1)); // true 
console.log(Person.prototype.isPrototypeof(person2)); // true

原型Person對象的isPrototypeOf()方法測試了 person1和person2。因為它們內部都有一個指向 Person.prototype 的指針,因此都返回了true
ES5增加了一個新方法,叫Object.getPrototypeof(),在所有支援的實現中。這個方法返回[[Prototype]]的值。 返回的對象實際就是這個對象的原型。

console.log(Object.getPrototypeOf(person1) == Person.prototype);  // true 
console.log(Object.getPrototypeOf(person1).name); // "Echo

每當程式碼讀取某個對象的某個屬性時,都會執行一次搜索,搜索首先從對象實例本身開始。如果在實例中找到了該屬性,則返回該屬性的值;若未找到。則繼續搜索指針指向的原型對象,在原型對象中查找該屬性。若在原型對象中找到該屬性,則返回該屬性的值。而這正是多個對象實例共享原型所保存的屬性和方法的基本原理。

雖然可以通過對象實例訪問保存在原型中的值,但卻不能通過對象實例重寫原型中的值。在實例中添加了一個屬性,而該屬性與實例原型中的屬性同名,那就在實例中創建該屬性,該屬性將會屏蔽原型中的那個同名屬性。

function Person(){}

Person.prototype.name = "Echo" ;

var person1 = new Person();
var person2 = new Person();
person1.name = "yya";
console.log(person1.name); //"yya" --來自實例 
console.log(person2.name); //"Echo" --來自原型 

添加這個屬性只會屏蔽我們訪問原型中的那個屬性,但不會修改那個屬性。使用 delete 操作符可以刪除實例屬性,從而能夠重新訪問原型中的屬性。

function Person(){}

Person.prototype.name = "Echo" ;

var person1 = new Person();
var person2 = new Person();
person1.name = "Greg";
console.log(person1.name); // "Greg" --來自實例 
console.log(person2.name); // "Echo" --來自原型 
    
delete person1.name;
console.log(person1.name); // "Echo" --來自原型 

使用hasOwnProperty()方法可以檢測一個屬性是存在於實例中,還是原型中。只有給定屬性存在於對象實例中時,才會返回true。

function Person(){}

Person.prototype.name = "Echo" ;

var person1 = new Person();
var person2 = new Person();
    
console.log(person1.hasOwnProperty("name"));  // false

person1.name = "yya";
console.log(person1.name);   // "yya" - 來自實例
console.log(person1.hasOwnProperty("name"));  // true

console.log(person2.name);   // "Echo" - 來自原型
console.log(person2.hasOwnProperty("name"));  // false
           
delete person1.name;
console.log(person1.name);   // "Echo" -來自原型
console.log(person1.hasOwnProperty("name"));  // false

ES5的Object.getOwnPropertyDescriptor()方法只能用於實例屬性,要取得原型屬性的描述符,必須直接在原型對象上調用Object.getOwnPropertyDescriptor()方法。

function Person(){}

Person.prototype.name = "Echo" ;
 
var person1 = new Person();
var person2 = new Person();
    
person1.name = "yya";

console.log(Object.getOwnPropertyDescriptor(person1, 'name'));
console.log(Object.getOwnPropertyDescriptor(Person.prototype, 'name'));

5.2.原型與in操作符

有兩種方式使用in操作符:單獨使用和在for-in循環中使用。

單獨使用時in操作符:對象能夠訪問指定屬性時返回true,無論該屬性存在於實例中還是原型中。

function Person(){}
Person.prototype.name = "Echo"; 
 
var person1 = new Person(); 

console.log(person1.hasOwnProperty("name")); // false 
console.log("name" in person1); // true

console.log(person1.name); // Echo  -來自原型 
person1.name = "yya";
console.log(person1.name); // yya --來自實例
console.log(person1.hasOwnProperty("name")); // true 
console.log("name" in person1); // true

hasOwnProperty()方法和in操作符配合使用。只要in操作符返回true,而hasOwnProperty()返回 false,就可以確定屬性是原型中的屬性。函數的返回值最終返回true說明該屬性一定存在於原型上,而返回false的情況比較複雜,並不能直接判斷該屬性到底是存在於實例上還是原型上,也有可能判斷的屬性根本就不存在。

function Person(){}
Person.prototype.name = "Echo"; 
Person.prototype.age = 18;
 
var person1 = new Person(); 
person1.name = "yya";
person1.job = "Software Engineer"; 

function hasPrototypeProperty(obj,name){ 
	console.log(!obj.hasOwnProperty(name),(name in obj))
	return !obj.hasOwnProperty(name) && (name in obj);
};

hasPrototypeProperty(person1,'name')  // !true短路 = false  (存在於實例,且存在於原型,短路)
hasPrototypeProperty(person1,'job')   // !true短路 = false  (存在於實例,不存在於原型,短路)
hasPrototypeProperty(person1,'age')   // !false && true = true (不存在於實例,僅存在於原型)
hasPrototypeProperty(person1,'1212')  // !false && false = false (不存在於實例,不存在於原型)

for-in 循環:,返回的是所有能夠通過對象訪問的、可枚舉的屬性,其中既包括存在於實例中的屬性,也包括存在於原型中的屬性。覆蓋了原型中不可枚舉屬性的實例屬性也會在for-in 循環中返回。

function Person(){}
Person.prototype.age = 18;
var person = new Person(); 

Object.defineProperty(person, "name", {
  writable: false,      // 表示能改修改屬性的值
  configurable: true,   // 表示是否可以從對象中刪除屬性
  enumerable:false,     // 表示能否通過for in 循環返回屬性
  value: "Echo"     // 屬性值
});
person.toString = function(){}
for (var key in person) {
    console.log(key);   // age toString
}
for (var key in person) {
  if (Object.hasOwnProperty.call(person, key)) {
    console.log(key);    // toString
  }
}

要想取得對象上所有可枚舉的實例屬性,可以使用ES5的Object.keys()方法。該方法接收一個對象作為參數,返回一個包含所有可校舉屬性的字元串數組。

function Person(){}
Person.prototype.name = "Echo";
Person.prototype.age = 18; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function(){
	console.log(this.name);
}
var keys = Object.keys(Person.prototype);
console.log(keys) //  ['name', 'age', 'job', 'sayName']

var p1 = new Person();
p1.name="Rob";
p1.age = 32;
var p1keys = Object.keys(p1);
console.log(p1keys); // ['name', 'age']

如果想要得到所有實例屬性,無論是否可枚舉,可以使用Object.getOwnPropertyNames()方法。

var keys = Object.getOwnPropertyNames(Person.prototype); 
console.log(keys); // ['constructor', 'name', 'age', 'job', 'sayName']

結果中包含了不可枚舉的constructor屬性。Object.keys()和Object.getOwnPropertyNames()方法都可以用來替代for-in循環。

5.3.更簡單的原型語法

前面例子中每添加一個屬性和方法就要敲一遍Person.prototype。為減不必要的輸入,也為了從視覺上更好地封裝原型的功能,更常見的做法是用一個包含所有屬性和方法對象字面量來重寫整個原型對象,

function Person(){
    this.sayName = function(){
		console.log('Echo');
	}
}

var p = new Person()
Person.prototype = {
	name: "Echo",
    age: 18,
	job: "Software Engineer",
    sayName: function(){
		console.log(this.name);
	}
};
p.sayName()

本質上完全重寫了默認的prototype對象,此時constructor屬性也就變成了新對象的constructor屬性,指向Object構造函數,不再指向Person函數。 如果constructor的值很重要,可以手動設置值。

function Person(){}

Person.prototype = {
	constructor: Person, 
	name: "Echo" ,
	age: 18,
	job: "Software Engineer" ,
        sayName: function(){
		console.log(this.name);
	}
}

但是值得注意的是:以這種方式重設constructor屬性會導致它的[[Enumerable]]特性被設置為true。默認情況下,原生的 constructor 屬性是不可枚舉的,可以試試Object.defineProperty()。

Object.defineProperty(Person.prototype,"constructor",{ 
	Enumerable: false,
	value:  Person
});

5.4.原型的動態性

由於在原型中查找值的過程是一次搜索,因此我們對原型對象所做的任何修改都能夠立即從實例上反應出來。即使是先創建了實例後修改原型也照樣如此。

var p1 = new Person();
Person.prototype.sayHi = function(){
	console.log("Hi");   // Hi
};

先創建了一個實例,然後在Person. prototype中添加了一個方法sayHi。即使實例是在添加新方法之前創建的,仍然可以訪問這個新方法。其原因可以歸結為實例與原型之間的鬆散連接關係。因為實例與原型之間的連接只不過是一個指針,而非一個副本,可以隨時為原型添加屬性和方法。

但如果是重寫整個原型對象,把原型修改為另外一個對象就等於切斷了構造函數與最初原型之間的聯繫。因為調用構造函數時會為實例添加一個指向最初原型的 [[Prototype]]指針,實例中的指針僅指向原型,而不指向構造函數。

function Person(){}
var p1 = new Person(); 
Person.prototype = {
    constructor: Person, 
    name:"Echo", 
    age: 18,
    job:"Software Engineer",
    sayName: function (){
    	console.log(this.name);
    }
};
p1.sayName(); // TypeError: p1.sayName is not a function 

5.5.原生對象的原型

原型模式的重要性不僅體現在創建自定義類型方面,所有原生的引用類型,都是採用這種模式創建的。所有原生引用類型都在其構造函數的原型上定義了方法。

console.log(typeof Array.prototype.sort);  // function
console.log(typeof String.prototype.substring); // function

通過原生對象的原型,不僅可以取得所有默認方法的引用,而且也可以定義新方法。可以像修改自定義對象的原型一樣修改原生對象的原型,因此可以隨時添加方法。儘管可以這樣做,但不推薦在產品化的程式中修改原生對象的原型。如果因某個實現中缺少某個方法。就在原生對象的原型中添加這個方法,那麼當在另一個支援該方法的實現中運行程式碼時,就可能會導致命名衝突。而且,這樣做也可能會意外地重寫原生方法。

5.6.原型對象的問題

它省略了為構造函傳遞初始化參數,結果所有實例在默認情況下都將獲得相同的屬性值。雖然會帶來一些不方便,但還不是原型的最大問題,原型模式的最大問題是由其共享的本性所導致的。
原型中所有屬性是被所有實例共享的,這種共享對於函數非常合適。對於基本值的屬性也不會有太大的問題,然而,對於引用類型的屬性來說,問題就比較突出了。

function Person(){}

Person.prototype = {
	constructor: Person, 
	name :"Echo", 
	age : 18, 
	job :"Software Engineer", 
	friends : ["Shelby", "Court"], 
	sayName : function (){
		console.log(this.name);
	}
};
var person1 = new Person(); 
var person2 = new Person(); 
person1.friends.push("Van");
console.log(person1.friends); // ['Shelby', 'Court', 'Van']
console.log(person2.friends); // ['Shelby', 'Court', 'Van']
console.log(person1.friends === person2.friends); // true

friends的屬性包含一個字元串數組。修改了person1.friends引用的數組,也會影響person2.friends,因為friends數組存在於Person.prototype而非person1中,所以二者的friends屬性指向同一個數組,實例一般都是要有僅屬於自己的屬性的。而這個問題正是我們很少看到有人單獨使用原型模式的原因所在。

6.組合使用構造函數模式和原型模式

創建自定義類型的最常見方式。就是組合使用構造函數模式與原型模式。其中構造函數模式用於定義實例屬性,而原型模式用於定義方法和共享的屬性。這樣一來,每個實例都會有自己的一份實例屬性的副本但同時又共享著對方法的引引用。最大限度地節省了記憶體。還支援向構造函數傳遞數。

是目前在ECMAScript中使用最廣泛、認同度最高的一種創建自定義類型的方法。可以說,這是用來定義引用類型的一種默認模式。

function Person(name, age, job){
    this.name = name; 
    this.age = age;
    this.job= job;
    this.friends = ["Shelby","Court"];
}
Person.prototype = {
    constructor : Person, 
    sayName : function(){
      console.log(this.name)  
    }
}

var person1 = new Person("Echo",18,"Software Engineer"); 
var person2 = new Person("yya",17,"Doctor");
person1.friends.push("Van");
console.log(person1.friends); // ['Shelby', 'Court', 'Van']
console.log(person2.friends); // ["Shelby","Court"]
console.log(person1.friends == person2.friends); // false 
console.log(person1.sayName == person2.sayName); // true 

7.動態原型模式

動態原模式把所有資訊都封裝在了構造函數中,通過構造函數初始化原型,保持了同時使用構造函數和原型的優點。其主要原理是可以通過查找某個應該存在的方法是否有效,來決定是否需要初始化原型。

function Person(name, age,job){
    //屬性
    this.name =name; 
    this.age = age; 
    this.job = job;

    //方法
    if (typeof this.sayName != "function"){
        Person.prototype.sayName = function(){
        	console.log(this.name);
        }
    }
}

var p1 = new Person("Echo", 18, "Software Engineer");
p1.sayName();

在sayName()方法不存在的情況下,將它添加到原型中。只會在初次調用構造函數時才會執行。原型完成初始化後對原型所做的修改,能夠立即在所有實例中得到反映。因此,這種方法可以說非常完美。其中,if語句檢查的可以是初始化之後應該存在的任何屬性或方法--不必用一大堆 if 檢查每個屬性和方法,只要檢查其中一個即可。

使用動態原型模式時,不能使用對象字面量重寫原型。因為如果在已經創建了實例的情況下重寫原型,那麼就會切斷現有實例與新原型之間的聯繫。

8.寄生構造函數模式

寄生構造函數模式:除了使用new操作符並把使用的包裝函數叫做構造函數之外,這個模式跟工廠模式其實模一樣的。構造函數在不返回值的情況下,默認會返回新對象實例。而通過在構造函數的末尾添加return語句,可以重寫調用構造函數時返回的值。

function Person(name, age, job){
    var o = new Object(); 
    o.name = name; 
    o.age = age; 
    o.job = job;
    o.sayName = function(){
    	console.log(this.name);
    }
    return o;
}
var p1 = new Person("Echo", 18,"Software Engineer");  // 寄生構造函數模式
var p2 = new Person("yya",17,"Doctor"); // 工廠模式
p1.sayName(); // Echo
p2.sayName(); // yya

返回的對象與構造函數或者與構造函數的原型屬性之間沒有關係,構造函數返回的對象與在構造函數外部創建的對象沒有什麼不同。因此不能依賴instanceof 操作符來確定對象類型。

9.穩妥構造函數模式

說到穩妥構造函數模式,首先要介紹一下穩妥對象這個概念,指的是沒有公共屬性,而且其方法也不引用this的對象。最適合在一些安全的環境中(這些環境中會禁止使用this和new)或者在防止數據被其他應用程式改動時使用。穩妥構造函數遵循與寄生構造函數類似的模式,但有兩點不同:

  1. 新創建對象的實例方法不引用this;
  2. 不使用new操作符調用構造函數。按照穩妥構造函數的要求,重寫一下Person構造函數。
function Person(name, age, job){
	var o = new Object();
    // 可以在這裡定義私有變數和函數
    
    // 添加方法
    o.sayName = function(){
        console.log(name); 
    }
	return o; 
}

var p1 = new Person("Echo", 18,"Software Engineer");   
p1.sayName()   // Echo

在以這種模式創建的對象中,除了使用sayName()方法之外,沒有其他辦法訪向name的值。變數p1中保存的是一個穩妥對象,而除了調用sayName()方法外,沒有別的方式可以訪問其數據成員。即使有其他程式碼會給這個對象添加方法或數據成員,但也不可能有別的辦法訪問傳入到構造函數中的原始數據,這種安全性,使得它非常適合在某些安全執行環境下使用。

同寄生構造函數模式,返回的對象與構造函數或者與構造函數的原型屬性之間也沒有關係,構造函數返回的對象與在構造函數外部創建的對象沒有什麼不同。因此同樣不能依賴instanceof 操作符來確定對象類型。

Tags: