跳至主要内容

[JS] 原型

TL;DR

參考資料

相關連結


建構函式

Javascript原本就提供一系列建構函式,例如ObjectStringBooleanDate,Promise...etc。

info

一般來說我們習慣用大寫來表示建構函式,例如Date

所以如果我們自己建立建構函示時,雖然使用小寫並不會產生任何錯誤,也會建議使用大寫。

建立自己的建構函示

建構函示其實與一般函示沒有差別,都是使用function來宣告,我們來簡單創建一個建構函式:

function Dog(name,size,color){
this.name=name
this.size=size
this.color=color
}

接著我們使用new運算子來透過這個Dog建構函示建立實體(instance)

const white=new Dog('小白','小型犬','白色')

console.log(white) // Dog {name: '小白', size: '小型犬', color: '白色'}

使用new運算子時,會進行以下動作:

  1. 創建一個空物件實體{}
  2. 接著將該函式的this指向步驟一創建的空物件
  3. 依序執行(e.g:this.name=name會在物件內創建一個name屬性,並且將參數name的值賦予到該屬性上)

經過以上動作,我們建立了一個建構函示Dog的原型,並且利用new運算子生成一個名叫小白的小型犬實體。

[[prototype]]

延續上一段的程式碼,我們先來觀察剛剛建立好的實例,因為是一個物件所以可以展開如下:

>  Dog
color: "白色"
name: "小白"
size: "小型犬"
Dog原型

Safari的展開畫面
Safari的Dog實例

Chrome的展開畫面
Chrome的Dog實例

[[prototype]]指向的是目前這個實例的原型,以我們上方範例中,我們的white實例的原型即為Dog建構函示。

在自己創建的建構函示實例這邊,實例的[[prototype]]只有Safari會明確的寫出是 Dog原型,其他如Chrome,Firefox,Arc等等瀏覽器都會顯示為 Object

那麼[[prototype]]能做到什麼事情呢? 我們知道所有的實例都會透過原型鏈鏈聯結起來。我們在[[prototype]]內希望新增一個汪汪叫的方法。

danger

在撰寫方法之前,要先知道[[prototype]]在一些瀏覽器內可能會用__proto__代替,這兩個指的是同一個東西。

需要注意的是,__proto__的用法已經從Web標準中移除,所以不建議再繼續使用,應該改用Object.getPropertyOf(或是Reflect.getPrototypeOf())來取代

__proto__新增方法

因為dog.__proto__本身是一個物件

console.log(typeof white.__proto__); // 'object'

const ary=[1,2,3];
console.log(typeof ary.__proto__); // 'object'

既然是物件,我們就可以在裡面新增一個方法:

white.__proto__.bark=function(){
console.log(`${this.name} 汪汪叫!!!`);
}

新增這個汪汪叫的方法之後,我們就可以讓剛剛建立好的實例(小白)汪汪叫:

white.bark(); // 小白 汪汪叫!!!

而且我們再次透過new運算子建立一個新的實例小黑,小黑也可以透過原型鏈的方式,找到bark這個方法,所以小黑也可以汪汪叫了:

const black=new Dog('小黑','大型犬','黑色');

console.log(black); // Dog {name: '小黑', size: '大型犬', color: '黑色'}
black.bark(); // 小黑 汪汪叫!!!

最後我們可以來觀察一下小黑跟小白的[[prototype]]

console.log(white.__proto__); // {bark: ƒ}
console.log(black.__proto__); // {bark: ƒ}

console.log(white.__proto__===black.__proto__); // true

Object.getPrototypeOf()新增方法

因為不建議繼續使用__proto__,所以我們要來介紹Object.getPrototypeOf()

console.log(typeof Object.getPrototypeOf(white)); // 'object'
console.log(white.__proto__===Object.getPrototypeOf(black)); // true

如果希望透過Object.getPrototypeOf來新增的話寫法如下

Object.getPrototypeOf(white).eatbone=function(){
console.log(`${this.name} 啃骨頭`);
}

black.eatbone(); //小黑 啃骨頭
info

靜態方法 Reflect.getPrototypeOf()Object.getPrototypeOf() 方法幾乎是一樣的,都是傳回指定物件的原型(即內部的 [[Prototype]] 屬性的值)。

通常我們只要記得Object.getPrototypeOf() 方法即可。

這邊有提到靜態方法,後續會介紹。

由建構函式新增方法(推薦)

其實除了上述兩種新增方法以外,還有另一種更推薦的方式 - 直接透過建構函示來新增

前面我們不管是透過Object.getPrototypeOf()__proto__,都是取得一個物件,並且在該物件下新增方法。其實我們也可以直接透過建構函示取得:

console.log(typeof Dog.prototype); // 'object'
console.log(Dog.prototype===white.__proto__); // true
console.log(Dog.prototype===Object.getPrototypeOf(black)); // true

也就是說,我們在建構函式的prototype屬性內所新增的方法,會對應到該建構函示生成實例的[[prototype]]內!

note

Dog.prototype內的prototype,與[[prototype]]是完全不一樣的東西!!

我們可以透過建構函式的prototype來新增方法如下:

Dog.prototype.getInfo=function(){
console.log(`我叫${this.name},我是${this.size},我的毛色是${this.color}`);
}

調用方法:

console.log(white.getInfo()); // 我叫小白,我是小型犬,我的毛色是白色
console.log(black.getInfo()); // 我叫小黑,我是大型犬,我的毛色是黑色

注意事項

需要注意,在定義方法的時候不可以使用箭頭函式(arrow function),而是要使用函式表達式(匿名函式)。

這是因為我們如果使用箭頭函式定義方法的話,可能this的指向會有誤,在simple call的情況下(不考慮'use strict'嚴格模式),this的指向會是window

// 正確範例
const ary1=[1,2,3,4,5]
Array.prototype.getLastElement=function(){
return this[this.length-1]
}
console.log(ary1.getLastElement()) //5

//錯誤範例
const ary2=[6,7,8,9]
Array.prototype.getFirstElement=()=>{
return this[0];
}
console.log(ary2.getFirstElemnt()) //error!!! expect 6, but undefined
console.log(ary2.getLastElement()) //9

我們首先先直接宣告一個陣列,並且利用Array建構函示來新增一個取得最後一個元素的方法

因為在定義getFirstElement時使用箭頭函式,this在這個狀況下指向window,所以this[0]會是undefined。並且我們也同樣可以在ary2上使用先前所定義的getLastElement方法

constructor

觀察white的內容時,可以發現[[prototype]]內現在除了我們剛剛透過三種方式新增的三個方法以外,還有另一個屬性 - constructor

note

white[[prototype]]內,另外也有另一個[[prototype]],指向原始物件原型的prototype

Javascript就是透過原型鏈的方式來實現的,所以我們透過Dog所產生的實例,也可以使用到Object.prototype內所定義的方法!

white.__proto__.__proto__===Object.prototype // true

我們在建立建構函式的時候,在建構函式的prototype內就會存放一個屬性constructor,內容指向建構函示本身。

Dog.prototype.constructor===Dog // ture

後續我們會介紹到使用Object.create的方式來創建兩層以上的自訂原型鏈,到時候我們會需要重新手動調整constructor的指向。

instanceof

instanceof 運算子用於檢測建構函式的prototype屬性是否出現在某個實例物件的原型鏈上。

用法如下:

function Dog(name,size,color){
this.name=name
this.size=size
this.color=color
}

const white=new Dog('小白','小型犬','白色');
const ary=[1,2,3,4];

console.log(white instanceof Dog) // true
console.log(white instanceof Object) //true
console.log(ary instanceof Dog) // false
console.log(ary instanceof Object) // true

簡單來說,因為white.__proto__.constructorDog,所以white instanceof Dog會是true
同理,white.__proto__.__proto__.constructorObject,所以white instanceof Object也會是true

note

使用instanceof來檢查是不是該建構函式的實例,其實就是在檢查他的原型鏈上的constructor有沒有指向該建構函式

當然,因為ary透過原型鏈查找(即ary.__proto__)會先找到Array.prototype,再往上一層去尋找(即ary.__proto__.__proto__)會查找到Object.prototype。中間並不會有Dog.protoype

所以ary instanceof Dog就會是false

note

在原型鏈向上尋找的過程中,使用Object.getPrototypeOf()的寫法會不易閱讀:

Object.getPrototypeOf(Object.getPrototypeOf(ary))===Object.prototype // true
ary.__proto__.__proto__===Object.prototype //ture

兩者的寫法是相同的,但是為了閱讀方便,仍然採用__proto__來撰寫筆記。

原型鏈

在前面我們已經學會如何自己撰寫一個建構函式,並且透過該建構函式生成實例。目前我們的Dog建構函式,上一層是連結到Object

Object.getPrototypeOf(Dog.prototype)===Object.prototype // true
//or
Dog.prototype.__proto__===Object.prototype // true

如果我們希望在Dog.prototypeObject.prototpe中間額外再插入一層Animal.prototype,並且在Animal.prototype新增一個方法該怎麼做?

首先先來了解Object.create可以做到什麼事

Object.create

Object.create可以直接指定一個物件作為原型:

const Foo={
myName:'Foo的myName',
fn(){
console.log(this.myName)
}
}

const bar = Object.create(Foo);
console.log(bar) //Object { }
bar.fn(); // Foo的myName
bar.myName='bar的myName';
console.log(bar) // { myName: "bar的myName" }
bar.fn(); // bar的myName

因為我們透過Object.create創建出bar,所以bar雖然是空物件,但是bar的原型會指向Foo物件。也因為如此,所以我們調用bar.fn()時,執行的console.log(this.myName)當中的myName其實是bar.__proto__.myName

當我們手動在bar直接新增myName屬性後,在調用bar.fn()時,因為bar本身就有myName屬性,所以不需向上往原型鏈查找,最後會印出'bar的myName'

note

需要注意的是,這邊的bar原型內不會存在constructor,因為是直接指定物件當作原型。

實作原型鏈

我們知道Object.create的用法之後,接著我們要來建立自己的原型鍊了。做法是透過Object.create,來將建構函式的prototype給串聯起來。我們先建立上層原型:

function Animal(family='人科'){
this.kindom='動物界';
this.family=family
}

Animal.prototype.move=function(){
console.log(`${this.name} 移動`)
}

先建立好希望當作上層原型的動物界原型。,並且在該原型新增一個走動(move)的方法。

接著透過Object.create串連:

function Dog(name,size,color){
this.name=name
this.size=size
this.color=color
}

Dog.prototype=Object.create(Animal.prototype);

最後,我們要把Dog原型可以使用的方法給加回來:

Dog.prototype.bark=function(){
console.log(`${this.name} 汪汪叫`);
}

現在我們如果使用new運算子新增一隻小狗:

const bibi=new Dog('bibi','小型犬','灰色');

最後,我們來測試bibi這個由狗建構函式建立的實例,分別能不能正確使用到屬於Dog方法的bark跟屬於Animal方法的move

bibi.bark(); // bibi 汪汪叫  
bibi.move(); // bibi 移動

雖然目前可以讓實例bibi正確使用DogAnimal的方法,但是我們有在Animal建構函式內定義的kindom屬性並沒有繼承到bibi身上:

console.log(bibi.kindom); // undefined

問題出在於,我們有讓Dog的原型繼承在Animal原型下,但是並沒有讓Dog建構函式Animal建構函式產生關聯,要修正這個問題我們需要調整Dog的建構函式如下:

function Dog(name,size,color){
Animal.call(this,'犬科');
this.name=name
this.size=size
this.color=color
}

調整完Dog建構函式之後,重新建立一個新的實例:

const yellow=new Dog('小黃','中型犬','黃色');

console.log(yellow) // Dog {kindom: "動物界", family: "犬科", name: "小黃", size: "中型犬", color: "黃色"}

因為我們修正了Dog建構函示,在使用new運算子創建一個新的空物件並動態將this指向這個空物件,我們接著使用Animal.call並且將指向剛剛創建的空物件的this當成thisArg傳入,接著將'犬科'當作參數也傳入並執行Animal建構函式。

note

這邊需要先了解Function.prototype.call(MDN)

目前程式碼都可以正確運行了,但是因為我們在建立原型鏈的時候,手動賦予Dog.prototype。因為是手動賦予的關係,目前Dog.prototype並不具有constructor

所以我們要手動補上這個constructor

Dog.prototype.constructor=Dog;

實作完整程式碼

上方為了示範過程,中間有許多回頭修正的地方,所以以下附上一段完整的程式碼,實作出white(instance)->Dog->Animal的結構:

// 定義Animal建構函式
function Animal(family='人科'){
this.kindom='動物界';
this.family=family
}

// 新增Animal原型方法
Animal.prototype.move=function(){
console.log(`${this.name} 移動`)
}

// 定義Dog建構函式(需callAnimal建構函式)
function Dog(name,size,color){
Animal.call(this,'犬科');
this.name=name
this.size=size
this.color=color
}

// 建立Dog.prototype->Animal.prototype的原型鏈結構
Dog.prototype=Object.create(Animal.prototype);

// 將constructor綁回Dog建構函式
Dog.prototype.constructor=Dog;

// 新增Dog原型方法
Dog.prototype.bark=function(){
console.log(`${this.name} 汪汪叫`);
}
// 需注意Dog原型的方法一定要在透過Object.create綁定過後才可以新增,否則會被覆蓋掉

// 建立實例(instance)
const white=new Dog('小白','小型犬','白色');

white.bark(); // 小白 汪汪叫
white.move(); // 小白 移動


// 如果後方想繼續新增原型方法,不管是Animal的原型方法或Dog的原型方法皆可,因為原型鏈已經建立好了。
// 提供程式碼測試在原型建立過後,新增其他方法的測試

// Animal.prototype.foo=function(){
// console.log(`${this.name} ~ foo`)
// }

// Dog.prototype.bar=function(){
// console.log(`${this.kindom}~ bar`)
// }

// white.foo();
// white.bar();
caution

必須要先透過Object.create建立原型鏈後,才可以調整下層的原型方法(在範例中即為Dog.prototype)。

測試:實例(white)產生後才調整Animal原型方法,已產生實例是否可以使用

結論:可以使用。

function Animal(family='人科'){
this.kindom='動物界';
this.family=family
}

function Dog(name,size,color){
Animal.call(this,'犬科');
this.name=name
this.size=size
this.color=color
}

Dog.prototype=Object.create(Animal.prototype);

Dog.prototype.constructor=Dog;

const white=new Dog('小白','小型犬','白色');

Animal.prototype.move=function(){
console.log(`${this.name} 移動`)
}

white.move() // 小白 移動

測試:不修正constructor是否會影響instanceof的運作?

結論:不影響。

function Animal(family='人科'){
this.kindom='動物界';
this.family=family
}

function Dog(name,size,color){
Animal.call(this,'犬科');
this.name=name
this.size=size
this.color=color
}

Dog.prototype=Object.create(Animal.prototype);

const white=new Dog('小白','小型犬','白色');

white instanceof Dog // true
white instanceof Animal // true

實作一個動物界原型

目前已經學習到如何透過Object.create製作出一個原型鏈。接著我們希望可以新增一個貓科,同樣隸屬於動物界之下。

關係圖如下:
動物界關係圖,在動物界下有貓科以及犬科

// 定義Animal建構函式
function Animal(family='人科'){
this.kindom='動物界';
this.family=family
}

// 新增Animal原型方法
Animal.prototype.move=function(){
console.log(`${this.name} 移動`)
}

// 定義Dog建構函式
function Dog(name,size,color){
Animal.call(this,'犬科');
this.name=name
this.size=size
this.color=color
}

// 建立Dog.prototype->Animal.prototype的原型鏈結構
Dog.prototype=Object.create(Animal.prototype);

// 將constructor綁回Dog建構函式
Dog.prototype.constructor=Dog;

// 新增Dog原型方法
Dog.prototype.bark=function(){
console.log(`${this.name} 汪汪叫`);
}
// 需注意Dog原型的方法一定要在透過Object.create綁定過後才可以新增,否則會被覆蓋掉

// 定義Cat建構函式
function Cat(name,size,color){
Animal.call(this,'貓科');
this.name=name
this.size=size
this.color=color
}

// 建立Cat.prototype->Animal.prototype的原型鏈結構
Cat.prototype=Object.create(Animal.prototype);

// 將constructor綁回Cat建構函式
Cat.prototype.constructor=Cat;

// 新增Cat原型方法
Cat.prototype.meow=function(){
console.log(`${this.name} 喵喵叫`);
}

接著我們創建實例(instance)並且調用方法:

const bibi=new Dog('小白','小型犬','白色');
const mi=new Cat('小咪','小貓','三花');

bibi.bark(); // 小白 汪汪叫
bibi.move(); // 小白 移動
mi.meow(); // 小咪 喵喵叫
mi.move(); // 小咪 移動

// 無法調用不是屬於自己原型的方法
bibi.meow(); // Uncaught TypeError: bibi.meow is not a function
mi.bark(); // Uncaught TypeError: mi.bark is not a function

小結

基本原型鏈觀念驗證

我們透過上方的動物界原型可以得到以下的原型鏈關係圖:

原型鏈關係圖

在左上方中,我們建立了一個狗的實例,也就是Bibi。

Bibi本身具有從動物界建構函示繼承的family屬性,以及狗建構函式的顏色等屬性(圖片中因空間關係沒有完整呈現)。從__proto__中可以查找到狗建構函式原型(i.e:Dog.prototype)所具有的方法。所以Bibi也可以透過bibi.bark()來吼叫。最後,Dog.prototype之下會有一個constructor屬性,指向該建構函式本身(i.e : function Dog)。

接著,因為Dog.prototype是透過Object.create繼承自Animal.prototype,所以我們也可以再次透過Dog.prototype.__proto__找到Animal.prototype。因此,Bibi也具有移動的能力(可以調用bibi.move())。同樣的,Animal.prototype內也會有一個constructor屬性,指向建構函式自身(i.e : function Animal)

如果Animal.prototype再透過__proto__向上查找,則會找到物件建構函式的原型Object.prototype,同樣的,Object.prototype.constructor也會指向Object建構函式。

最後,物件原型因為已經是原型鏈的頂層了,所以再向上查找(Object.prototype.__proto__)只會找到null

可以透過以下程式碼驗證:

實作原型鏈:

function Animal(family='人科'){
this.kindom='動物界';
this.family=family
}
Animal.prototype.move=function(){
console.log(`${this.name} 移動`)
}

function Dog(name,size,color){
Animal.call(this,'犬科');
this.name=name
this.size=size
this.color=color
}
Dog.prototype=Object.create(Animal.prototype);
Dog.prototype.constructor=Dog;
Dog.prototype.bark=function(){
console.log(`${this.name} 汪汪叫`);
}

const bibi=new Dog('小白','小型犬','白色');

驗證:

console.log(bibi.__proto__===Dog.prototype) // ture
console.log(bibi.__proto__.constructor===Dog) // true

console.log(bibi.__proto__.__proto__===Animal.prototype) //ture
console.log(Dog.prototype.__proto__===Animal.prototype) // true
console.log(Animal.prototype.constructor===Animal) //true

console.log(bibi.__proto__.__proto__.__proto__===Object.prototype) //true
console.log(Animal.prototype.__proto__===Object.prototype) // true
console.log(Object.prototype.constructor===Object) // true

console.log(bibi.__proto__.__proto__.__proto__.__proto__===null) // true

建構函式的原型練

建構函式本身也有屬於他的原型鏈

建構函式的原型練結圖示

Dog,Animal,Object等等的建構函式,也可以透過__proto__去向上查找到 Function.prototype

同樣的,我們可以在Function.prototype內透過constructor找到Function建構函式。

最後,Function.prototype向上查找會找到物件的原型(Object.protoype)。

info

Function很特殊,他自身也是屬於建構函式,所以也可以透過Function.__proto__查找到Function.prototype`。形成一個circle。

可以透過以下方式驗證:

console.log(Function.__proto__===Function.prototype); // true

可以透過以下程式碼驗證:

實作原型鏈:

// 這邊只是要驗證建構函式自身原型鏈,所以不需要方法及屬性,也不需要讓Dog繼承Animal建構函式所有屬性
function Animal(){}
function Dog(){}
Dog.prototype=Object.create(Animal.prototype);
Dog.prototype.constructor=Dog;
const bibi=new Dog();

驗證:

console.log(Dog.__proto__===Function.prototype); // true
console.log(Animal.__proto__===Function.prototype); // true
console.log(Object.__proto__===Function.prototype); // true

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