原型鏈的概念較為複雜,我們可以從認識如何以「建構子函式(constructor function)」搭配「new」關鍵字建立實例(instance)開始,逐步了解如何以「物件原型(prototype)」、「原型繼承(prototype inheritance)」概念,提升記憶體使用效能,最後再看語法糖「class」如何在保留相同功能的同時,增進相關程式碼的可閱讀性。
當我們要製作許多相似物件時,可以像下方程式碼這樣土法煉鋼,把物件一個一個做出來:
let Japan = { //建立「Japan」實例
name: "Japan",
capital: "Tokyo",
population: 378000,
inAsia: true,
welcome(){
console.log("Welcome to Japan!");
}
}
let Finland = { //建立「Finland」實例
name: "Finland",
capital: "Helsinki",
population: 338000,
inAsia: false,
welcome(){
console.log("Welcome to Finland!");
}
}
console.log(Japan); //顯示「Japan」實例屬性
console.log(Finland); //顯示「Finland」實例屬性
Japan.welcome(); //顯示「Japan」實例方法
Finland.welcome(); //顯示「Finland」實例方法
顯示結果如下:
但如果要製作非常大量相似的物件,這樣一個一個把物件打出來實在太慢,有沒有更有效率的什麼方式呢?
建構子(constructor)
「建構子(constructor)」函式可讓我們避免大量打重複的程式碼,只要打好建構子函式一次,後面要製作相似物件時,都可以直接套用函式內容。
使用建構子函式時,有下列兩點要特別留意:
- 在為函式命名時,習慣上會讓第一個字母為大寫。
- 以「函式表達式」建立新物件時,使用「new」這個關鍵字,新建立的物件稱為「實例(instance)」。
舉例來說,如果要如上述程式碼製作多個不同國家的物件,就可以先做一個名為「Country」的建構子函式,例如:
function Country(name, capital, area, inAsia){
this.name = name,
this.capital = capital,
this.area = area,
this.inAsia = inAsia,
this.welcome = function(){
console.log("Welcome to " + this.name + "!");
}
}
要製作新的實例時,就先為新實例賦予一個變數名稱、完成函式表達式,並在「Country」前加上「new」這個關鍵字,後面再依序輸入該實例的參數如下:
let Japan = new Country(“Japan”, “Tokyo”, 378000, true);
這裡輸入的「"Japan”」就是建構子函式的「name」這個參數,於函式內賦值於「this.name」,這樣「Japan」這個實例的「name」屬性就會是「"Japan”」,其他三個參數亦同。
"Japan"就是以Country建構子函式建立Japan實例的name屬性
至於若要顯示這個實例當中的方法,則一樣使用點記法(dot notation)如下:
Japan.welcome();
上述概念的完整程式碼如下,這裡再多建立一個名為「Finland」的實例:
function Country(name, capital, area, inAsia){ //建立名為「Country」的建構子函式
this.name = name;
this.capital = capital,
this.area = area,
this.inAsia = inAsia,
this.welcome = function(){
console.log("Welcome to " + this.name);
}
}
let Japan = new Country("Japan", "Tokyo", 378000, true); //套用建構子函式,製作名為「Japan」的實例
let Finland = new Country("Finland", "Helsinki", 338000, false); //套用建構子函式,製作名為「Finland」的實例
console.log(Japan); //顯示「Japan」實例屬性
console.log(Finland); //顯示「Finland」實例屬性
Japan.welcome(); //顯示「Japan」實例方法
Finland.welcome(); //顯示「Finland」實例屬性
有了「Country」這個建構子函式,只要建立函式表達式、並輸入對應的參數,就可以做出大量實例,不必重複打出物件的完整程式碼。上述程式碼顯示結果如下:
我們可以再分析顯示結果是如何產生的:
物件原型(prototype)
雖然可以透過建構子函式快速建立多個實例,但這些實例全部都是分開存在記憶體中,使記憶體多個位置都儲存相同資料,十分浪費儲存空間。
以上述的例子來說,兩個實例都有同樣的「welcome()」方法,卻是儲存在記憶體中不同的地方:
既然兩者是一樣的東西,我們希望可以把這個方法放在記憶體的其中一個地方就好,不同實例要使用時,都可以去同一個位置讀取並執行該方法,避免浪費記憶體空間。
物件原型(prototype)
在 JavaScript 中,每個物件都有一個稱為「物件原型(prototype)」的屬性,代表該物件適用於所有情況的屬性與方法。
舉例來說,當我們以「new」搭配「Country()」建立「 Japan」實例時,除了有「Japan」本身的「area: 378000」、「capital: “Tokyo”」、「inAsia: true」、「name: “Japan”」四種屬性之外,也會有「Country.prototype」的所有屬性與方法;而由於「Country.prototype」是一種物件,因此也適用「Object.prototype」的所有屬性與方法,三者間有繼承(inherit)關係。
仔細觀察程式碼執行結果,也能看到這樣的繼承關係:
透過綁定建構子與「.prototype」,並套上新的方法,就能讓該方法適用於所有建立的實例上。以本文的範例而言,在「Country.prototype」中新增的方法即可套用於所有以「new」搭配「Country()」建立的實例。
舉例來說,要讓「new Japan」與「new Finland」都共享新增的「welcome()」方法,可以先在「Country.prototype」加上「welcome」如下:
Country.prototype.welcome = function{
console.log("Welcome to " + this.name + "!");
}
當「welcome()」方法被加進了「Country.prototype」中,不管是「new Japan」、「new Finland」或任何其他以「new」搭配「Country()」建立的實例,都可以直接取用同一個「welcome()」方法。
若檢視「new Japan」與「new Finland」兩實例的屬性與方法內容,可見「welcome()」方法已經一同被放進「Country.prototype」中。
執行後的顯示結果依然相同:
原型繼承(prototype inheritance)
如果該物件沒有某屬性或方法,可以往上一層的「prototype」找,最高找到「Object.prototype」,再上去就是 null;但上層的物件無法繼承下層物件的屬性或方法。
舉例來說,現在有個建構子函式「Person」,參數包含「name」與「gender」,用其建立的實例另設定「greeting()」方法如下:
function Person(name, gender) {
this.name = name,
this.gender = gender
}
Person.prototype.greeting = function(){
console.log(this.name + " says Hi!");
}
let Andy = new Person("Andy", "male");
console.log(Andy);
Andy.greeting();
顯示結果如下:
現在我們希望以「Person」為母集合,另建立「Staff」這個子集合,參數包含「ID」與「department」如下:
function Person(name, gender) {
this.name = name,
this.gender = gender
}
Person.prototype.greeting = function(){
console.log(this.name + “ says Hi!”);
}
//建立Staff建構子函式
function Staff(name, gender, ID, department){
Person.call(this, name, gender);
this.ID = ID,
this.department = department
}
let Andy = new Person(“Andy”, “male”);
//以Staff建構子函式建立新實例Emily
let Emily = new Staff(“Emily”, “female”, 13, “R&D”);
console.log(Emily);
Emily.greeting();
顯示結果如下,可發現「Emily」這個實例無法讀取位在「Person.prototype」中的方法:
Object.create()-讓實例方法也能被繼承
要讓「Staff.prototype」繼承原屬「Person.prototype」的方法,需用到「Object.create()」,程式碼如下:
function Person(name, gender) {
this.name = name,
this.gender = gender
}
Person.prototype.greeting = function(){
console.log(this.name + " says Hi!");
}
function Staff(name, gender, ID, department){
Person.call(this, name, gender);
this.ID = ID,
this.department = department
}
//以Person.prototype為原型,建立Staff.prototype
Staff.prototype = Object.create(Person.prototype);
let Emily = new Staff("Emily", "female", 13, "R&D");
console.log(Emily);
Emily.greeting();
加上這一行後,「Emily」實例就可以一併使用「Person.prototype」的方法,顯示結果如下:
「Staff.prototype」也可以製作屬於自己專屬的方法,但該方法無法套用於以「Person」建構子函式建立的實例:
function Person(name, gender) {
this.name = name,
this.gender = gender
}
Person.prototype.greeting = function(){
console.log(this.name + “ says Hi!”);
}
function Staff(name, gender, ID, department){
Person.call(this, name, gender);
this.ID = ID,
this.department = department
}
Staff.prototype = Object.create(Person.prototype);
//建立專屬於Staff的方法
Staff.prototype.resign = function(){
console.log(this.name + “ resigns.”);
}
let Andy = new Person(“Andy”, “male”);
let Emily = new Staff(“Emily”, “female”, 13, “R&D”);
//Emily實例適用greeting()與resign()兩方法
Emily.greeting();
Emily.resign();
//Andy實例僅適用greeting()方法
Andy.greeting();
Andy.resign();
上述程式碼顯示結果如下:
類別(class)
跟物件導向程式語言不同的是,JavaScript 的「class」只是語法糖,用來簡化「物件原型繼承」的程式碼,並不會形成「物件導向繼承模型(object-oriented inheritance model)」。
以上個段落「Person」建構子函式建立的實例而言,可以連同「Person.prototype」一起寫進「class」如下:
class Person{
constructor(name, gender){
this.name = name,
this.gender = gender
}
greeting() {
console.log(this.name + “ says Hi!”);
}
}
let Andy = new Person(“Andy”, “Male”);
console.log(Andy);
Andy.greeting();
顯示結果與未使用「class」時相同:
extends & super
如果要用「class」加上「Staff」這個子集合,則用「extends」讓以「Staff」建立的實例繼承以「Person」建立的實例之屬性與方法,建構子函式內部用「super」繼承原「Person」屬性,表示「Person」是「Staff」的母集:
class Person{
constructor(name, gender){
this.name = name,
this.gender = gender
}
greeting() {
console.log(this.name + “ says Hi!”);
}
}
//用extends使以Staff建立的實例繼承以Person建立的實例之屬性與方法
class Staff extends Person{
constructor(name, gender, ID, department){
//super指Person是Staff的母集(superset)
super(name, gender);
this.ID = ID,
this.department = department
}
resign(){
console.log(this.name + “ resigns”);
}
}
let Emily = new Staff(“Emily”, “female”, 13, “R&D”);
Emily.greeting();
Emily.resign();
顯示結果與未使用「class」時相同:
相較於原本的寫法,使用「class」語法糖寫的程式碼比較直觀、也相對好懂許多。
static
在「class」語法糖中,可以用關鍵字「static」賦予專屬於該「class」內的屬性與方法,而非屬於新做出來的實例,例如在「Circle」這個類別中建立「Circle.Pi」屬性與「getAreaFormula()」,可以這樣寫:
class Circle{
//static屬性:Pi
static Pi = 3.14;
constructor(radius){
this.radius = radius
}
getArea(){
console.log(“The area is “+ this.radius**2*Circle.Pi);
}
//static方法:公式
static areaFormula(){
console.log(“Static formula is r * r * Pi”);
}
}
let C1 = new Circle(5);
C1.getArea();
Circle.areaFormula();
顯示結果如下: