什麼是原型(Prototype)?|JavaScript 初學者筆記(3)
更新日期: 2024 年 4 月 8 日
小弟在學習 JavaScript 的「物件導向」的相關概念時,「原型」(Prototype)這個概念對我來說,一直是如同迷一般難以理解。
直到最近重新學習物件的概念時,才對「原型」有了比較清晰的認識。
本文,就是要針對「原型」此概念,向如同自己一樣的程式新手,盡可能白話地解釋相關意涵。
建議閱讀本文前,務必先閱讀以下 2 篇「物件」的基本概念,才會更清楚理解內文。
什麼是原型(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)中。
換句話說,當我們將固定不變的方法與屬性,放到物件原型的成員之後。
以此物件原型建立的實體,就可以從該原型「借用」已經建立好的方法與屬性,而不是在該實體重新創立一模一樣的內容。
此外,以物件原型建立方法與屬性,也能夠幫助我們達成下一段會說到的「原型繼承」功能。
看完以上關於原型的介紹,接著我們繼續介紹「如何使用原型?」
如何新增原型成員?
想要新增物件的原型,首先需要針對我們的建構子函式,新增以下語法:
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 方法移動至原型中,原先用 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
};
實際運作如下圖所示:
藉由以上方式,我們就可以順利「繼承」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 這兩個實體的位置,可以發現有些微差異。
這種現象的產生成因,在於 JS 物件導向的原型鍊特性(Prototype Chains)。
前面我們說過,只要是一個「物件」就一定會有「原型」,而 prototype 本身也是一個物件。
因為 prototype 會繼承它上一層的父階原型 ,同時該父階原型本身又是物件,因此它會在一次往上繼承,不斷循環。
上述這個過程,就稱為原型鍊。
在這個鍊狀結構中,最尾端的值就是 null,null 往下就是 Object.prototype,通常我們都會以 Object.prototype 作為原型鍊的初始值。
例如我們創立一個 x 變數,並設定它的原型:
let x = Object.creat(null)
接著幫 x 創立一個物件實體。
let x = {
id:9527,
name:'唐伯虎'
}
或者,我們也可以創立一個 y 陣列。
let y = [1,2,3,4]
當然,由於函式(function )本身也是一個物件,當我們與 new 運算子一起合併使用,也可以創立物件實體。
function Parent(id,name){
this.id = id;
this.name = name;
}
我們再試試創立一個 Child 函式,並繼承 Parent 的原型,看看它的原型鍊情況。
function Child (...args) {
Parent.apply(this,args);
}
Child.prototype = Object.create(Parent.prototype);
以上就是原型鍊的基本運作機制與概念。
套用回本文範例,由於 CreatCaptain 是繼承 CreatBleachCharacter ,因此它的實體原型鍊才會出現兩層 prototype。
以上就是本次針對原型的介紹,謝謝大家收看~
系列文章統整
- 物件導向是什麼?|JavaScript 四大物件特性簡介|初學者筆記(1)
- 如何建立物件?|JavaScript 初學者筆記(2)
- 什麼是原型(Prototype)?|JavaScript 初學者筆記(3)
- 類別(class)是什麼?|JavaScript 初學者筆記(4)