💡 本篇主題與重點字:**Prototype Chain**
- Prototype
原型 Prototype
每個 JavaScript 的物件都有一個原型(使用__proto__
存取或直接叫內部屬性 [[Prototype]]
)。這個原型本身也是一個物件,它也有自己的原型,如此串起來形成原型鏈。
原型鏈 Prototype Chain
如果你訪問一個物件上不存在的屬性,JS 引擎就會沿著這個物件的原型鏈向上查找,直到找到該屬性或到達 null
為止,也就是達到此原型鏈的頂端。
1. 手動設定原型 Object.create
Object.create(animal)
創建了一個新的物件 dog
,這個新物件的 [[Prototype]]
直接指向 animal
,意味著這個新物件可以「看到」animal
上的所有方法和屬性。
在這邊的 dog
的 __proto__
指向 animal
,因此可以使用父物件定義好的方法 animal.speak()
。
Object.create()
的好處這邊使用 Object.create(Animal.prototype)
,好處在於它創建了一個「乾淨」的物件,這個物件沒有任何實例屬性,因此避免了呼叫 constructor 的副作用、原型鏈正確地指向 Animal.prototype
。
假設我們想要建立一個 sub Class Dog
,這樣的做法也允許我們安全地添加 Dog
特有的方法,而不會影響 Animal.prototype
。
2. 自定義建構函式 + 原型繼承
當我們呼叫 myDog.speak()
時:
myDog
物件本身是否有 speak
方法 → 沒有myDog.__proto__
(也就是 Dog.prototype
)是否有 speak
方法 → 沒有Dog.prototype.__proto__
(也就是 Animal.prototype
)是否有 speak
方法 → 找到了!Animal.prototype.speak
,但是 this
仍然指向 myDog
JavaScript 本質上是基於原型(prototype-based)的語言,而不是基於 class 的語言。在 JS 中,我們使用函數和原型來模擬傳統物件導向語言中的類繼承行為,這種做法就被稱為偽類繼承,如此處的 Animal.call(this, name)
。
讓我們來說文解字一下:Animal
實際上是一個函數,但我們把它當作 Class 來使用。在真正的 class-based language 中,類別是一個獨立的概念,但在 JavaScript 中,我們只是使用函數來模擬類別的行為,叫做「偽類」。而 Animal.call(this, name)
這行程式碼的作用是呼叫 Animal
函數,但將執行環境(this
)設定為當前的 Dog
實例並傳入 name
參數,這樣做的效果等同於在 Dog
的 constructor 中執行了 this.name = name
,也就是說,Dog
實例獲得了 Animal
中定義的屬性。這個子物件透過設定自己的原型、函數呼叫和 this
綁定的行為,模擬了真正類別「繼承」父物件的屬性與方法。
與方法繼承的區別
要注意的是,Animal.call(this, name)
只處理屬性的繼承,並不處理方法的繼承。方法的繼承是透過Dog.prototype = Object.create(Animal.prototype)
這行來實現的。這種設計分離了屬性繼承(在建構函數中使用call()
方法)和方法繼承(在原型鏈中設定繼承關係)。
當我們執行 Dog.prototype = Object.create(Animal.prototype)
時,我們實際上建立了這樣的原型鏈結構:
Dog 實例 → Dog.prototype → Animal.prototype → Object.prototype → null
Dog.prototype
的原型指向,而不是複製方法或屬性Dog
實例共享同一個 Animal.prototype
物件上的方法dog.speak()
時,JavaScript 引擎會沿著原型鏈查找這個方法即使在創建實例之後,我們仍然可以給父類添加方法,而呼叫子類的方法,則會一層層沿著 prototype chain 往上找。
1. 直接賦值Dog.prototype = Animal.prototype;
這麼做的話,雖然 Dog 和 Animal 共享同一個 prototype 物件,但是當我們添加 Dog 特有的方法時,Animal 也會有這些方法。
2. 使用 newDog.prototype = new Animal();
這會執行 Animal 的建構函數,可能會有副作用,而且 Dog.prototype 會有不必要的實例屬性。
讓我們回顧剛剛的例子
不知道讀者有沒有注意到這一行
Dog.prototype.constructor = Dog;
這句的用意是修正 constructor 指向。什麼意思呢?為什麼要這麼做?
我們再次思考當我們使用 Object.create() 時發生了什麼:
當我們執行 Dog.prototype = Object.create(Animal.prototype)
時,實際上是用一個新的物件替換了 Dog.prototype
。這個新物件是基於 Animal.prototype
創建的,所以它繼承了 Animal.prototype
的所有屬性,包括 constructor 屬性。
Object.create(Animal.prototype)
創建的新物件,其原型鏈指向 Animal.prototype
。當我們存取 Dog.prototype.constructor
時,由於新的 Dog.prototype
物件本身沒有 constructor 屬性,JavaScript 引擎會沿著原型鏈向上查找,結果找到了 Animal.prototype.constructor
,也就是 Animal
。
那你此時可能就會想,難不成每次寫 code 都要記得這句嗎?我們當然可以寫一個自定義繼承工具 function,使用 Object.create
手動指定原型,並重新指定 constructor,來避免忘記重新指向而導致非預期的行為與錯誤。
最後,附上昨天練習的解答
(1)
分析流程
步驟 | 執行內容 | 類型 | 加入佇列 |
---|---|---|---|
1 | console.log('1') | 同步 | - |
2 | setTimeout 註冊 | 宏任務 | 加入宏任務 queue1 |
3 | Promise.then() | 微任務 | 加入微任務 queue |
4 | console.log('5') | 同步 | - |
微任務階段
宏任務階段
最終輸出
1
5
3
2
4
(2)
分析流程
步驟 | 執行內容 | 類型 | 加入佇列 |
---|---|---|---|
1 | console.log('start') | 同步 | - |
2 | setTimeout 註冊 | 宏任務 | 加入宏任務 queue1 |
3 | Promise.then() | 微任務 | 加入微任務 queue |
4 | asyncFunc() 開始 | 同步 | - |
5 | await → 產生微任務 | 微任務 | 加入微任務 queue |
6 | setTimeout 註冊 | 宏任務 | 加入宏任務 queue2 |
7 | console.log('end') | 同步 | - |
微任務階段
宏任務階段
最終輸出
start
async start
end
promise1
promise2
async end
timeout1
timeout1-microtask
timeout2