Mangochu's Blog

歡迎來到網頁學習天地

0%

JavaScript學習筆記1 - 什麼是原型基礎物件導向(上)

前言

這是自學筆記的第一篇,我在寫這一篇文章的背景是在學習完Freecodecamp還有實作React一段時間之後,所以對JavaScript的語法和使用上都有一定程度的了解。
文章最後也會附上參考資料來源,都是我認為非常棒的作者和文章。因為我本身是自學,所以這樣的學習順序不知是否正確,但是我以自己的經驗認為這樣的學習方式是很好的,原因是:

先學會用,再了解其背後的原理。

這樣不會一開始就覺得枯燥學不下去,而且舉例的時候才能有較好的理解和聯想關聯性。

原型基礎物件導向

首先說明何謂物件導向程式設計(Object-oriented programming:OOP),物件導向是一種程式設計模式,簡單來說就是將「物件」作為程式的基本單元來做設計。
並非所有程式語言都有物件導向設計,例如:C就不支援物件導向。後來的C++、Objective-C則是在C之上加入物件導向的功能。

近年來,物件導向的程式設計越來越流行於手稿語言中,包括Python和JavaScript都是建立在OOP原理之上的程式語言,而Perl和PHP亦分別在Perl 5和PHP 4時加入物件導向特性。

JavaScript是原型為基礎的物件導向設計。與Java、C++不同的是,JavaScript原始沒有類別(Class)的概念,而很多教學和文章介紹的類別定義方法(例如:ES6的Class),並不是真的是以類別為基礎(class-based)的,而是仍然以原型為基礎(prototype-based)的語法糖。

註: 語法糖(Syntactic sugar)指的是在程式語言中添加的某些語法,這些語法對語言本身的功能並沒有影響,但是能更方便使用,可以讓程式碼更加簡潔,有更高可讀性。

原型(prototype)

什麼是原型?

為了提供繼承性,物件可以有一個原型對象,而且物件將以此原型對象作為模版,繼承其方法及屬性。

簡單舉一個例子,Mary和Alan都是「人」。他們有共通的屬性,像是有姓名(name)、年齡(age)、性別(gender)、興趣(interests)等等。
首先用基礎JavaScript定義物件的方式,先定義一個Person物件,現在將它作為一個「原型」,接著我們用這個原型複製出Mary和Alan。

1
2
3
4
5
6
7
8
9
const Person = {
name: {
first: 'first',
last: 'last'
},
age: 'age',
gender: 'gender',
interests: 'interests'
}

若是還不知道如何使用原型鍊或建構子繼承的方式,可能會直接定義一個MaryAlan物件,並客製化屬性的值。
其實這樣做完全沒有使用Person這個原型,而是直接建立了兩個新的物件,意義上是錯的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const Mary = {
name: {
first: 'Mary',
last: 'Smith'
},
age: 21,
gender: 'female',
interests: ['music','skiing']
}

const Alan = {
name: {
first: 'Alan',
last: 'Green'
},
age: 26,
gender: 'male',
interests: ['movie','hiking']
}

如果是使用MaryAlan直接賦值為Person,並修改其屬性的值。
這個方法不但沒有沒有建立新的物件,而且MaryAlan的屬性都會在每一次新的賦值後被更改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const Mary = Person
Mary.name.first = 'Mary'
Mary.name.last = 'Smith'
Mary.age = 21
Mary.gender = 'female'
Mary.interests = ['music','skiing']

console.log(Person.age) // 21

const Alan = Person
Alan.name.first = 'Alan'
Alan.name.last = 'Green'
Alan.age = 26
Alan.gender = 'male'
Alan.interests = ['movie','hiking']

console.log(Person.age) // 26
console.log(Mary.age) // 26
console.log(Alan.age) // 26
// 這時候Mary也被更改成Alan了

由此可知用現有基礎觀念是無法實現「原型」的概念,所以這邊需要來認識「建構函式」。

建構函式(Constructor)和實例(instance)

建構函式的概念是從Java和C++來的,前面有稍微提到Java和C++是基於類別的程式語言,會利用類別來建立實例,而在類別裡有個很特別的函式叫「建構函式」,他會進行實例的初始化,設置對象屬性的初始值。其中,C++及Java都始用new命令來產生新的實例。

C++的寫法是:

1
ClassName *object = new ClassName(param);

Java的寫法是:

1
Foo foo = new Foo();

所以設計者便把new引入JavaScript,但是JavaScript沒有Class,後面要接什麼呢?
這時,他想到C++及Java使用new命令時,都會調用「類別」裡的建構函數(constructor),所以他做了一個簡化,直接在new的後面接一個函式吧!

以上一個例子來說,現在有一個叫做Person的函式:

1
2
3
4
5
6
7
8
9
10
11
12
function Person(first, last, age, gender, interests) {
this.name = {
'first': first,
'last' : last
};
this.age = age;
this.gender = gender;
this.interests = interests;
}

const Mary = new Person('Mary','Smith',21,'female',['music','skiing'])
const Alan = new Person('Alan','Green',26,'male',['movie','hiking'])

對這個函數使用new,JavaScript就會將它視為建構函數,並且生成一個Person的實例。建構函數中的this代表新創建的實例對象。

new運算子的缺點

用建構函數生成實例對象,有一個缺點,就是無法共享屬性和方法。接續上一段程式碼,修改Mary或Alan其中一個,不會影響到另一個。

1
2
3
Alan.age = 10
console.log(Mary.age) // 同樣顯示21,不受Alan更改的影響
console.log(Alan.age) // 10

為什麼說這是缺點?
每一個實例對象,都有自己的屬性和方法,因為沒辦法共享數據,所以沒辦法節省資源。

prototype屬性引入

考慮到這一點,設計者決定為建構函數添加一個prototype屬性,而prototype屬性值就是一個物件。所有實例對象需要共享的屬性和方法,都放在這個物件中;不需要共享的屬性和方法,就放在建構函式中。

實例一但創建(如Alan和Mary),將自動引用prototype物件的屬性和方法。也就是說,實例的屬性和方法,分成兩種,一種是本地的,另一種是引用的。

用同樣個例子說明,建構函式中的屬性(firstlastagegenderinterests)這些都是本地給值,都會各自佔用資源。接下來我們用prototype給值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Person(first, last, age, gender, interests) {
this.name = {
'first': first,
'last' : last
};
this.age = age;
this.gender = gender;
this.interests = interests;
}

Person.prototype.species = 'human' // 會在Person.prototype新增species屬性

const Mary = new Person('Mary','Smith',21,'female',['music','skiing'])
const Alan = new Person('Alan','Green',26,'male',['movie','hiking'])

console.log(Mary.species) // human
console.log(Alan.species) // human

現在species屬性放在prototype物件裡,是兩個實例共享的,只要修改prototype物件,就會同時影響兩個實例。

1
2
3
4
Person.prototype.species = 'vampire'

console.log(Mary.species) // vampire
console.log(Alan.species) // vampire

原型鏈(prototype chain)

JavaScript沒有父類、子類的繼承,也沒有類別(class)和實例(instance)的區分,全靠「原型鏈」(prototype chain)的方式來繼承。

物件與物件之間的連結有各自的屬性,讓JavaScript可以實現「繼承」,接下來就讓我們深入了解這些屬性。

prototype

所有JavaScript中的函式都有一個內建的prototype屬性,指向一個特殊的prototype物件,prototype物件中也有一個constructor屬性,指向原來的函式。
用程式碼來驗證:

1
console.log(Person.prototype.constructor === Person)  // true

proto

再來是__proto__這個內部屬性,它是每一個JavaScript裡物件都有的內部屬性,它會指向該物件的原型(prototype),用來連接出原型鏈,也就是JavaScript的繼承方式。

1
console.log(Alan.species) //human

當查找物件的屬性或方法時,若本身的物件找不到時,就往更上層尋找,直到串鏈尾端。實例Alan其實是透過原型prototype才得以繼承species屬性。

對於一個函式而言,它的原型就是Function.prototype,這可以說是所有函式的發源地。所以Person函式的__proto__會指向Function.prototype。

Person.prototype__proto__又指向哪裡?Person.prototype本身是也一個物件,它直接指向JavaScript中最上層的物件起源Object.prototype

由此可知,Function.prototype也會同樣指向Object.prototype。然而剛剛也說了Object.prototype是原型鏈的最上層,那它的__proto__又會指向哪呢?以下用程式碼驗證就清楚了:

1
2
3
4
5
console.log(Person.prototype.__proto__ === Object.prototype) // true

console.log(Person.__proto__) // Function.prototype
console.log(Person.__proto__.__proto__) // Object.prototype
console.log(Person.__proto__.__proto__.__proto__) // Null

原型鏈的終點答案就是指向Null,所以可以說Object.prototype就是原型鏈的最上層。

用之前的例子做出以下簡單的圖示,圖片中紅色箭頭即為__proto__的指向

註:__proto__注意是前後各有兩條下底線(_)。
註:雖然現今__proto__被幾乎所有瀏覽器支援,且ES6已經正式被標準化,以確保 Web 瀏覽器的兼容性,但是不推薦使用,除了標準化的原因之外還有性能問題。為了更好的支援,建議使用Object.getPrototype()。

指令

這邊要指供兩個指令來為原型鏈的介紹作結:

  1. hasOwnProperty
  2. instanceof

這個是誰的屬性或方法?

hasOwnProperty可以用於只檢查屬性或方法是否屬於當前物件

1
2
Alan.hasOwnProperty('name') // true
Alan.hasOwnProperty('species') // false

name確實是屬於Alan實例內部的屬性,但是species並不在其中,而是在原型鏈中。

如果要檢查整條串鏈,可以用prop in object的方法:

1
'species' in Alan // true

prop in object會檢查整個原型鏈,不管屬性是否可列舉

1
2
3
4
5
6
7
8
Object.defineProperty(Alan, 'legs', {
value: 2,
writable: true,
configurable: true,
enumerable: false, // 設定 legs 為不可列舉的屬性
})

'legs' in Alan // true

這邊自訂一個legs屬性,並設定為不可列舉,用prop in object依然可以在Alan原型鏈中找到legs

如果想要印出實例中所有的屬性方法,可以用for loop prop in object的方法:

1
2
3
for (let prop in Alan) {
console.log(prop);
}

for loop prop in object會檢查整個原型鏈且為可列舉的屬性。
所以會印出:

1
2
3
4
5
name
age
gender
interests
species

可以發現除了legs以外,所有原型鏈的屬性都被印出。

這個是誰的實例?

語法

object instanceof constructor

instanceof運算符用來檢查constructor.prototype是否存在於object的原型鏈上。這在傳統物件導向環境中稱為「內省」(introspection)。

1
2
3
4
5
Alan instanceof Person  // true
Alan instanceof Object // true
Alan instanceof Function // false
Person instanceof Function // true
Person instanceof Person // false

instanceof運算符對不同資料型別作用的範例可以參考這裡

另一個方法是isPrototypeOf,這個用法跟instanceof基本上相同,但是在某些情況下instanceof會出現TypeError而isPrototypeOf運作正常。所以相較之下isPrototypeOf是較萬用的。可以參考這篇

語法

isPrototypeOf(object)

1
Person.prototype.isPrototypeOf(Alan)