為什麼 JavaScript 要設計原型模式

Smallfly發表於2019-01-21

雖然 Object 建構函式或物件的字面量可以用來建立單個物件,但是這些方式有個明顯的缺點,建立相同結構的物件,會產生大量的重複程式碼。

const person1 = {
    name: 'Zhang san',
    age: 18,
    job: 'Engineer',
    sayName: function() {
        alert(this.name);
    }
};

const person2 = {
    name: 'Li si',
    age: 18,
    job: 'Engineer',
    sayName: function() {
        alert(this.name);
    }
};
複製程式碼

person1 和 person2 具有相同的屬性和方法,但它們之間沒有複用。為了解決這個問題,有人開始使用工廠模式的一種變體。

工廠模式

工廠模式抽象了建立具體物件的過程。因為在 JavaScript 中沒有類(ES6 中的類也是函式),開發人員就發明一種函式,用函式來封裝以特定介面建立物件的細節,如下面的示例:

function createPerson(name, age, job) {
    let o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function() {
        console.log(this.name);
    }
    return o;
}

const person1 = createPerson('Zhang san', 18, 'Engineer');
const person2 = createPerson('Li si', 18, 'Doctor');
複製程式碼

函式 createPerson() 能夠根據接受的引數構建一個包含所有必要資訊的 Person 物件。可以無數次的呼叫這個函式,每次都會返回全新的 Person 物件。

然而,工廠模式雖然解決了建立多個相似物件的問題,但是沒有解決物件的識別問題,即無法知道一個物件的型別。

隨著 JavaScript 的發展,又出現了一種新的模式。

建構函式模式

ECMAScript 中的建構函式可以用來建立特定型別的物件。像 Object 和 Array 這樣的原生建構函式,在執行時會自動在執行環境中呼叫。

因此,我們也可以為自定義物件設計建構函式。使用建構函式重寫前面的例子。

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

const person1 = new Person('Zhang san', 18, 'Engineer');
const person2 = new Person('Li si', 18, 'Doctor');
複製程式碼

Person() 函式取代了 createPerson() 函式。並且它們的程式碼有幾個不同之處:

  • 沒有顯式地建立物件;
  • 直接將屬性和方法賦值給 this 物件;
  • 沒有 return 語句。

要建立 Person 的新例項,必須使用 new 操作符。以這種方式呼叫建構函式會經歷 4 個步驟:

  1. 建立一個新物件;
  2. 將建構函式的作用域賦值給新物件(指向 this);
  3. 執行建構函式中的程式碼;
  4. 返回新物件。

使用 Person 建構函式建立物件時,物件會被新增一個 constructor 屬性,該屬性指向 Person,也就是建構函式的指標地址。

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

物件的 constructor 屬性可以用來標識物件的型別,這也是將 JavaScript 用於物件導向程式設計必不可少的特性。

但是在檢測型別時,使用 instanceof 操作符會更可靠一些, 因為 constructor 屬性有時可能會被修改。

我們來驗證一下:

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

如果測試 createPerson() 建立的物件是否是 Person 的例項,返回的會是 false。

1. 建構函式與普通函式的區別

建構函式與普通函式唯一的區別在於呼叫它們的方式不同。不過,建構函式畢竟也是函式,不存在定義建構函式的特殊語法。

任何函式,只要通過 new 操作符來呼叫,那麼就可以作為建構函式;而任何函式,如果不通過 new 操作符來呼叫,那它跟普通的函式也沒有什麼兩樣。

例如,前面例子定義的 Person() 函式可以通過下列任何一種方式呼叫。

// 作為建構函式使用
const person = new Person('Zhang san', 18, 'Engineer');
person.sayName(); // Zhang san

// 作為普通函式呼叫
Person('Li si', 18, 'Doctor');
global.sayName(); // Li si
複製程式碼

使用 new 操作符來建立新物件時,Person() 作為建構函式。而不使用 new 操作符直接呼叫,屬性和方法會被新增給 global 物件。

當在全域性作用域中呼叫一個函式時,this 物件總是指向 global 物件(瀏覽器中是 window 物件)。因此,在呼叫完函式之後,可以通過 window/global 物件來呼叫 sayName() 方法,並且返回正確的值。

2. 建構函式的問題

建構函式雖然好用但也有缺點。使用建構函式的主要問題,就是每個方法都要在每個例項上重新建立一遍。

我們來看一下 Person 建構函式的定義:

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

const person1 = new Person('Zhang san', 18, 'Engineer');
const person2 = new Person('Li si', 18, 'Doctor');
複製程式碼

用這個函式建立 person1 和 person2 都有一個名為 sayName() 的方法,但這兩個方法不是同一個 Function 例項。

以這種方式建立函式,會匯出現不同的作用域鏈和識別符號解析,雖然它們做的事情是一樣的,但不同的例項沒有得到共享。

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

建立兩個完成相同任務的 Function 例項,實屬浪費記憶體。有 this 物件在,其實我們並不需要在建構函式的時候就將函式繫結到特定物件上。因此,大可像下面這樣,通過函式定義轉移到建構函式外部來解決這個問題。

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

function sayName() {
    console.log(this.name);
}
複製程式碼

我們把 sayName() 函式的定義轉移到建構函式外部。而在建構函式內部,我們將 Person 的 sayName 屬性設定成等於全域性的 sayName 函式。這樣一來,由於 sayName 包含的是一個指向函式的指標,因此 person1 和 person2 物件就共享了在全域性作用域定義的同一個 sayName() 函式。

這樣做確實解決了兩個函式做同一件事的問題,但是又引入了兩個新的問題:

  1. 全域性作用域中定義的函式實際上只能被某個物件呼叫,這讓全域性作用域變得名不副實。
  2. 如果物件需要定義很多方法,那麼就要定義很多個全域性函式,於是我們這個自定義的物件型別就毫無封裝性可言了。

原型模式的出場很好地解決了這個問題。

原型模式

我們建立的每一個函式其實都有一個 prototype 屬性,這個屬性是一個指標,指向一個物件,這個物件的屬性和方法被由這個函式建立的所有例項共享。prototype 物件被稱為這些例項的原型物件。

這樣我們就可以把建構函式中定義物件的方法,直接新增到原型物件上,如下例項所示。

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

Person.prototype.sayName = function() {
    console.log(this.name);
}
複製程式碼

我們將 sayName() 方法和所有屬性直接新增到 Person 的 prototype 屬性中,Person 的所有例項就共用了同一個方法,同時又保證該方法只在 Person 作用域內上生效。

小結

本文涉及到的內容:

  • 檢查一個物件的型別;
  • 如何使用 new 關鍵字定義函式(不是呼叫);
  • 如何正確地建立自定義建構函式。

相關文章