你懂 JavaScript 嗎?#19 原型(Prototype)

你所不知道的 JS

本文主要會談到

前言

JavaScript 並不像 Java、C++ 這些知名的物件導向語言具有「類別」(class)來區分概念與實體(instance)或天生具有繼承的能力,而只有「物件」,因此只能利用設計模式來模擬這些功能。本文就來探討在 JavaScript 世界中,到底是怎麼實現物件導向的概念的?

首先要有個模子,我們稱它為類別,而當前面有 new 的時候,可看成是建構子(constructor),接著用這個建構子做初始化,進而建立(new)實體。

藍圖

如下,建構子 Book 產出實體 ydkjs_1 和 ydkjs_2。

function Book(name, pNum) {
  this.name = name; // 書名
  this.pNum = pNum; // 頁數
  this.comment = null; // 評價
  this.setComments = function (comment) {
    this.comment = comment;
  };
}

var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);

ydkjs_1.setComments('好書!');
ydkjs_1.comment // "好書!"

ydkjs_1.setComments === ydkjs_2.setComments // false

共用的屬性或方法,不用每次都幫實體建立一份,提出來放到 prototype 即可。承上,將 setComments 這個共用的方法放到 Book.prototype,暫且稱它為 Book 的原型。

function Book(name, pNum) {
  this.name = name; // 書名
  this.pNum = pNum; // 頁數
  this.comment = null; // 評等
}

Book.prototype.setComments = function (comment) {
  this.comment = comment;
};

var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);

ydkjs_1.setComments('好書!');
ydkjs_1.comment // "好書!"

ydkjs_2.setComments('超好書!');
ydkjs_2.comment // "超好書!"

ydkjs_1.setComments === ydkjs_2.setComments // true,確認是同一個函式!

注意

請勿修改原生原型

在這裡都是在設定自己建立的物件的原型!不要嘗試修改預設的原生原型(例如:String.prototype),也不要無條件地擴充原生原型,若要擴充也應撰寫符合規格的測試程式。另,不要使用原生原型當成變數的初始值,以避免無意間的修改。

關於建構子…

原型串鏈(Prototype Chain)

在前面巢狀範疇的部份提到「若在目前執行的範疇找不到這個變數的時候,就往外層的範疇搜尋,持續搜尋直到找到為止,或直到最外層的全域範疇」,同理,當查找物件的屬性或方法時,若在本身這個物件找不到的時候,就會往更上一層物件尋找,直到串鏈尾端 Object.prototype,若無法找到就回傳 undefined,而這個尋找的脈絡就是依循著 .__proto__ 這個原型串鏈(prototype chain)來找–每個物件在建立之初都會有個 .__proto__(dunder proto)內部屬性,它可用來存取另一個相連物件內部屬性 [[Prototype]] 的值,而 [[Prototype]] 存放其建構子原型的位置。

如下範例,ydkjs_1.__proto__ 所存的參考即指向 Book.prototype 的位置。

function Book(name, pNum) {
  this.name = name; // 書名
  this.pNum = pNum; // 頁數
  this.comment = null; // 評等
}

Book.prototype.setComments = function (comment) {
  this.comment = comment;
};

var ydkjs_1 = new Book('導讀,型別與文法', 257);
ydkjs_1.__proto__ === Book.prototype; // true

模型圖。

`ydkjs_1.__proto__` 所存的參考即指向 `Book.prototype` 的位置

由於在 ydkjs_1 是找不到方法 setComments 的,因此就會循著 .__proto__ 找到 Book.prototype 而找到方法 setComments,也因為原型串鏈,讓 JavaScript 可達到類似其他物件導向語言般的使用類別、繼承的功能。

備註,使用 .__proto__ 來取得 [[Prototype]] 似乎太暴力了(畢竟人家是內部屬性嘛),還是改用 Object.getPrototypeOf(..) 來得優雅,其中 Object.getPrototypeOf(..) 會回傳 .__proto__ 的值。

ydkjs_1.__proto__ === Book.prototype // true
Object.getPrototypeOf(ydkjs_1) === Book.prototype // true

接著來看幾個疑難雜症。

Q1:到底是誰的屬性?

可用 hasOwnProperty 檢查屬性是屬於當前物件,還是位於原型串鏈中。

ydkjs_1.hasOwnProperty('name') // true
ydkjs_1.hasOwnProperty('setComments') // false

name 的確是存在於物件 ydkjs_1 中的,而 setComments 並不在物件 ydkjs_1 中,是在原型串鏈中。

注意

範例如下。

function Book(name, pNum) {
  this.name = name; // 書名
  this.pNum = pNum; // 頁數
  this.comment = null; // 評等
}

Book.prototype.setComments = function (comment) {
  this.comment = comment;
};

var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);

Object.defineProperty(ydkjs_1, 'hello', {
  value: 'world',
  writable: true,
  configurable: true,
  enumerable: false, // 設定 hello 為不可列舉的屬性
});

由於 for loop prop in obj 會檢查整個原型串鏈且為可列舉的屬性,因此除了 hello 之外,其它的屬性都會被列出來。

for (var prop in ydkjs_1) {
  console.log(prop);
}

結果得到

name
pNum
comment
setComments

承上,prop in obj 會檢查整個原型串鏈,不管屬性是否可列舉。

'hello' in ydkjs_1 // true
'name' in ydkjs_1 // true

更多關於檢視屬性是否存在的範例可參考這裡

Q2:到底是誰的實體?

instanceof 檢查物件是否為指定的建構子所建立的實體,位於 instanceof 左邊的運算元是物件,右邊的是函式,若左邊的物件是由右邊函式所產生的,則會回傳 true,否則為 false。instanceof 可檢查整條原型串鏈的繼承世系,這在傳統的物件導向環境中稱為「內省」(introspection)或「反思」(reflection)。

function Book(name, pNum) {
  this.name = name; // 書名
  this.pNum = pNum; // 頁數
  this.comment = null; // 評價
}

Book.prototype.setComments = function (comment) {
  this.comment = comment;
};

var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);

ydkjs_1 與 ydkjs_2 都是由 Book 建立出來的實體,而 Book 也是由 Object 與 Function 建立出來的,因此都會得到 true。最後舉個反例,window 不是由 Book 建立出來的,因此得到 false。

ydkjs_1 instanceof Book // true
ydkjs_2 instanceof Book // true

ydkjs_1 instanceof Object // true
ydkjs_1 instanceof Function // true

ydkjs_2 instanceof Object // true
ydkjs_2 instanceof Function // true

window instanceof Book // false
window instanceof Window // true

另外一個方法是使用 isPrototypeOf,它可檢視運算子左邊的物件是否出現於右邊物件的原型串鏈中。與 instanceof 不同之處只在於運算元的資料型別不同而已,但功能是相同的。

再看一次這個相似的範例,Novel 繼承了 Book,並建立實體 novel。

function Book(name, pNum) {
  this.name = name; // 書名
  this.pNum = pNum; // 頁數
  this.comment = null; // 評價
}

Book.prototype.setComments = function (comment) {
  this.comment = comment;
};

function Novel(name, pNum, price) {
  Book.apply(this, [name, pNum]); // Novel 繼承 Book
  this.price = price;
}

Novel.prototype = Object.create(Book.prototype);

Novel.prototype.printPrice = function () {
  console.log(`${this.name} is ${this.price}`);
};

var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);
var novel = new Novel('最近沒在看小說 ><', 500, 600);

我們來檢視幾個問題…

Book.prototype.isPrototypeOf(ydkjs_1) // true
Book.prototype.isPrototypeOf(novel) // true
Novel.prototype.isPrototypeOf(ydkjs_1) // false
Novel.prototype.isPrototypeOf(novel) // true

看模型圖會更清楚。

原型串鏈

注意,這裡的繼承是指原型式繼承(prototypal inheritance)。

「原型式繼承」是指使用連結相連兩個物件而能共用屬性的方式,又稱為「差異式繼承」(differential inheritance),它模仿了傳統物件導向語言的類別方法,而達到繼承的功能。

說明

// pre-ES6
// throws away default existing `Novel.prototype`
Novel.prototype = Object.create(Book.prototype);

// ES6+
// modifies existing `Novel.prototype`
Object.setPrototypeOf(Novel.prototype, Book.prototype);

那麼,如果反過來想要取得物件的 [[Prototype]] 的值呢?那就可以用 Object.getPrototypeOf

ydkjs_1 的 [[Prototype]] 值是?

Object.getPrototypeOf(ydkjs_1) === Book.prototype // true

或等同直接使用 .__proto__ 取得 [[Prototype]] 的值,也是可行的。

ydkjs_1.__proto__ === Book.prototype // true

備註

Q3:原型串鏈的終點是?

承上範例,針對這整條原型串鏈,我們就拿它來檢查看看…

ydkjs_1.__proto__ === Book.prototype // true
Book.__proto__ === Function.prototype // true
Book.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ // null

因此,Object.prototype 物件就是整條串鏈的最頂端了。我們可想像成,在查找變數時,最後的終點就是全域範疇了。

Object.prototype 這個物件含有很多常用的屬性和方法,例如:toString、valueOf 等,這也就是為什麼所有的物件都能使用這些功能的原因。

Q4:屬性的設定與遮蔽

查找物件的屬性或方法時要注意設定與遮蔽的問題。

我們可能遇過以下這種狀況…

物件 obj 有屬性 counter 作為計數器,而 anotherObj 無此屬性且原型串列的參考指向 obj。可能是一時手誤吧,居然將 anotherObj.counter 當計數器做遞增,之後在程式某處分別印出 obj.counteranotherObj.counter,發現居然所存的值是不一樣的!這到底發生了什麼事呢?

const obj = {
  counter: 0,
};

const anotherObj = Object.create(obj);
anotherObj.counter++; // 一時手誤,應改為 obj.counter++

obj.counter // 0
anotherObj.counter // 1

obj.counter++;
anotherObj.counter++;

obj.counter // 1
anotherObj.counter // 2

目前已知,anotherObj 並無 counter 屬性,而 counter 屬性位於原型串鏈 [[Prototype]] 更上一層 的 obj 之內。當使用指定運算子更新 counter 屬性值的時候,會依照以下規則來決定處理的方式

  1. 此屬性可被寫入(writable 為 true),則 anotherObj 會新增此屬性,而產生遮蔽(shadowing)的效果。
  2. 此屬性不可被寫入(writable 為 false),則在嚴格模式下會被報錯,而在非嚴格模式下,會忽略這個設定/更新。
  3. 此屬性有設定 setter,因此會回傳 setter 所設定的預設值。

在上面的這個例子中,是屬於狀況「1」,因此目前在 obj 與 anotherObj 兩物件上都具有 counter 屬性了。解法是小心一點,不要再手誤了!

Q5:一定要用「類別」的概念才能建立兩物件的連結嗎?

答案是「不必」。

Object.create(..) 可將兩個物件連結起來,如下,Object.create(..) 可建立一個新物件 coolPerson,連結到指定的物件 person,意即設定 coolPerson.__proto__ 指向 person。

var person = {
  name: null,
  sayHi: function (name) {
    this.name = name;
    console.log(`Hi, I am ${this.name}`);
  },
};

var coolPerson = Object.create(person); // coolPerson.__proto__ === person

coolPerson.sayHi('Jack'); // Hi, I am Jack

備註,若使用 Object.creat(null) 建立一個空物件,那它就真的非常空,裡面不含任何屬性,因此也就沒有 .__proto__.constructor 可用了,通常會單純當成存資料用的物件而已。

var empty = Object.create(null);
empty // {}
empty.__proto__ // undefined--很空,什麼都沒有!

Q6:連結作為備援之用?

原型串鏈的功用似乎只是當備援(fallback)之用?意即,當查找的屬性無法在當前物件找到時,就往更上一層的物件尋找。

但其實沒這麼簡單,我們在下一篇文章「行為委派」會看到它的強大之處,例如:讓物件建立平等的委派關係以取得屬性和方法、實作更簡單易懂的設計模式等,敬請期待。

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

最後再次附上本文範例的模型圖。

原型串鏈

完整程式碼。

function Book(name, pNum) {
  this.name = name; // 書名
  this.pNum = pNum; // 頁數
  this.comment = null; // 評價
}

Book.prototype.setComments = function (comment) {
  this.comment = comment;
};

function Novel(name, pNum, price) {
  Book.apply(this, [name, pNum]);
  this.price = price;
}

Novel.prototype = Object.create(Book.prototype);

Novel.prototype.printPrice = function () {
  console.log(`${this.name} is ${this.price}`);
};

var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);
var novel = new Novel('最近沒在看小說 ><', 500, 600);

References


同步發表於2019 鐵人賽


You-Dont-Know-JS javascript 你所不知道的JS 2019鐵人賽 你懂JavaScript嗎? 鐵人賽 You-Dont-Know-JS-this-and-Object-Prototypes operator 運算子 你懂 JavaScript 嗎?2019 iT 邦幫忙鐵人賽 系列文