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

更新日期: 2024 年 4 月 8 日

小弟在學習 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.namethis 位置,因此會顯示泡芙老師」此文字。

其他的同學名稱,也會被捆成 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 會繼承它上一層的父階原型 ,同時該父階原型本身又是物件,因此它會在一次往上繼承,不斷循環。

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

在這個鍊狀結構中,最尾端的值就是 nullnull 往下就是 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

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

Similar Posts