JavaScript 建立物件的四種常見模式(附《高程3》下載地址)

艾倫先生發表於2017-12-14

寫在前面

因為本人想回顧一遍JavaScript基於原型繼承的相關知識,所以開始翻查過去的筆記和相關部落格,想來想去還是先從建立物件這一塊入手,這個地方講的比較清楚的應該首推《JavaScript高階程式設計指南》。下面的內容80%出自這本書的第五章,如果大家不想翻書的話,姑且可以先看看我的這篇抄書筆記~

後文附贈了這本聖經的高清第三版下載地址,希望能節省各位查詢下載的時間。下面開始介紹建立物件的幾種常見模式(寄生建構函式模式和穩妥建構函式模式等不常見的模式暫且忽略,等後面關於原型鏈的文章繼續填坑~)

工廠模式

考慮到ECMAScript無法建立類,開發人員就發明了一種函式,用函式來封裝以特定介面建立物件的細節,如下例子所示:

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('feng', 25, 'enginner');
var person2 = createPerson('yun', 52, 'doctor');
複製程式碼

可以無數次呼叫createPerson(), 每次都會返回一個包含三個屬性一個方法的物件。工廠模式雖然解決了建立多個相似物件的問題,但是無法解決物件識別的問題(型別都是Object,我們希望能返回型別為function並且具有特徵name的物件),於是就有了下面的建構函式模式。

JavaScript 建立物件的四種常見模式(附《高程3》下載地址)

建構函式模式

建構函式可以用來建立特定型別的物件

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('feng', 25, 'enginner');
var person2 = new Person('yun', 52, 'doctor');
複製程式碼

在本方法中我們使用到了new操作符來達到目的。以這種方式呼叫建構函式實際上會經歷一下四個步驟

  • 建立一個新物件(讓空物件的'_proto'屬性指向Person.prototype,詳見下文)
  • 將建構函式的作用域賦值給新物件(因此this指向了這個新物件)
  • 執行建構函式中的程式碼(為這個新物件新增屬性)
  • 返回新物件

建立自定義的建構函式意味著將來可以將它的例項標誌為一個特定的型別;而這正是建構函式模式勝過工廠模式的地方。

console.log(person1.constructor === Person);//true
console.log(person2.constructor === Person);//true

console.log(person1 instanceof Object);//true
console.log(person1 instanceof Person);//true
console.log(person2 instanceof Object);//true
console.log(person2 instanceof Person);//true
複製程式碼

何為建構函式

建構函式與其他函式唯一的區別,就在於呼叫他們的方式不同。不過,建構函式畢竟也是函式,不存在定義建構函式的特殊語法。任何函式,只要通過new操作符來呼叫,那他就可以當做建構函式;不通過new來呼叫,則就是普通函式。上面構造器模式中的Person()函式可以通過下面任何一種方式來呼叫:

var person = new Person('feng', 25, 'enginner');
console.log(person.sayName());//feng

Person('duang', 101, 'god');//函式直接呼叫,this繫結到window
window.sayName();//duang
複製程式碼

建構函式的問題

每個方法都要在每個例項上重新建立一遍。在前面的例子中,person1person2都有一個名為sayName()的方法,但那兩個方法不是同一個Function的例項(ECMAScript中,函式就是物件,因此每定義一個函式,也就例項化了一個物件)。從邏輯上講,此時的建構函式也可以這樣定義:

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

  this.sayName = new Function("console.log(this.name)")
}
複製程式碼

因此不同例項上的同名函式實不相等的

console.log(person1.sayName == person2.sayName);//false
複製程式碼

這樣做浪費記憶體,我們希望得到一種能把共享的屬性和方法放到同一個容器的模式,於是就有了下文的原型模式。

原型模式

原型模式初步

我們建立的每個函式在生成的一瞬間,都有一個prototype(原型)屬性,這個屬性是一個指標,指向一個物件,而這個物件的用途是包含可由特定型別的所有例項共享的例項和方法,這個物件一般稱為這個函式的 原型物件(prototype)。所有原型物件都會自動獲得一個constructor(建構函式)屬性,這個屬性包含一個指回建構函式。

使用原型物件的好處就是可以讓所有的例項物件共享原型所包含的屬性和方法。

function Person(){}

Person.prototype.name = 'Nicholas';
Person.prototype.age = '29';
Person.prototype.job = 'Software Engineer';
Person.prototype.sayName = function(){
  console.log(this.name);
}

var person1 = new Person();
person1.sayName();//feng

var person2 = new Person();
person.sayName();//feng

console.log(person1.sayName == person2.sayName);//true
複製程式碼

與建構函式模式不同的是,新物件的屬性和方法是有所有例項所共享的。

理解原型

預設情況下,具體的關係如下所示

原型鏈示意圖

注: (2)中,如果手動分配了原型指標(重寫了原型物件,比如將原型物件重新宣告為一個字面量物件),則需要手動為原型新增constructor屬性,重新建立建構函式和原型物件之間的關係 (5)中,Chrome 中可以通過_proto_訪問到該屬性,而在IE中是不可見的。

上面那段程式碼參考上圖的關係如下:

JavaScript 建立物件的四種常見模式(附《高程3》下載地址)

從上圖可見,建構函式和例項物件沒有直接關係!當直譯器讀取某個物件的某個屬性的時候,都會按照上圖中的實現執行一遍搜尋,目標是具有給定名字的屬性。搜尋首先從物件例項開始,如果在例項中找到該屬性則返回,如果沒有則繼續搜尋指標指向的原型物件(prototype),如果還是沒有找到則繼續遞迴prototypeprototype物件,直到找到為止,如果遞迴到object(原型鏈最頂端)仍然沒有則返回錯誤。

可以通過物件事例訪問儲存在原型中的值,但卻不能通過物件事例重寫原型中的值。如果在例項中定義和原型中同名的屬性或函式,則會事例中的屬性會覆蓋原型中的同名屬性。

重寫原型

上面例子中,每新增一個屬性和方法都要敲一遍Person.prototype。為了減少不必要的輸入,更常見的寫法是用一個包含所有屬性和方法的字面量物件來從斜原型物件,程式碼如下:

function Person(){}

Person.prototype = {
    name: 'Nicholas',
    age: '29',
    job: 'Software Engineer',
    sayName: function(){
      console.log(this.name);
    }
}
複製程式碼

但是這麼寫有一個例外:constructor屬性不再指向Person了。因為這樣寫,本質上重寫了prototype物件,因此constructor屬性也就變成了新的物件的constructor(指向Object建構函式),不再指向Person函式。此時儘管instanceOf操作符還能返回正確結果,但是通過constructor已經無法確定物件的型別了。

var friend = new Person();

console.log(friend instanceof Object);//true
console.log(friend.instanceof Person);//true
console.log(friend.constructor == Person);//false
console.log(friend.constructor == Object);//true
複製程式碼

所以如果constructor屬性真的很重要的話,可以向下面的方式特意將它設定為適當的值:

function Person(){}

Person.prototype = {
    constructor: Person,
    name: 'Nicholas',
    age: '29',
    job: 'Software Engineer',
    sayName: function(){
      console.log(this.name);
    }
}    
複製程式碼

in && hasOwnProperty

如何確認屬性是儲存在原型中還是儲存在例項物件中呢?組合使用inhasOwnProperty

  • in操作符,如果value in object 返回true則表示這個value屬性要麼能在例項中訪問,要麼儲存在原型中,反正能通過原型鏈訪問到

  • hasOwnProperty只在屬性存在於例項中時才返回true

    function hasPrototypeProperty(object, name) { return !object.hasOwnProperty && (name in object) }

上面這段程式碼返回true則表示,屬性在原型中而不在例項物件中

原型模式的問題

首先,他省略了為建構函式傳遞引數這個環節,結果所有屬性預設情況下都獲得了相同的屬性值。還有最重要的問題是由其共享的本質導致的。

原型中所有屬性被很多實力共享,這種共享對函式很適合,對那些事基本值的屬性也還說得過去,然而,對那些包含引用值得屬性來說,問題就比較大了:

function Person(){}

Person.prototype = {
    constructor: Person,
    name: 'Nicholas',
    age: '29',
    job: 'Software Engineer',
    friends:['aa', 'bb', 'cc'],
    sayName: function(){
      console.log(this.name);
    }
}

var person1 = new Person();
var person2 = new Person();

person1.friends.push('dd');

console.log(person1.friends);//aa,bb,cc,dd
console.log(person2.friends);//aa,bb,cc,dd
console.log(person1.friends === person2.friends);//true
複製程式碼

可見,一個事例修改原型中的某個引用型別屬性,其他事例都會受到影響。正是這個原因導致了很少有人單獨使用原型模式,於是就有了下文的混合模式。

組合使用原型模式和建構函式模式

組合使用原型模式和建構函式模式是最常見,應用最廣泛的建立自定義型別的方式:建構函式模式用來定義實力屬性,原型模式用來定義方法和共享的屬性。結果,每個例項都會有自己的一份例項屬性的副本,同時共享著對方法的引用。另外這種混合模式,還支援想建構函式傳遞引數。

function Person(name, age, job){      
    this.name = name;
    this.age =  age;
    this.job =  job;
    this.friends =['aa', 'bb'];
}

Person.prototype = {
    constructor: Person,
    sayName: function(){
      console.log(this.name);
    }
}

var person1 = new Person("Nicholas","29","Software Engineer");
var person2 = new Person("Grep","27","Doctoer");

person1.friends.push('cc');

console.log(person1.friends);//aa,bb,cc
console.log(person2.friends);//aa,bb
console.log(person1.friends === person2.friends);//false
console.log(person1.sayName === person2.sayName);//true
複製程式碼

原文推薦

相關文章