iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 19
3
Modern Web

你懂 JavaScript 嗎?系列 第 19

你懂 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),也不要無條件地擴充原生原型,若要擴充也應撰寫符合規格的測試程式。另,不要使用原生原型當成變數的初始值,以避免無意間的修改。

關於建構子...

  • 在 JavaScript 中,除了沒有類別外,其實也沒有建構子,因此
    • 只要函式前有 new,這個函式就是建構子。
    • 只要函式前有 new 來做呼叫,就叫做建構子呼叫。
  • new 關鍵字要做哪些事情呢?它的工作就是
    1. 建立一個新的物件。
    2. 將物件的 .__proto__ 指向建構子的 prototype,形成原型串鏈。
    3. 將建構子的 this 指向 new 出來的新物件。
    4. 回傳這個物件。

原型串鏈(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 中,是在原型串鏈中。

注意

  • hasOwnProperty 只會檢查該物件,而不會檢查整條原型串鏈。
  • for loop prop in obj 會檢查整個原型串鏈且為可列舉的屬性 。
  • prop in obj 會檢查整個原型串鏈,不管屬性是否可列舉。

範例如下。

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);

我們來檢視幾個問題...

  • 在 ydkjs_1 的整個原型串鏈中,是否出現過 Book.prototype?
Book.prototype.isPrototypeOf(ydkjs_1) // true
  • 在 novel 的整個原型串鏈中,是否出現過 Book.prototype?
Book.prototype.isPrototypeOf(novel) // true
  • 在 ydkjs_1 的整個原型串鏈中,是否出現過 Novel.prototype?
Novel.prototype.isPrototypeOf(ydkjs_1) // false
  • 在 novel 的整個原型串鏈中,是否出現過 Novel.prototype?
Novel.prototype.isPrototypeOf(novel) // true

...

...

看模型圖會更清楚。

原型串鏈

...

...

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

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

說明

  • Novel.prototype = Object.create(Book.prototype);Object.create 建立了一個新物件(稱呼它為 O),並將 O.__proto__ 設定為 Book.prototype,因此我們可以想成藉由 O 這個橋樑,讓 Novel.prototype.constructor === Book
  • 承上,也可改用 ES6 的 setPrototypeOf 來設定 [[Prototype]] 內部屬性的值。
// 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

備註

  • 雖然剛才說「instanceof 檢查物件是否為指定的建構子所建立的實體」,但其實 instanceof 所檢視的是物件的內部屬性 [[Prototype]](或說是 __proto__)所形成的整條原型串鏈中,是否能找到其建構子原型。例如:ydkjs_1 與 ydkjs_2 的 __proto__ 屬性是否為 Book.prototype?(答案是肯定的)
  • Object 與 Function 互為彼此的實體,意即 Function.__proto__ 指向 Object.prototype,而 Object.__proto__ 也指向 Function.prototype
  • 在下一篇文章「行為委派」中,我們會將「繼承世系」改稱為「委派連結」(delegation link),這會比較符合 JavaScript 的現況。

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)之用?意即,當查找的屬性無法在當前物件找到時,就往更上一層的物件尋找。

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

回顧

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

  • 原型串鏈是指經由物件的內部屬性 .__proto__ 而形成的物件到物件的連結串連,當查找物件屬性時,若在本身這個物件找不到,就往更上一層物件尋找,直到串鏈尾端,若無法找到就回傳 undefined。.__proto__ 存放的即為其建構子原型的參考。
  • JavaScript 沒有類別,也沒有建構子,因此
    • 只要函式前有 new,這個函式就是建構子。
    • 只要函式前有 new 來做呼叫,就叫做建構子呼叫。
  • JavaScript 中的繼承是指原型式繼承或差異式繼承,意即使用連結兩個物件而能共用屬性的方式來模擬物件導向語言的類別與繼承功能。
  • hasOwnProperty 可用來檢查屬性是屬於當前物件,還是位於原型串鏈中。
  • instanceof 與 isPrototypeOf 可用來檢查物件是否為指定的建構子所建立的實體;Object.getPrototypeOf 可取得物件的 [[Prototype]] 的值;Object.setPrototypeOf 可設定物件的 [[Prototype]] 的值。
  • Object.prototype 就整條原型串鏈的終點。
  • 物件屬性的設定與遮蔽規則。
  • Object.create(..) 可將兩個物件連結起來。

...

...

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

原型串鏈

完整程式碼。

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


同步發表於部落格


上一篇
你懂 JavaScript 嗎?#18 (簡易版)物件導向概念
下一篇
你懂 JavaScript 嗎?#20 行為委派(Behavior Delegation)
系列文
你懂 JavaScript 嗎?30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言