iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 24
0
Modern Web

JavaScript Note系列 第 24

prototype chain 原型鏈 & inherit 繼承

  • 分享至 

  • xImage
  •  

JavaScript是物件導向的語言,但其方式跟Java、C#等物件導向語言有很大的差異。

JavaScript使用原型建立新物件,Java、C#而是以類別建立新物件,可以說JavaScript是以原型為基礎的物件導向的程式語言。

原型

所謂原型是「作為其他物件基礎的物件」。

我們來看看物件是如何透過原型來取得其他物件的屬性。

let obj1 = {
    prop1: 'prop of obj1'
}
let obj2 = {
    prop2: 'prop of obj2'
}
let obj3 = {
    prop3: 'prop of obj3'
}
console.log('prop1' in obj1); //true
console.log('prop2' in obj1); //false

建立3個物件,只能各自存取自己的屬性。

在程式開發的過程中,盡可能地重複使用程式碼,繼承可以實現這樣的做法,可以將某種功能繼承到其他的物件上,達到重複使用的目的,在JavaScript中使用原型可以實作出繼承。

原型的概念,就是每個物件都有自身參考的原型物件,當要使用特定屬性或函式,而物件本身沒有時,它就會往它的原型參考搜尋。

上面的範例,我們可以使用Object.setPrototypeOf( )方法,給物件設定其原型。

Object.setPrototypeOf(obj1, obj2);
console.log('prop1' in obj1); //true
console.log('prop2' in obj1); //true

Object.setPrototypeOf(obj, prototype)使用方式,將prototype物件設為obj的原型。
所以我們可以這樣說:obj2此時為obj1的原型,在obj1本身找不到屬性prop2,所以往原型也就是obj2找,找到目標。

console.log('prop2' in obj1); //true

結果雖然為true,但prop2並不屬於obj1,而是obj1透過原型找到prop2,經由這樣的關係,就好像prop2是obj1自身的屬性一樣。

Object.setPrototypeOf(obj2, obj3);
console.log('prop3' in obj1); //true

每個物件都有原型,而原型物件也有自己的原型,我們再把obj3設為obj2的原型,如此一來,obj1就可以藉由原型的原型,找到prop3,這樣透過層層找到屬性的關係,就是所謂的原型鏈,它就像鏈子一樣,讓obj1、obj2、obj3有了原型的關聯。

__proto__

(雙底線)__proto__(雙底線)是個非常特別的屬性,它表示這個物件繼承自何方,也就是指向該物件的原型。
https://ithelp.ithome.com.tw/upload/images/20181108/20112573i9mHZRNpCS.png
由此可以看出,obj1.__proto__指向obj2,obj2.__proto__指向obj3

我們再把物件攤開:
https://ithelp.ithome.com.tw/upload/images/20181108/20112573TYBCqG0QKV.png
在obj1.__proto__的部分,可以看出obj2的__proto__屬性指向obj3,換個角度來看,這就是原型鏈。那obj3的原型呢?它指向的是所有JavaScript物件的最頂端,Object物件。

函式建構子

在物件產生時,將其初始化的特殊函式。

  • 函式也是物件,每個函式都有原型物件,而這個原型會被設定為透過該函式所建立的物件的原型。
  • 函式內部有個prototype屬性,它所指向的是函式的原型物件,這個原型物件是函式建立之初,一併建立的。
let Member = function () {}
let mem1 = new Member();
  • Member( )是個空函式,透過prototype屬性可以參考到它的原型物件。
  • 使用「new」運算子將Member( )當作建構子來呼叫,建立一個新物件mem1,mem1的原型參考會指向Member( )的原型物件。
  • 當函式當作建構子使用,通常命名會以大寫字母為開頭。
console.log(mem1.__proto__ === Member.prototype); //true

由此可以看出mem1.__proto__所指向的原型與Member.prototype所指向的是同一個物件。
換個方式說,Member.prototype所代表的就是mem1的原型物件。
https://ithelp.ithome.com.tw/upload/images/20181108/20112573yew0v7G3cp.png
再次地證明,它們都指向同一個原型物件,這個原型物件有2個屬性:

  • constructor屬性,當我們定義Member( )函式時,一個藉由constructor屬性參考到Member( )函式的Member( )原型也會被建立出來。
  • __proto__屬性,說明了這個原型物件,它從Object物件繼承而來的。

有個非常重要的概念要特別說明,這也是非常多初學者一直搞不清楚的地方。

__proto__跟prototype到底是有啥不同?

  • __proto__:該物件從何處繼承而來。
  • prototype:讓其他物件繼承的原型物件。

擴充

既然已經知道Member.prototype是mem1的原型,當我們在原型加上屬性方法,greeting( ),mem1自然可以透過原型找到該方法。

function Member() {}
Member.prototype.greeting = function () {
    console.log('Hello!');
}
let mem1 = new Member();
mem1.greeting(); //Hello!

我們在建構子內部加了一個同名的屬性方法:

function Member() {
    this.greeting = function () {
        console.log('Hello!JavaScript!');
    }
}
Member.prototype.greeting = function () {
    console.log('Hello!');
}
let mem1 = new Member();
mem1.greeting(); //Hello!JavaScript!

在建構子內部,this關鍵字指的是新建立的物件,也就是mem1。

所以在建構子內部所增加的屬性是直接建立在mem1之上,當我們存取mem1的greeting( )屬性時,立即找到建構子內部的屬性方法,而不是透過原型找到。

然而這會暴露出一個問題:浪費記憶體

let mem1 = new Member();
let mem2 = new Member();
let mem3 = new Member();
mem1.greeting(); //Hello!JavaScript!
mem2.greeting(); //Hello!JavaScript!
mem3.greeting(); //Hello!JavaScript!

同時建立3個物件,它們都指向同一個原型,也透過建構子建立自己的屬性版本,如果是資料屬性,這是可以的,因為每個物件的資料本就有可能不同,但如果是屬性方法,情況就不同了。

這3個物件都擁有不同的greeting( ),但greeting( )卻是相同的邏輯,複製相同邏輯的方法,只會浪費記憶體空間,所以解決的方式,就是將此方法設定為原型方法,讓所有繼承的物件都能共享此方法。

function Member() {}
Member.prototype.greeting = function () {
    console.log('Hello!');
}
let mem1 = new Member();
let mem2 = new Member();
let mem3 = new Member();
mem1.greeting(); //Hello!
mem2.greeting(); //Hello!
mem3.greeting(); //Hello!

擴充的副作用

JavaScript是動態的程式語言,對於原型,也可以輕易地修改其屬性。

function Member() {}
let mem1 = new Member();
Member.prototype.greeting = function () {
    console.log('Hello!');
}
mem1.greeting(); //Hello!
Member.prototype = {
    review: function () {
        console.log('review');
    }
}
let mem2 = new Member();
mem2.greeting(); //Uncaught TypeError: mem2.greeting is not a function
mem1.greeting(); //Hello!

使用Member( )建立mem1物件,之後在其原型上增加一個方法greeting( ),由於mem1是參考到Member( )原型,所以就算建立物件後才增加原型方法,也能被存取到。

將Member.prototype修改,參考至另一個物件。內部有review屬性方法,之後再建立另一個物件mem2,會發現它無法參考到greeting( ),但mem1依舊可參考到greeting( )。

mem2可以參考到修改後的原型物件,但因為mem1參考的因素,舊的原型物件依然存在。

由此可以看出,原型的參考在執行過程中,可以被修改,而改變之後新建立的物件的原型參考。

mem2.review(); //review!

我們可以使用下列方式來取得關於使用建構子建立之物件的更多資訊。

function Member() {}
let mem1 = new Member();
console.log(typeof mem1); //object
console.log(mem1 instanceof Member); //true
console.log(mem1.constructor === Member); //true
  • typeof運算子,只能檢查出運算元的類型,無法提供更多資訊。
  • instanceof運算子,能檢查出該物件是否是透過哪個建構子建立的。
  • constructor屬性,指向原本建立的函式建構子。

繼承

接下來,我們來討論物件導向的核心,繼承。
透過繼承,可以讓新物件存取原型的功能,讓程式碼得以重複使用,又可以避免記憶體的浪費。

使用原型實現繼承:

function Person() {}
Person.prototype.run = function () {
    console.log('running');
};
function Member() {}
Member.prototype = new Person();

先定義Person( ),在其原型新增run( )屬性,再定義Member( ),將其原型設為Person( )的物件,可以推導出,由Member( )所建立的物件,其原型參考,會指向Person( )的物件。

let mem1 = new Member();
console.log(typeof mem1); //object
console.log(mem1 instanceof Member); //true
console.log(mem1.constructor === Member); //false
console.log(mem1.constructor === Person); //true

「typeof」與「instanceof」運算子的結果正如預期,但constructor屬性的結果卻跟剛剛的例子有所出入,
可以看出constructor屬性所指向函式建構子已經由Member( )變成Person( ),因為我們已經將Member( )的原型改成Person( )物件,所以當mem1找不到constructor屬性,會往原型(Person( )物件)找,也沒找到再往原型(Person.prototype)找,最後找到的會是Person( )。
既然mem1的原型參考到Person.prototype,所以以下指令會成功執行。

mem1.run(); //running

「instanceof」運算子

在傳統的程式語言,「instanceof」運算子,它的功能是檢查物件是否為指定的類別或子類別。
雖然JavaScript也有「instanceof」運算子,但其運作原理是完全不同的,「instanceof」運算子的搜尋依據是建立在原型鏈之上。

function Person() {}
Person.prototype.run = function () {
    console.log('running');
};
function Member() {}
Member.prototype = new Person();
let mem1 = new Member();
console.log(mem1 instanceof Member); //true
console.log(mem1 instanceof Person); //true

由上面的範例可以得知,mem1同時是Member( )與Person( )的物件。

我們先來討論

console.log(mem1 instanceof Member); //true

mem1是Member( )的物件,所以JavaScript引擎會對Member( )的原型也就是new Person( )檢查,看它是否有在mem1的原型鏈上,由於我們已經將new Person( )設為Member( )的原型,所以結果為true。

那我們來看看Person( )的部分。JavaScript引擎會試著在mem1的原型鏈上搜尋Person( )的原型,由於Member( )的原型被設為Person( )的物件,所以它會找到Person( )的物件,再透過Person( )的物件找到Person( )的原型。

以上就是「instanceof」運算子的運作。它會檢查運算子右邊運算元的原型是否有在運算子左邊物件的原型鏈上。

如果我們在執行中途修改函式的原型會造成以下的情況。

function Member() {}
let mem1 = new Member();
console.log(mem1 instanceof Member); //true
console.log(mem1.__proto__ === Member.prototype); //true
Member.prototype = {};
console.log(mem1 instanceof Member); //false
console.log(mem1.__proto__ === Member.prototype); //false

第一次執行「instanceof」運算子沒什麼問題,但我們把Member( )的原型設為空物件,這時mem1的原型參考依舊指向舊的Member( )原型,而新的Member( )原型(空物件)並未在mem1物件的原型鏈上,所以為false。
https://ithelp.ithome.com.tw/upload/images/20181108/20112573k2zMBmS8w1.png

那如果再一次建立新的mem2物件呢?

let mem2 = new Member();
console.log(mem2 instanceof Member); //true
console.log(mem2.__proto__ === Member.prototype); //true

https://ithelp.ithome.com.tw/upload/images/20181108/20112573rfgFZ2GLtt.png

因為是在重新指定Member( )原型之後,才建立mem2物件,所以,新的Member( )原型(空物件)自然就會在mem2的原型鏈上。

參考來源:
忍者:JavaScript開發技巧探秘


上一篇
call by value 傳值 & call by reference 傳址
下一篇
class 類別
系列文
JavaScript Note31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言