本篇為 [JS201] 進階 JavaScript:那些你一直搞不懂的地方 這門課程的學習筆記。如有錯誤歡迎指正!
學習目標:
P1 你知道 Prototype 在 JavaScript 裡是什麼
P1 你知道大部分情況下 this 的值是什麼
P2 你知道物件導向的基本概念(類別、實體、繼承、封裝)
什麼是物件導向?
其實我們在上一篇 Closure & Scope Chain 筆記中,就有稍微提到物件導向的相關觀念:
function createWallet(initMoney) {
var money = initMoney;
return {
add: function(num) {
money += num;
},
deduct: function(num) {
if (num >= 10) {
money -= 10;
} else {
money -= num
}
},
getMoney() {
return money;
}
}
}
var myWallet = createWallet(99);
myWallet.add(1);
myWallet.deduct(100);
console.log(myWallet.getMoney()); // 90
在物件導向的世界,我們以呼叫 function 的方式進行操作,會比較像是在使用物件的形式。例如 myWallet.add() 這句的意思,就像是對 myWallet 這個物件做一些操作。
像這樣透過塑造物件,就可以不需一直 Call function,也會使程式更模組化一些,並且能夠隱藏內部資訊。
建立物件實體(Object instance)
根據 MDN 說明,其實物件導向(Object-oriented)的基本概念就是:「採用物件(Objects)來模塑真實的實物世界」。
也就是說,我們可以在程式中透過 Objects(物件)來塑造其模型,而物件能夠存放其相關資料與程式碼,並且能使用方式(Method)來進行存取。
舉個簡單的例子,以一隻狗來說,可能會有許多基本資訊,像是毛色、身高、名字、性別等,透過這些抽象概念或特質,我們能夠建立一個「Dog」範本,作為代表狗的物件類別(class)。
接著我們可以從這個類別建立物件實體(Object instance),也就是該透過執行類別的「建構子函式(Constructor Function)」建立物件,而這段過程就稱為實體化(Instantiation)。
// 透過類別中的建構子將物件實體化
class -> Constructor -> Object instance
Constructor Function & new
前面我們提到,JavaScript 是使用類別(class)中的建構子函式(Constructor Function)來定義物件與功能。
但實際上,JavaScript 程式本身並沒有 class 可以使用,是到了 ES6 以後才出現。簡單來說,class 其實是 ES6 提供的語法糖,底層機制仍然是 JavaScript 的原型(prototype)。
接著同樣以建立 Dog 物件類別為例:
- class 後面要接名稱,表示要「建立 Dog 這個 class」,第一個字必須為大寫
- 接著透過
new
指令來實體化,建立出物件實體(Object instance) - class 中的
constructor
用來定義物件,也就是在建立時會呼叫constructor
來執行內部的程式碼,目的是初始化 instance - 透過 instance 傳入參數,再由
constructor
接收,可設定 Dog 的屬性name
class Dog {
constructor(name) {
this.name = name;
console.log(this);
console.log(this.name);
}
}
var a = new Dog('dog A');
var b = new Dog('dog B');
// Dog {name: "dog A"}
// dog A
// Dog {name: "dog B"}
// dog B
- constructor 也可用透過呼叫 function 來定義物件的方法
- 在 constructor 中,有設定就會有取得。例如:setName 會被稱為 setter,getName 則是 getter
- 透過這種形式,就不需直接操作 class 內部的值
- this 在 class 的用途:誰呼叫它就指向呼叫它的
- 例如
a.sayHello();
:代表 a 透過 sayHello 呼叫 this,所以這個 this 是 a
- 例如
class Dog {
constructor(name) {
// setter
setName(name) {
this.name = name;
}
// getter
getName() {
return this.name;
}
sayHello() {
console.log('hello');
}
}
var a = new Dog('dog A');
console.log(a.name) // dog A
a.sayHello(); // hello
var b = new Dog('dog B');
console.log(b.name) // dog B
b.sayHello(); // hello
此外,藉由 new 指令建立出 a 和 b 兩個物件,這兩個物件是完全獨立的物件,且都事先具備 Dog 這個 class 的所有屬性。
這其實和物件導向的三大特性有關,這部分我們後面會再提到:
- 封裝(Encapsulation)
- 繼承(Inheritance)
- 多型(Polymorphism)
Prototype Chain 原型鍊
在建立物件實體(Object instance)之後,接著需透過原型(Prototype)機制來相互繼承功能。
前面有提到,class 其實是 ES6 提供的語法糖,底層機制仍然是 JavaScript 的原型(prototype)。因此,即使是在不使用 class 的情況下,我們一樣可以透過 function 和 prototype 來做到 class(類別)的功能。
ES5 寫法
若將前面的範例改成 ES5 寫法,也就是替換掉 constructor 和 new,如下方程式碼:
function Dog(name) {
var myName = name;
return {
getName: function () {
return myName
},
sayHello: function () {
console.log('say hello!')
}
}
}
var a = Dog('dog A'); // say hello!
a.sayHello();
var b = Dog('dog B'); // say hello!
b.sayHello();
console.log(a.sayHello === b.sayHello); // false
會發現 a.sayHello 和 b.sayHello 兩個同樣的 function 卻不是一樣的東西,代表程式在背後其實儲存了兩次。原因在於 class 中是不同的 instance,造成 this 指向不同。
但這其實會產生一個問題,如果今天有一萬隻狗,就會儲存一萬次 function,明明都是在處理同樣的事情,應該改成使用同一個 function 去跑不同的 instance 即可。
以 .prototype
連結 function
在 JavaScript 機制中,有個 .prototype
語法能夠連結 function,如下方程式碼:
- 使用
Dog.prototype.sayHello
將建構 function 新增一個 sayHello 的屬性 - new 出來的物件皆可存取 sayHello 這個屬性
function Dog(name) { // 等於 constructor
this.name = name;
}
Dog.prototype.getName = function () {
return this.name;
}
Dog.prototype.sayHello = function () {
console.log('say hello!');
}
var a = new Dog('dog A');
var b = new Dog('dog B');
console.log(a.sayHello === b.sayHello); // true
兩個 function 就會是相同的,因為 a 和 b 都是 prototype 上面的 function。如此一來,我們就可以使用同一個 function 去跑不同的 instance,透過這個方式實作 JavaScript 的物件導向。
__proto__
& prototype
所以 prototype 是什麼?在 ECMA5.1 標準中,定義原型(prototype)是為其他對象提供共享屬性的對象:
object that provides shared properties for other objects
也就是說,原型(prototype)的本質,就是能夠分享自己的屬性給其他物件使用,而所有的物件都有原型(prototype)屬性。
接著舉一個簡單的物件為例:
const obj = { name: "Dog" };
console.log(obj);
可以看到 obj 物件的原型物件,具有的 __proto__
、constructor
等特性,這些都是由原型物件分享給 obj 物件使用的特性:
接著來說明 prototype
和 __proto__
之間的差異:
prototype
(顯性原型)
- 用來實現基於原型的繼承與屬性的共享
- 例如:用來指定屬性或 function
__proto__
(隱性原型) - 構成原型鏈,同樣用於實現基於原型的繼承
- 例如:繼承資料
- 由 new 指令建立的物件,
__proto__
屬性會指向該原型物件的prototype
- 也就是說,當我們想找 obj 物件中的 X 屬性時,如果在 obj 中找不到,就會繼續沿著
__proto__
往下一層查找
參考資料:js中__proto__和prototype的區別和關係?
接著繼續沿用剛才的 Dog 範例:
function Dog(name) { // 等於 constructor
this.name = name;
}
Dog.prototype.getName = function () {
return this.name;
}
Dog.prototype.sayHello = function () {
console.log('say hello!');
}
var a = new Dog('dog A');
var b = new Dog('dog B');
console.log(a.__proto__);
console.log(b.__proto__);
// Dog { getName: [Function], sayHello: [Function] }
// Dog { getName: [Function], sayHello: [Function] }
會發現 a.__proto__
和 b.__proto__
其實就代表 Dog.prototype,會得到相同結果:
console.log(a.__proto__ === Dog.prototype)
// true
也映證我們前面所說,class 底層其實還是 prototype(原型)這件事,只是寫法不同。
而當我們執行 a.sayHello()
的時候,程式會沿著原型鏈尋找 sayHello 屬性,參考過程如下:
a.sayHello()
1. a 有沒有 sayHello
2. a.__proto__ 有沒有 sayHello
3. a.__proto__.__proto__ 有沒有 sayHello
4. a.__proto__.__proto__.__proto__ 有沒有 sayHello
5. 找到最後剩下 null,也就是最頂層
a.__proto__ = Dog.prototype
a.__proto__.__proto__ = Dog.prototype.__proto__ = Object.prototype
a.__proto__.__proto__.__proto__ = null
像這樣透過 __proto__
不斷串起來的鏈,就稱作原型鏈(Prototype Chain),其實概念類似於之前提過的 Scope Chain。透過這條原型鏈,就可以引用或繼承自己物件沒有的屬性。
// Prototype Chain
a --> Dog.prototype --> Object.prototype --> null
// 等同於
a -> a.__proto__ --> a.__proto__.__proto__ --> a.__proto__.__proto__.__proto__
hasOwnProperty()
- 用來判斷某個物件是否含有指定的自身屬性
- 以 Dog 例子來說,可用
a.hasOwnProperty('sayHello')
來判斷 sayHello 是存在於 instance 還是該原型鏈中
console.log(a.__proto__.hasOwnProperty('sayHello'));
// true
console.log(a.hasOwnProperty('sayHello'));
// false
instanceof
- 用來判斷 A 物件是否為 B 的實例,比較的是原型(prototype):
console.log(a instanceof Dog); // true
比較:instanceof vs typeof
- typeof:用來判斷參數是什麼型別,回傳值是 data type
console.log(typeof 123) // number
new 扮演的角色
在理解 new 在背後做了什麼是之前,先來看這個例子:
function test() {
console.log(this)
}
test();
會發現 this 其實是非常大的值,裡面包含許多東西:
.call()
:另一種呼叫 function 的方式
此外,function 還有一種呼叫方式叫做 .call()
,如果我們 call 來呼叫 test,並在括號內帶入值,將會改變 function 結果:
function test() {
console.log(this)
}
test.call('123'); // [String: '123']
test.call({}); // {}
也就是說,若使用 .call()
呼叫 function,會將傳入的東西設定為 this 的值。
接著我們可以透過「.call()
第一個參數代表 this 指向」這個概念,試著自己建立一個 new 的模型:
function Dog(name) {
this.name = name;
}
Dog.prototype.getName = function () {
return this.name;
}
Dog.prototype.sayHello = function () {
console.log('hello', this.name);
}
// 使用 new 方法
var a = new Dog('dog a');
// 透過 function 實作 new 方法
var b = newDog('dog b')
function newDog(name) {
var obj = {}; // 先建立一個空物件 obj
Dog.call(obj, name); // 呼叫 constructor,this 會指向這個物件
obj.__proto__ = Dog.prototype; // 建立物件的原型鏈
return obj; // 回傳完成的物件 obj
}
a.sayHello(); // hello dog a
b.sayHello(); // hello dog b
透過 function 實作 new 方法,上述的程式碼步驟如下:
- 先建立一個空物件 obj
- 呼叫 constructor,然後把 Dog 利用
.call()
帶入空物件,並設定參數:Dog.call(obj, name)
,第一個參數代表 this 指向 obj,第二個參數代表要帶入的值 - 接著設定方法:建立物件的原型鏈,針對 obj 的
.__proto__
等同於 Dog 的.prototype
即可完成關聯 - 回傳完成的物件 obj
物件導向的繼承:Inheritance
當其他類別需要用到共同屬性時,不需再重新建立 class 的各種屬性,可以利用繼承的方法,來直接存取父層的屬性。
例如狗本身有名字有動作,有自己的屬性與方法。而黑狗也屬於一種狗,黑狗也有同樣的屬性與方法,只是會有些微差異,這種情況我們可以透過繼承,也就是讓黑狗 class 直接使用狗 class,就不需再另外建立。
如下方的範例:
class Dog {
constructor(name) {
this.name = name;
}
sayHello() {
console.log(this.name);
}
}
// 繼承 Dog class
class BlackDog extends Dog {
test() {
console.log('test', this.name);
}
}
const black = new BlackDog('hello');
black.sayHello(); // hello
修改子層 class 的 constructor
如果想要在子層的 class 更改 constructor 屬性時,必須先呼叫 super()
,並把父層 class constructor 需要的參數傳進去。否則就只會初始化子層的 constructor,不會有繼承的作用。
透過 super()
來呼叫父層 class 的 constructor,才能連同父層的 constructor 一併初始化,並接收初始化的值:
class Dog {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
sayHello() {
console.log('Hello, ' + this.name);
}
}
class BlackDog extends Dog { // 繼承
constructor(name) {
super(name); // 繼承後在 constructor 一定要使用 super 接收資料
this.sayHello(); // 當 BlackDog 被建立時,就打招呼
}
test() {
console.log('test!', this.name);
}
}
const a = new Dog('dog a');
a.sayHello();
// Hello, dog a.
const black = new BlackDog('I am black dog');
// Hello, I am black dog
透過子層繼承父層類別可延伸多種型態,也就是同樣的方法名稱會有多種行為,使用上也增加彈性,這其實就是物件導向的特性之一:多型(Polymorphism)。
結語
在學習 JavsScript 之前,一直以為物件導向和 this 是能夠畫上等號的(三個的那種)。直到實際學到物件導向以後,才瞭解到物件導向中有許多觀念,其實和在之前學到的 Hoisting、Closure 有很大的關聯。此外,物件導向其實應用在許多現代的程式語言,以物件導向的方式進行開發。
物件導向程式的寫法,基本上可分為三部分:
- 定義物件類別(class)。例如:
class Dog
- 定義物件類別中的屬性與方法。例如:可使用
dog.name
存取屬性,使用dog.sayHello()
存取方法 - 定義物件之間的行為,也就是主程式
之所以需要物件導向,最重要的目的就是把資料(屬性)與函式(方法)結合在一起,定義出物件模型,這麼做有幾個優點:
- 便於重複使用程式碼
- 能夠隱藏程式內部資訊
- 透過模組化來簡化主程式邏輯
而這些概念,其實也就是先前談到有關物件導向的三大特性,並且三者具有次序性,沒有封裝就不可能有繼承、沒有繼承就不可能有多型:
- 封裝(Encapsulation):
- 藉由把程式包成類別,能夠隱藏物件內容
- 避免程式間互相干擾,也利於後續維護
- 繼承(Inheritance):
- 子層能夠繼承使用父層的屬性和方法,並且加以微調
- 能夠重複使用程式碼
- 多型(Polymorphism):
- 父層可透過子層衍伸成多種型態,接著子層可藉由覆寫父層的方法來達到多型
- 可增加程式架構的彈性與維護性
藉由瞭解什麼是物件導向,為什麼需要物件導向以後,對整體架構似乎又更加清楚一些。過程中也查了許多資料,在碰到新的名詞時總會感到慌張,像是 constructor(建構子)、prototype(原型)、instance(實例)等等,其實只要能夠先瞭解定義是什麼,就不難繼續理解整體架構。
最後,在找相關資料的時候,有在這篇網誌中,看到使用泡麵的例子來比喻物件導向,因為還蠻喜歡的也記錄在這裡:
- 由泡麵工廠製作麵和醬包,並包裝在一起,我們可以直接買來享用
- 我們可以在泡麵中自己加料,或是不用泡的改用炒的
- 同樣都是泡麵,卻能夠實作出不同的口味
參考資料:
- 該來理解 JavaScript 的原型鍊了
- [week 16] JavaScript 進階 - 淺談物件導向 & this
- JavaScript: Object-oriented JavaScript, Prototype Chain & This