0%

JS核心篇-原型鏈

當我們撰寫 JavaScript的程式碼時,為什麼會有那麼多的內建方法可以用? 例如一組陣列可以使用 .length 來得知陣列有多少位址資料,陣列內建的方式又是從何而來?

這裡紀錄一些關於原型的筆記 :

  • 原型物件
    • 原型屬性
    • 原型繼承
  • 原型鏈
  • new Object( ) 自訂原型
  • Object.create( ) 將其他物件作為原型
  • 多層繼承實作
  • 物件屬性特徵

原型物件

JavaScript是一個物件導向的語言,許多內建的方法都是透過繼承原型屬性來的,當我們打開以 JS撰寫的瀏覽器時,就建好基礎的原型方法。像是以下例子,原型的頂層為一個物件,其名稱為 Object。物件 {}、函式 function、陣列 [] ,都是繼承了這個原型物件內的屬性,再根據型別有自己的原生方法,例如陣列與函式的原生原型就不一樣。

1
2
3
4
5
6
7
8
9
10
11
12
console.log(Object) 
// 此為物件頂層,會發現一個名為 Object的函式
// 函式也是物件的一種,可以發現它擁有自己的物件屬性

console.log(Object())
// 執行 Object() 這個函式會 return 一個名為 Object的物件

console.log(Array.__proto__ === Function.__proto__)
// true,__proto__ 都是繼承了物件

console.log(Array.prototype === Function.prototype)
// false,原生原型不同

原型屬性

原型物件內建了很多原生屬性,我們可以自行增加共用的屬性,方式有兩個 :

  1. __proto__ ,繼承原型,建立在物件上
    • 每個物件都有此屬性
    • 屬性特徵為可被列舉
    • 原型鏈的關鍵
  2. prototype ,原生原型,建立在函式上
    • 函式才有此原生屬性
    • 屬性特徵為不可被列舉

為什麼以下比對是 true?

1
2
console.log( Object.__proto__.__proto__ === Object.prototype ) 
// true

因為物件原型 Object 本身是一個函式,而

  • Object 這個函式繼承了 function 的原生原型
  • function 又繼承了原型物件 Object 的原生原型

所以它們指得是同樣的東西,可以使用 console.dir(Object) 驗證。(可以先往下看原型繼承,再回來想一下。)


原型繼承

以下比對可以了解繼承原型與原生原型。

首先,物件、函式與陣列本身為 "function"

1
2
3
console.log(typeof Object)   // "function"
console.log(typeof Array) // "function"
console.log(typeof Function) // "function"

Object 原生原型與繼承原型

1
2
3
4
5
6
7
/*  Object */
console.log( Object.prototype === Object.__proto__ )
// false,前者是物件原生原型之物件,後者是繼承函式原型之函式
// Object,本身是為名為 Object的函式

console.log( Object.__proto__.__proto__ === Object.prototype )
// true,前者是繼承函式原型又繼承物件原型之物件,後者是原生原型之物件

Function 原生原型與繼承原型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*  Function  */
console.log( Function.prototype === Object.__proto__ )
// true,前者是函式原生原型之函式,後者是繼承函式原型之函式

console.log( Function.prototype === Function.__proto__ )
// true,前者為函式原生原型之函式,後者為繼承函式原型之函式

console.log( Function.__proto__ === Object.__proto__ )
// true

console.log(
Function.__proto__.__proto__ ===
Object.__proto__.__proto__
)
// true

Array 原生原型與繼承原型

1
2
3
4
5
6
7
/* Array */
console.log( Array.prototype === Object.__proto__ )
// false,前者為陣列原生原型之物件,後者為繼承函式原型之函式
// typeof Array.prototype 為 "object"

console.log( Array.prototype === Array.__proto__)
// false,前者為陣列原生原型之物件,後者為繼承函式原型之函式

Function and Array 原生原型與繼承原型

1
2
3
4
5
6
7
8
9
10
11
12
/* Function and Array*/
console.log( Function.prototype === Array.prototype )
// false,原生原型不同

console.log( Function.__proto__ === Array.__proto__ )
// true,均為繼承函式原型之函式

console.log(
Function.__proto__.__proto__ ===
Array.__proto__.__proto__
)
// true

在往後撰寫 JS程式碼時,就可以知道正在使用的陣列、函式以及物件是繼承誰的屬性。


原型鏈

  • 擁有物件的特性
  • 會往上層搜尋
  • 原型屬性可共用,不須額外撰寫
    • 可以新增屬性在原型中並賦予值,可以是陣列、函式、物件、純值等等…
    • 其他物件可以利用點記號取用

例如下面的範例 :

1
2
3
4
5
6
7
8
9
10
11
12
13
var arrayA = [1, 2, 3]
var arrayB = [4, 5, 6]
arrayA.name = 'X陣列'
arrayB.name = 'Y陣列'
arrayA.getLast = function(){
return this.name
}
console.log(typeof arrayA)
console.log(arrayA.getLast())
console.log(arrayB.getLast())
// "object"
// "X陣列"
// arrayB.getLast is not a function

上面的範例中 :

  • arrayA ,可以使用 getLast這個方法
  • arrayB ,卻無法使用 getLast這個方法,因為只建立在 arrayA 這個物件下
  • 若要讓 arrayB ,可以使用 getLast這個方法,就得再建立一次

那如何讓方法共用呢? 我們可以利用原型鏈,新增一個共用方法在陣列這個實體物件上,像是這樣 :

1
2
3
4
5
6
7
8
9
10
11
12
13
var arrayA = [1, 2, 3]
var arrayB = [4, 5, 6]
arrayA.name = 'X陣列'
arrayB.name = 'Y陣列'
// 使用 __proto__ ,其繼承了陣列的原生原型
// 在這個原型屬性上建立一個新方法
arrayA.__proto__.getLast = function(){
return this.name
}
console.log(arrayA.getLast())
console.log(arrayB.getLast())
// "X陣列"
// "Y陣列"

不過上述的例子無法使用 prototype ,因為這個實體陣列型別為 “object”,它沒有這個原型屬性,只有函式才有,而 __proto__ 直接在繼承來的物件上新增共用方法。除非新增在原生原型 Array.prototype.getLast(),這樣所有陣列都能共用。


new Object( ) 自訂原型

只需要在原型自訂方法,就不用浪費一堆記憶體空間(不用一直重開)。

舉個例子來說,打開某個編輯應用程式時,會問你是否套入範本,然後再問你基本資料要填入甚麼。
以下範例是一個構造函式,把它想成是一範本,利用這個範本去建立一個實體檔案,這個範本函式裏頭可以先寫上未來要成為物件時,所擁有的屬性,以建構式運算子建立時,就會成為該物件的屬性。
而範例中 :

  • 每個物件實體繼承了構造函式的屬性名稱,同時也繼承了物件的原生屬性
  • 每個物件實體有自己的屬性值
  • 不同物件時體會新開記憶體位置
  • 每個物件實體之物件屬性為自己的原生原型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function motorCycle(name, color, displacement){
this.name = name
this.color = color
this.displacement = displacement
this.congratulate = function(){
console.log('Congratulations on the deal! Have a nice trip!')
}
}

var motoA = new motorCycle('Z900RS', 'red', 948)
var motoB = new motorCycle('R1000', 'blue', 1000)
console.log(motoA.name)
console.log(motoB.color)
motoB.congratulate()
console.log(motoA.congratulate === motoB.congratulate)
// "Z900RS"
// "blue"
// "Congratulations on the deal! Have a nice trip!"
// false

雖然上述範例中,每個物件實體都能使用相同的方法,但它們屬於不同的記憶體位址,直接寫在構造函式中也不易閱讀,接著讓我們來改寫上述的範例 :

  • 將構造函式的方法移動到原生原型上,也就是物件原生原型
  • 結果變成了 true,代表它們參考同樣的位址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function motorCycle(name, color, displacement){
this.name = name
this.color = color
this.displacement = displacement
}
motorCycle.prototype.congratulate = function(){
console.log('Congratulations on the deal! Have a nice trip!')
}
var motoA = new motorCycle('Z900RS', 'red', 948)
var motoB = new motorCycle('R1000', 'blue', 1000)

console.log(motoA.congratulate === motoB.congratulate)
console.log(motoA.__proto__ === motorCycle.prototype )
console.log(motorCycle.__proto__ === Function.prototype )
// true
// true,motoA繼承了 motorCycle的原生原型,但沒有繼承函式的原生原型
// true,motorCycle繼承了 Function的原生原型

建構式物件繼承了甚麼

不知道看到這裡會不會有個疑問,motoA 這個物件實體怎麼沒有繼承函式的原生原型,讓我們用 console.log(motoA.__proto__) 檢查它繼承了甚麼?

  • congratulate
    • 繼承 motorCycle的原生原型之自訂方法
  • constructor
    • 繼承 motorCycle這個函式
    • 可以利用它來辨認是從哪一個建構函式繼承的
  • __proto__
    • 繼承物件的原生原型
    • 建構式物件型別為 “object”

如何得知該方法為物件所有還是繼承的

可以利用 hasOwnProperty 來檢查。

1
2
3
4
5
6
7
8
9
10
11
12
var array = [1, 2]
array.forEach((item)=>{
console.log(item)
})
console.log( array.hasOwnProperty('forEach'))
console.log( array.hasOwnProperty('length'))
console.log( array.__proto__.hasOwnProperty('forEach'))
// 1
// 2
// false
// true
// true

Object.create( ) 將其他物件作為原型

除了使用構造函式轉為建構物件來自訂原生原型屬性,也可以將另一個物件直接作為建構物件使用。

  1. 自訂構造函式 > 建構物件 > 實體物件
  2. 將另一個物件屬性作為建構物件 > 實體物件
    • 新的實體物件透過 Object.create(另一物件) 將其作為原生原型屬性(包含屬性值)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function motorCycle(name, color, displacement){
this.name = name
this.color = color
this.displacement = displacement
}
motorCycle.prototype.congratulate = function(){
console.log('Congratulations on the deal! Have a nice trip!')
}

var motoA = new motorCycle('Z900RS', 'red', 948)
var motoB = new motorCycle('R1000', 'blue', 1000)

var motoC = Object.create(motoA)
motoC.color = 'black'
console.log(motoC)

console.log(motoC.prototype === motoA.prototype)
// true
// 原生原型看起來相同,但要注意,建構物件型別為 "object"
// 也就是說,它們沒有 prototype這個屬性,是 undefined

console.log(motoC.__proto__ === motoA)
// true
// 繼承另一個物件屬性作為原型

但是上面的範例這樣做會有點問題,繼承來的原生原型是一個物件,若不變更屬性值,基本上它們的 console.log() 內容一樣,但它是利用原型鏈向上層找,本身沒有自己的屬性,像這樣 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 這是 motoC */
console.log(motoC)
// 在開發者模式下的結果
// motorCycle {
// __proto__ : motorCycle
// }

/* 這是 motoA */
console.log(motoA)
// motorCycle {
// name: 'Z900RS'
// color: 'red'
// displacement: 948
// __proto__ : Object
// }

不過,還是可以利用 Object.create() 來快速複製屬性,例如繼承多個物件屬性時會用到它。

多層繼承實作

JS無法同時繼承多個物件,但是可以使用多層繼承的方式,實作中我們會用到 :

  • Object.creat()
  • new Object()
  • prototype
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// Object > Transportation > 汽車、摩托車、飛機 > 保時捷911、Z900RS、A320

/* 交通工具 */
function Transportation(tool){
this.toolName = tool || '交通工具'
}
// 在頂層交通工具函式原型上新增共用方法
Transportation.prototype.move = function(){
console.log(this.toolName + '向前移動')
}
Transportation.prototype.congratulate = function(){
console.log('Congratulations on the deal! Have a nice trip!')
}

/* 交通工具類別*/
function motorCycle(name, color, displacement){
// 執行 Transportation.call() 並填入工具名稱,完成該工具類別屬性
// 未加入任何原型
Transportation.call(this, '摩托車')
this.name = name
this.color = color
this.displacement = displacement
}
// 將交通工具類別的原型更改為頂層交通工具
motorCycle.prototype = Object.create(Transportation.prototype)
// motorCycle.prototype = Transportation.prototype
// 這樣也是可以運作,但是這樣變成傳參考,修改 motorCycle.prototype時,
// 也會一併修改 Transportation.prototype


// 將交通工具的類別的建構函式改回自己,否則為頂層交通工具的建構函式
// 有助於辨別從哪個函式轉來
motorCycle.prototype.constructor = motorCycle

// 將交通工具的類別,新增一個只有該類別才能用的函式
motorCycle.prototype.wheelie = function (){
console.log('翹孤輪~')
}
// 雖然它的繼承原型還是 Transportation,
// 但是同一層的建構函式已修改回原本交通工具類別,還是可以辨認

var motoA = new motorCycle('Z900RS', 'red', 948)
motoA.move() // "摩托車向前移動"
motoA.wheelie() // "翹孤輪~"

物件屬性特徵

當我們使用原型來新增方法時,有沒有發現到有個東西會用到,但實際上看不到它?

prototype

當我們新增一個方法在函式原型上,為什麼使用 console.log()時,卻找不到 prototype 呢? 讓我們使用 Object.getOwnPropertyDescriptor 這個方式來檢查該 prototype 這個物件屬性,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 創造一個構造函式
function proto(name){
this.name = name
}
// 接著,在原型上新增一個方法
proto.prototype.fnA = function (){
console.log('函式')
}
console.log(Object.getOwnPropertyDescriptor(proto, 'prototype'))

// Object {
// value: {
// fnA: funciton(){
// console.log('函式')
// },
// constructor: ƒ proto(name),
// __proto__: Object{...}
// }
// writable: true, // 可寫入
// configurable: false, // 不可被刪除
// enumerable: false, // 不可被列舉
// __proto__: Object{...}
// }

我們可以發現,prototype 這個屬性設定的其中兩個為 false

  • configurable 可否被刪除
  • enumerable 可否被列舉

很多教學建議新增原型方法時,盡量設定在這個屬性上,但實際上又看不到它,而上述就是為什麼看不到 prototype 這個屬性的原因。

參考來源

  1. 六角學院 - JS核心篇
  2. Huli- 該來理解 JavaScript 的原型鍊了
  3. MDN - 繼承與原型鏈