Logo

新人日誌

首頁關於我部落格

新人日誌

Logo

網站會不定期發佈技術筆記、職場心得相關的內容,歡迎關注本站!

網站
首頁關於我部落格
部落格
分類系列文

© 新人日誌. All rights reserved. 2020-present.

什麼是原型(Prototype)?|JavaScript 初學者筆記(3)

最後更新:2024年4月8日物件導向
什麼是原型(Prototype)?|JavaScript 初學者筆記(3)

小弟在學習 JavaScript 的「物件導向」的相關概念時,「原型」(Prototype)這個概念對我來說,一直是如同迷一般難以理解。

直到最近重新學習物件的概念時,才對「原型」有了比較清晰的認識。

本文,就是要針對「原型」此概念,向如同自己一樣的程式新手,盡可能白話地解釋相關意涵。

建議閱讀本文前,務必先閱讀以下 2 篇「物件」的基本概念,才會更清楚理解內文。

  1. 物件導向是什麼?|JavaScript 四大物件特性簡介
  2. 如何建立物件?|JavaScript 初學者筆記

什麼是原型(Prototype)?

在學習「類別」(Class)之前,我們需要先理解什麼是「物件原型」(Prototype)。

因為「類別」語法的運作機制,就是以物件「原型」作為基底,並將相關代碼簡化生成的「語法糖」而已。

補充:「語法糖」是指將原有的程式碼,以更簡潔、流暢的方法撰寫出來,且不影響原始功能,讓使用者寫起來有點甜甜的感覺!?

我們在上一篇文章說過,可以使用建構子函數(Constructor function)創立物件的「模板」。

若我們想要創立動畫「死神」的角色物件,可以先建立一個函數。

function CreatBleachCharacter(){
};

使用 this 關鍵字,再設定此死神物件的屬性。

function CreatBleachCharacter(fullname,age,Zanpakutou){
    this.fullname: fullname; // 角色名稱
    this.age: age; // 角色年齡
    this.Zanpakutou: Zanpakutou; // 斬破刀的名稱
};

除了設定屬性,也可以設定物件的方法,例如讓角色能說一句話。

function CreatBleachCharacter(fullname,age,Zanpakutou){
    this.fullname = fullname;
    this.age = age;
    this.Zanpakutou = Zanpakutou; 
    this.say = function(){
        console.log(`斬破刀名稱為:${this.Zanpakutou}`);
    } // 角色的台詞
};

最後搭配 new 關鍵字,並輸入對應的角色設定,即可順利創立死神角色的物件。

let Ichigo = new CreatBleachCharacter('黑崎一護','15','斬月');

(由於黑崎一護的英文名稱為「Kurosaki Ichigo」,因此變數名稱取為「Ichigo」)

(以下為目前進度的代碼全貌:)

function CreatBleachCharacter(fullname,age,Zanpakutou){
    this.fullname = fullname;
    this.age = age;
    this.Zanpakutou = Zanpakutou; 
    this.say = function(){
        console.log(`斬破刀名稱為:${this.Zanpakutou}`);
    } 
};

let Ichigo = new CreatBleachCharacter('黑崎一護','15','斬月');

創立好物件後,我們可以在瀏覽器中查看一下物件內容,確認是否成功。

物件在瀏覽器的顯示情況
看起來順利建立成功

但當我們仔細查看時,可以發現除了原先自己創立的物件成員,下方還多了一行文字寫著:「Prototype」。

物件在瀏覽器的顯示情況
發現除了原本建立的物件成員,還多了一些額外的物件成員。

點擊「Prototype」後,又冒出「constructor」與「Prototype」文字,再次點選「Prototype」後,會忽然跑出一堆額外的物件成員。

物件在瀏覽器的顯示情況

看到一整堆的東西,我們需要問一個關鍵問題:「 Prototype 到底是三小!?」。

簡單來說,原型(Prototype)是一種存在於「物件資料型態」的預設值。例如以下資料類型:

  • 陣列
  • 物件
  • 函式

此外,我們自行創建的物件也包含在內,例如創立死神角色的 CreatBleachCharacter 此建構子函式,也會有 Prototype 預設值。

此預設的原型(Prototype)預設值會根據不同的物件類型,對應包含不同的屬性與方法。

例如我們各自創立一個陣列與物件。

let array = [1,2,3,4,5];

let object = {
    name : "法鬥",
    age: 14
};

console.log(array);
console.log(object);

當我們在瀏覽器顯示時,可以發現它們各自的原型(Prototype)成員不太一樣。

請將中間的控制桿左右移動,即可對比兩者間的差異。

如果我們想要使用這些原型成員,可以使用以下兩種方式:

array.__proto__.filter // 最標準寫法
array.filter // 省略寫法

只要我們創立該類型的物件,該物件內含的原型屬性(Prototype Property),就會與原先創立該物件的模板連接,並知道如何使用對應的預設值。

舉例來說,如果我們憑空創造一個新的 JS 資料型態,稱為「忍者」好了。

套用到物件原型的特色,忍者就會有它專屬的原型成員,例如屬性具備查克拉,方法有射飛鏢。

因此,只要我們每創立一個該資料型態的新角色,該新角色的「原型屬性」就會連到「忍者」的原型,並讓該角色直接具備對應的成員。

以原型建立對應物件實體的圖形說明
假如有一個忍者資料型態,該對應的原型屬性則如上圖顯示

上述物件原型的運作機制,可以避免我們不斷將相同的「屬性」與「方法」,重複新增到我們用建構子函式創立的「實體」(Instance)中。

補充:什麼是實體( Instance)?

通常我們用物件「原型」或「建構子函式」創立的物件,會被稱為「實體」(Instance)。

我們可以將建構子想像成模板,「實體」則是該模板創立出來的成品。

以鯛魚燒為例,說明 Constructor 與 Instance 兩者差異

換句話說,當我們將固定不變的方法與屬性,放到物件原型的成員之後。

以此物件原型建立的實體,就可以從該原型「借用」已經建立好的方法與屬性,而不是在該實體重新創立一模一樣的內容。

此外,以物件原型建立方法與屬性,也能夠幫助我們達成下一段會說到的「原型繼承」功能。

看完以上關於原型的介紹,接著我們繼續介紹「如何使用原型?」

如何新增原型成員?

想要新增物件的原型,首先需要針對我們的建構子函式,新增以下語法:

Object.prototype

例如若我們以上述介紹的死神建構子函式,就可以將代碼中「Object」一詞取代為「CreatBleachCharacter」,實際代碼以下:

CreatBleachCharacter.prototype

這裡要注意,只有「建構子函式」才能夠使用此原型屬性(prototype property )。

物件實體擁有的屬性,是有包含底線的原型屬性(__proto__),它會指向該建構子函式,取得原型屬性(prototype property )的數值。

這裡命名的差異需要特別注意。

CreatBleachCharacter.prototype // 此為建構子函式,因此可以使用
Ichigo.prototype // 此為物件實體,因此不可以用

接著,如果我們想要將方法放入原型屬性中,就在 prototype 以點記法新增對應成員。

例如以死神角色的建構子函式為例,若想要將「say」函式放到其中,該代碼則為以下:

CreatBleachCharacter.prototype.say = function (){
    console.log(`斬破刀名稱為:${this.Zanpakutou}`);
}

在此函式中,我們需要使用 this 關鍵字,用來替代「CreatBleachCharacter」此建構子,並將原本位於建構子的 say 方法,移動到原型中(不知道 say 方法是什麼的人,可以往上查看重新理解一下代碼。)。

備註:如果不知道 this 關鍵字所代表的意涵,可以參考這篇上一篇關於建構子函式的相關介紹

當我們將原先位於建構子函式的方法,移動到物件原型後,我們就可以發現兩者在瀏覽器的顯示,出現了些微的差異。

左圖:未使用原型;右圖:使用原型
(注意看,say 方法的位置不一樣)

上圖顯示,當我們將 say 方法移動至原型中,原先用 CreatBleachCharacter 建構子創立的物件實體中,就不會直接具備該函式。

相反的,該實體是藉由 __proto__ 屬性指向原型中的方法,間接借用了 say 方法。

當然,一般我們使用 say 方法時,不會因為是否為原型而有所差異。

(以下為目前進度的完整代碼)

function CreatBleachCharacter(fullname,age,Zanpakutou){
    this.fullname = fullname; 
    this.age = age; 
    this.Zanpakutou = Zanpakutou;
};

CreatBleachCharacter.prototype.say = function (){
    console.log(`斬破刀名稱為:${this.Zanpakutou}`);
}

let Ichigo = new CreatBleachCharacter('黑崎一護','15','斬月');

什麼是原型繼承 (Prototype Inheritance) ?

前面我們建立了死神角色的建構子 CreatBleachCharacter,可以用它建立死神角色的實體。

試想一下,如果我想要新增另一類型的死神角色:「隊長級別死神」。 它具備同樣「姓名」、「年齡」、「斬破刀」以及「說話」等屬性與方法。

除此之外,它還有另外一個特殊的方法稱為:卍解。

由於隊長級別的死神角色,具備特殊的「卍解」方法,我們可以針對此類角色,另外新增一個 CreatCaptain 建構子。

(卍解的英文是:Bankai)

// 方法一:直接複製貼上,並修改另一個建構子函式

function CreatBleachCharacter(fullname,age,Zanpakutou){
    this.fullname = fullname;
    this.age = age;
    this.Zanpakutou = Zanpakutou; 
    this.say = function(){
        console.log(`斬破刀名稱為:${this.Zanpakutou}`);
    } 
};

function CreatCaptain(fullname,age,Zanpakutou){
    this.fullname = fullname;
    this.age = age;
    this.Zanpakutou = Zanpakutou; 
    this.say = function(){
        console.log(`斬破刀名稱為:${this.Zanpakutou}`);
    }
    this.Bankai = function(){
        console.log(`${this.Zanpakutou},卍解!`);
    } // 此為另外新增的方法
};

上述方法雖然可行,但你可以看到代碼被重複貼上使用,顯然不是一個「最佳方法」。

取而代之,我們可以使用物件原型的「繼承」(Inheritance)特性,以更簡潔的方法,達成相同目的。

首先,我們可以建立一個名為 CreatCaptain 的函數。

function CreatCaptain () {
}

接著,如果我們想要建立一個隊長角色「實體」,可以使用 new 關鍵字建立一個新的實體,代碼如下:

(十隊隊長 日番谷冬獅郎 的英文是:Hitsugaya Toushirou)

function CreatCaptain () {
}

let Hitsugaya = new CreatCaptain();

同時,我們也需要輸入該角色對應的數值,包含姓名、年齡、斬破刀名稱

function CreatCaptain () {
}

let Hitsugaya = new CreatCaptain('日番谷冬獅郎','110','冰輪丸')

接著,我們可以使用「其餘參數」(rest parameter),將已經設定好的角色數值,打包成一個「陣列」,並一起丟到要繼承的建構子中。

這種做法的好處,可以讓我們不必一個個將參數各自填入,而是將整套參數綑成一包,一同傳送。

其餘參數的代碼為「…自行命名」,「自行命名」部分可以根據需求自行定義名稱,例如:

  • …arguments
  • …args
  • …jojo

我們以 …args 作為表示其餘參數,撰寫以下代碼:

function CreatCaptain (...args) {
}

let Hitsugaya = new CreatCaptain('日番谷冬獅郎','110','冰輪丸')

如果我們將「其餘參數」顯示在瀏覽器,則會如下圖呈現:

接著,因為我們需要「繼承」已經建立好的建構子函式代碼,需要使用到 apply 方法。

Apply 方法

apply 方法是「函式」(function)此類型資料型態的原型方法。

換句話說,只要你創立一個「函式」,Javascript 就會預設讓你能使用這個 apply 方法,主要功能是回傳函式執行完的結果。

聰明的你可能會想說,我執行使用以「();」方式就行了,為何還需要 apply 方法!?

functionName(); //一般要執行函數
functionName.apply(參數一,參數二); // 使用 apply  方法

原因在於,apply 主要是針對物件的繼承功能使用。

此方法共需要寫入兩個參數:

  • 參數一:填寫要被繼承的物件實體名稱
  • 參數二:填寫要被繼承的參數

舉例來說,如果我們有一個上課點名的函數稱為 takeAttendence。

該函式的所有參數,我們統一都用其餘參數 …arg 表示,並在函式中使用 this 關鍵字。

function takeAttendence (...arg) {
    console.log(this.name,'現在要開始點名!');
    console.log(arg[0]);
    console.log(arg[1]);
    console.log(arg[2]);
}

let teacher = {
    name: '泡芙老師'
}

let students = ['海綿寶寶','派大星','珊迪'];

takeAttendence.apply(teacher,students);

另外,我們再創立另一個 teacher 物件以及 students 陣列。

當我們將 apply 方法套用 takeAttendence 函式後,就會得到以下結果:

從上圖可知,teacher 物件會取代原本 takeAttendence 函式中 this.name 的 this 位置,因此會顯示「泡芙老師」此文字。

其他的同學名稱,也會被捆成 student 一整包陣列丟進 takeAttendence 的 …arg 參數中,並依序顯示出來。

當理解完 apply 方法的使用規則後,我們可以回到原本的代碼中,探討該如何使用。


按照以上模式,CreatCaptain 建構子若要繼承 CreatBleachCharacter 的成員,則可以使用以下代碼。

function CreatCaptain (...args) {
    CreatBleachCharacter.apply(???,args); /// ??? 處要放啥!?
};

這裡有一個問題是,「???」代表參數一的物件實體,但具體來說該物件實體的名稱是什麼呢?

由於 CreatCaptain 屬於建構子函式,我們會藉由 this 關鍵字指向 new 關鍵字創立的實體。

因此該「???」應該要放置 this 一詞才是正解。

function CreatCaptain (...args) {
    CreatBleachCharacter.apply(this,args); /// ??? 處要放 this
};

實際運作如下圖所示:

通常我們會使用 this 關鍵字搭配 new 關鍵字 ,確保 this 指向正確的物件實體。
通常我們會使用 this 關鍵字搭配 new 關鍵字 ,確保 this 指向正確的物件實體。

藉由以上方式,我們就可以順利「繼承」CreatBleachCharacter 的成員,而不是使用複製貼上大法。

當順利繼承 CreatBleachCharacter 的成員後,我們可以再新增 CreatCaptain 特有的屬性。

function CreatCaptain (...args) {
    CreatBleachCharacter.apply(this,args);
    this.role = 'captain'; // 只有此建構子具備的屬性
}

let Hitsugaya = new CreatCaptain('日番谷冬獅郎','110','冰輪丸')

現在我們已經繼承了 CreatBleachCharacter 的建構子成員,但其實還有一些內容沒有順利繼承:就是物件的「原型成員」。

物件繼承的遺漏處:物件原型

為了要順利繼承 CreatBleachCharacter 的原型成員,我們需要使用到「繼承物件原型」的方法:Object.creat 方法

(以下為目前進度的完整代碼:)

function CreatBleachCharacter(fullname,age,Zanpakutou){
    this.fullname = fullname; 
    this.age = age; 
    this.Zanpakutou = Zanpakutou;
};

CreatBleachCharacter.prototype.say = function (){
    console.log(`斬破刀名稱為:${this.Zanpakutou}`);
}

function CreatCaptain (...args) {
    CreatBleachCharacter.apply(this,args); 
    this.role = 'captain';
}

let Ichigo = new CreatBleachCharacter('黑崎一護','15','斬月');
let Hitsugaya = new CreatCaptain('日番谷冬獅郎','110','冰輪丸');

Object.create 方法

在 Javascript 中,如果要創建一個「物件」,除了可以自行創建一個「建構子函式」外,也可以使用它內建的建構子函示:Object

例如當我們使用「物件實體語法」創立一個空的物件,其實就是使用了 Javascript 內建的建構子函式。

let jojo = {}; // 使用物件實體
let jojo = new Object(); //使用預設建構子函式

此 Object 建構子中,有一個專門用來繼承「物件原型」的方法:Object.creat。

我們可以使用 Object.create 繼承其他物件的「原型成員」。例如我們創另一個名為 user 的物件:

const user = {
    name: '預設值',
    age: '預設值',
    say: function(){
        return '你好,' + this.name + '歡迎光臨';
    }
}

接著,我們創另一個變數名為 user01 ,並且將 user 作為參數丟進 Object.create 方法中:

const user = {
    name: '預設值',
    age: '預設值',
    say: function(){
        return '你好,' + this.name + '歡迎光臨';
    }
}
const user01 = Object.create(user);
console.log(user01);

如果我們將 user01 顯示在瀏覽器上,會發現它是一個空物件,且沒有任何物件的成員。

空物件範例

你可能會覺得:「奇怪,我看 user 物件明明有很多成員,怎麼會一個都沒有呢!?」。

別急,我們繼續點擊「prototype」名稱後,就可以發現原來我們將 user 的物件成員,全部轉變成 user01 的原型成員了。

物件成員變原型成員

上述範例,就是 Object.create 的使用方法與原理。

同理,我們套用回原本的案例中,如果我們希望 CreatCaptain 建構子能夠繼承 CreatBleachCharacter 建構子的物件原型,則可撰寫以下代碼:

CreatCaptain.prototype = Object.create(CreatBleachCharacter.prototype);

(大家不要忘記,原型「prototype」本身也是一個物件,因此我們可以將它作為參數丟進 Object.create 方法中。)

如此,我們就能順利繼承 CreatBleachCharacter 的原型成員了。

順利繼承原型成員的示意圖

(以下為死神建構子與隊長建構子的完整代碼)

function CreatBleachCharacter(fullname,age,Zanpakutou){
    this.fullname = fullname; 
    this.age = age; 
    this.Zanpakutou = Zanpakutou;
};

CreatBleachCharacter.prototype.say = function (){
    console.log(`斬破刀名稱為:${this.Zanpakutou}`);
}

function CreatCaptain (...args) {
    CreatBleachCharacter.apply(this,args); 
    this.role = 'captain';
}

CreatCaptain.prototype = Object.create(CreatBleachCharacter.prototype);

let Ichigo = new CreatBleachCharacter('黑崎一護','15','斬月');
let Hitsugaya = new CreatCaptain('日番谷冬獅郎','110','冰輪丸');

什麼是原型鍊(Method Chaining)?

若我們仔細查看 say 方法在 Ichigo 與 Hitsugaya 這兩個實體的位置,可以發現有些微差異。

物件實體的 prototype 層級不同
物件實體的 prototype 層級不同

這種現象的產生成因,在於 JS 物件導向的原型鍊特性(Prototype Chains)。

前面我們說過,只要是一個「物件」就一定會有「原型」,而 prototype 本身也是一個物件。

因為 prototype 會繼承它上一層的父階原型 ,同時該父階原型本身又是物件,因此它會在一次往上繼承,不斷循環。

上述這個過程,就稱為原型鍊。

在這個鍊狀結構中,最尾端的值就是 null,null 往下就是 Object.prototype,通常我們都會以 Object.prototype 作為原型鍊的初始值。

例如我們創立一個 x 變數,並設定它的原型:

let x = Object.creat(null)
x 的原型鍊情況
x 的原型鍊情況

接著幫 x 創立一個物件實體。

let x = {
    id:9527,
    name:'唐伯虎'
}
x 物件的原型鍊情況
x 物件的原型鍊情況

或者,我們也可以創立一個 y 陣列。

let y = [1,2,3,4]
y 陣列的原型鍊情況
y 陣列的原型鍊情況

當然,由於函式(function )本身也是一個物件,當我們與 new 運算子一起合併使用,也可以創立物件實體。

function Parent(id,name){
    this.id = id;
    this.name = name;
}
Parent 函式的原型鍊情況
Parent 函式的原型鍊情況

我們再試試創立一個 Child 函式,並繼承 Parent 的原型,看看它的原型鍊情況。

function Child (...args) {
    Parent.apply(this,args); 
}

Child.prototype = Object.create(Parent.prototype);
Child 函式的原型鍊情況
Child 函式的原型鍊情況

以上就是原型鍊的基本運作機制與概念。

套用回本文範例,由於 CreatCaptain 是繼承 CreatBleachCharacter ,因此它的實體原型鍊才會出現兩層 prototype。

以上就是本次針對原型的介紹,謝謝大家收看~

系列文章統整

  • 物件導向是什麼?|JavaScript 四大物件特性簡介|初學者筆記(1)
  • 如何建立物件?|JavaScript 初學者筆記(2)
  • 什麼是原型(Prototype)?|JavaScript 初學者筆記(3)
  • 類別(class)是什麼?|JavaScript 初學者筆記(4)
目前還沒有留言,成為第一個留言的人吧!

發表留言

留言將在審核後顯示。

物件導向

目錄

  • 什麼是原型(Prototype)?
  • 如何新增原型成員?
  • 什麼是原型繼承 (Prototype Inheritance) ?
  • Apply 方法
  • Object.create 方法
  • 什麼是原型鍊(Method Chaining)?