iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 24
7
Modern Web

重新認識 JavaScript系列 第 24

重新認識 JavaScript: Day 24 物件與原型鏈

本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。

購書連結 https://www.tenlong.com.tw/products/9789864344130

讓我們再次重新認識 JavaScript!


在上一篇文章中,我們介紹了基本型別包裹器 (Primitive Wrapper),也理解到基本型別之所以會有「方法」以及「屬性」,都是因為在存取它們的時候,JavaScript 會透過 Wrapper 自動將它們暫時轉型為「物件」的型態。

而這些物件的「方法」以及「屬性」又是從哪裡來的呢?

這就要從源頭的「原型」(Prototype) 講起。


原型鏈 Prototype Chain

前幾天的文章中曾經提到過,JavaScript 是一門物件導向的程式語言,因為沒有 Class,所以它的繼承方法是透過 「原型」(prototype) 來進行實作。

那麼「原型」繼承的概念是什麼呢? 簡單來說,透過「原型」繼承可以讓本來沒有某個屬性的物件去存取其他物件的屬性

肥宅如我就拿電玩人物當例子吧,不知道大家有沒有玩過洛克人。 洛克人有趣的地方就是打敗了某關卡的頭目之後,就可以擁有那個敵人的武器。

// 洛克人的武器是 buster 飛彈
var rockman = { buster: true };

// 剪刀人的武器是剪刀
var cutman  = { cutter: true };

像上面這樣,建立了兩個物件,分別代表洛克人與剪刀人。

然後,我們可以透過 in 來判斷某個屬性是否可以透過這個物件來存取:

// 注意,屬性名稱必須是「字串」
console.log( 'buster' in rockman );     // true
console.log( 'cutter' in rockman );     // false

很顯然,洛克人目前只有飛彈,並沒有獲得剪刀人的武器 cutter

那麼,洛克人辛辛苦苦把剪刀人幹掉了之後,這時候就可以取得他的武器。 以 JavaScript 來說,我們就可以透過 Object.setPrototypeOf() 將「剪刀人指定為原型」。

在 JavaScript 裡,物件原型是物件的內部屬性,而且無法直接存取 (所以通常會直接被標示為 [[prototype]]),但我們可以透過 Object.setPrototypeOf() 來指定物件之間的原型關係。

Object.setPrototypeOf(rockman, cutman);

像這樣,第一個參數是「繼承者」的物件,第二個則是被當作「原型」的物件。

如果以洛克人的範例來說,可以這樣寫:

// 洛克人的武器是 buster 飛彈
var rockman = { buster: true };
// 剪刀人的武器是剪刀
var cutman  = { cutter: true };

console.log( 'buster' in rockman );     // true
console.log( 'cutter' in rockman );     // false

// 指定 cutman 為 rockman 的「原型」
Object.setPrototypeOf(rockman, cutman);

console.log( 'buster' in rockman );     // true

// 透過原型繼承,現在洛克人也可以使用剪刀人的武器了
console.log( 'cutter' in rockman );     // true

不過可惜的是,在原型繼承的規則裡,同一個物件無法指定兩種原型物件

也就是說,假設我們再新增一個「氣力人」:

// 氣力人的武器是超級手臂
var gutsman = { superArm: true };

// 指定 gutsman 為 rockman 的「原型」
Object.setPrototypeOf(rockman, gutsman);

// 這個時候洛克人也可以使用氣力人的超級手臂
console.log( 'superArm' in rockman );     // true

// 但是剪刀卻不見了,哭哭
console.log( 'cutter' in rockman );       // false

如果我們希望洛克人可以同時使用「剪刀」與「超級手臂」,要怎麼做呢?

幸好在原型繼承之中,有個觀念叫「原型鏈」(Prototype Chain)。

當我們從某個物件要試著去存取「不存在」的屬性時,那麼 JavaScript 就會往它的 [[prototype]] 原型物件去尋找。

所以說,既然洛克人只能繼承剪刀人的武器,那麼我可不可以順勢讓剪刀人去繼承氣力人的超級手臂呢?

https://ithelp.ithome.com.tw/upload/images/20171227/20065504qAzPMYf2Ak.png

// 洛克人的武器是 buster 飛彈
var rockman = { buster: true };
// 剪刀人的武器是剪刀
var cutman  = { cutter: true };
// 氣力人的武器是超級手臂
var gutsman = { superArm: true };

// 指定 cutman 為 rockman 的「原型」
Object.setPrototypeOf(rockman, cutman);

// 指定 gutsman 為 cutman 的「原型」
Object.setPrototypeOf(cutman, gutsman);

// 這樣洛克人就可以順著「原型鏈」取得各種武器了!
console.log( 'buster' in rockman );       // true
console.log( 'cutter' in rockman );       // true
console.log( 'superArm' in rockman );     // true

謎之聲:有了超級手臂的剪刀人跟遊戲的設定好像不太一樣啊?

重點是原型鏈,這種小事就不要太在意啦。


最頂層的原型物件: Object.prototype

如同上面所說,當我們嘗試在某個物件存取一個不存在該物件的屬性時,它會繼續往它的「原型物件」[[prototype]] 去尋找,那麼這個 [[prototype]] 究竟會找到什麼時候才停止呢?

事實上,在 JavaScript 幾乎所有的物件 (環境宿主物件除外) 順著原型鏈找到最頂層級時,都會找到 Object.prototype 才停止,因為 Object.prototype 是 JavaScript 所有物件的起源。

換言之,在 Object.prototype 提供的所有方法,在 JavaScript 的所有物件的可以呼叫它,像是我們曾介紹過的這些方法:

  • Object.prototype.hasOwnProperty()
  • Object.prototype.toString()
  • Object.prototype.valueOf()

即便建立物件時,沒有定義這些方法,但基於原型鏈的繼承,我們還是可以呼叫這些方法。


建構式與原型

複習一下,先前我們介紹過「函式」與「建構式」,這裡透過範例說明:

var Person = function(){};

// 函式也是物件,所以可以透過 prototype 來擴充每一個透過這個函式所建構的物件
Person.prototype.sayHello = function(){
  return "Hi!";
}

var p1 = Person();
var p2 = new Person();

變數 p1 的內容,是直接呼叫 Person 的結果,但因為這個函式什麼都沒有回傳,所以結果是 undefined

變數 p2 的內容則是透過 new 關鍵字來建立一個物件。 但由於函式也是物件,所以可以透過 prototype 來擴充每一個透過這個函式所建構的物件。

所以當我們透過 new Person() 建立了新物件並指定給 p2 後,p2 就可以透過原型取得呼叫 sayHello() 的能力,即使我們尚未對 p2 定義這個方法。

p2.sayHello();      // "Hi!"

簡單來說,就是當函式被建立的時候,都會有個原型物件,當我們將這個函式當作建構式來建立新的物件時,這個函式的原型物件,就會被當作這個新物件的原型。

那麼有趣的事來了,如果我們在建構式中建立一個「同名」的實例方法:

var Person = function(){
  this.sayHello = function(){
    return "Yo!";
  };
};

Person.prototype.sayHello = function(){
  return "Hi!";
}

var p = new Person();

請問,執行 p.sayHello() 的結果會是什麼?

答案是 "Yo!"

也就是說,當物件實體與它的原型同時擁有同樣的屬性或方法時,會優先存取自己的屬性或方法,如果沒有才會再順著原型鏈向上尋找。

而關於從原型繼承屬性或方法,我們可以簡單歸納出幾種狀況:

  • 如果物件實體與它的原型同時擁有同樣的屬性或方法時,會優先存取自己的屬性或方法。

  • 如果物件實體找不到某個屬性或方法時,會往它的原型物件尋找。

    1. 如果在原型物件或更上層的原型物件有發現這個屬性,且屬性描述的 writabletrue ,則會為這物件實體新增對應的屬性或方法。 [註1]

    2. 同上,但若該屬性描述的 writablefalse,那麼就等於目標物件會多出一個「唯讀」的屬性,且事後無法再新增或修改。

    3. 如果在原型物件或更上層的原型物件有發現這個屬性,但這個屬性其實是一個「設值器」(setter function),那麼呼叫的永遠會是那個設值器,目標物件的屬性也無法被重新定義。


那麼,我們要怎麼知道某個屬性是透過「原型」繼承來的,還是物件本身所有的呢?

回到一開始的洛克人範例:

// 洛克人的武器是 buster 飛彈
var rockman = { buster: true, name: 'rock' };
// 剪刀人的武器是剪刀
var cutman  = { cutter: true };
// 氣力人的武器是超級手臂
var gutsman = { superArm: true };

// 指定 cutman 為 rockman 的「原型」
Object.setPrototypeOf(rockman, cutman);
// 指定 gutsman 為 cutman 的「原型」
Object.setPrototypeOf(cutman, gutsman);

如果你希望透過「原型鏈」檢查屬性,可以用 in:

console.log( 'buster' in rockman );       // true
console.log( 'cutter' in rockman );       // true
console.log( 'superArm' in rockman );     // true

如果你希望檢查的屬性,是否為「物件本身」所有,則可以透過 hasOwnProperty()

console.log( rockman.hasOwnProperty('buster') );    // true
console.log( rockman.hasOwnProperty('superArm') );  // false

[註1]: 對於屬性描述器不熟悉的朋友,可以往前查閱 重新認識 JavaScript: Day 22 深入理解 JavaScript 物件屬性 一文。


上一篇
重新認識 JavaScript: Day 23 基本型別包裹器 Primitive Wrapper
下一篇
重新認識 JavaScript: Day 25 原型與繼承
系列文
重新認識 JavaScript37

1 則留言

1
wuyuxin321000
iT邦新手 5 級 ‧ 2020-08-04 16:22:01

打败敌人,获得新技能的比喻真是太妙了!感谢kuro大大,终于明白了原型是啥。

我要留言

立即登入留言