JavaScript 物件導向:建構函式、prototype 與繼承三步驟
整理 ES5 時代用建構函式加 prototype 手刻物件導向的完整流程——封裝屬性與方法、共用函式為什麼要掛在 prototype 上、以及繼承的三個步驟(call 借用母類別建構子、Object.create 接上原型鏈、修正 constructor 指向),並附上物件縮寫與計算屬性這兩個順便學到的 ES6 寫法。
W
物件導向的核心想法很單純:把情境裡的東西包裝成物件。步驟大致是:把情境描述成物件、定義這些物件的屬性跟行為、讓物件之間互動。
用「人」跟「香蕉」這兩個物件當例子:
- 人:屬性是名字、年齡、有的物品;行為是打招呼、走路、接收物品
- 香蕉:屬性是名字、價格、味道;行為是剝皮、攻擊別人(比喻用,實際不會真的攻擊)
這篇整理 ES5 時代用建構函式手刻這套邏輯的完整流程。
封裝:把屬性跟方法包進一個建構函式
var Ticket = function (初始值) {
this.屬性 = 初始值;
this.方法 = function () {
// 這裡面可以用 this.屬性 跟 this.方法
};
};
用一個實際例子——一張票券,有價格、折扣、固定手續費,還有算總價跟印出描述的方法:
var Ticket = function (init_price, init_discount) {
// 初始化
this.price = init_price;
this.type = 'student';
this.discount = init_discount;
this.fee = 30;
this.get_price = function () {
// this 參照物件本體的資訊
return this.price * this.discount + this.fee;
};
this.get_description = function () {
console.log('這張票是' + this.type + '票,有' + this.discount * 10 + '折扣,共' + this.get_price() + '元');
};
};
建立實體物件時用 new,傳進去的參數對應建構函式裡的初始值:
var myticket = new Ticket(500, 0.5); // 產生時,傳進物件價格跟折扣
這樣寫的問題:每個實體都各自複製一份方法
上面的寫法裡,get_price、get_description 這兩個方法是寫在建構函式內部的,代表每次 new Ticket(...),這兩個方法都會被重新建立一次、各自佔一份記憶體——就算兩張票券的邏輯完全一樣,也是兩份獨立的函式物件。
共用函式:掛在 prototype 上
var Ticket = function (初始值) {
this.屬性 = 初始值;
};
// 共用函數
Ticket.prototype.方法 = function () {
// 其中可使用 this.屬性 跟 this.方法
};
放在 prototype 裡的東西不會被複製進每一個物件,物件本身只會有一個參考連到 prototype;存取屬性或方法時,先在物件自己身上找,找不到才會往上到 prototype 找。這代表不管 new 出幾個實體,prototype 上的方法永遠只有一份,多個實體共用同一份參考。
繼承:類別之間的延續
先定義一個比較上層、比較抽象的類別「生物」,再讓「狗」繼承它:
生物 (Creature):名字、生命長度、移動、吃東西
狗 (Dog):品種、喜歡吃的東西、吠叫、攻擊
「狗」除了自己的專屬屬性(品種、吠叫),也應該擁有「生物」共通的那些(名字、吃東西)——這就是繼承要解決的問題:避免每個子類別都要重新定義一次共通的部分。
繼承的完整步驟分三步:
步驟一:建立時呼叫母類別的建構子
var Creature = function (name, lifespan) {
this.name = name;
this.lifespan = lifespan;
};
var Dog = function (name, lifespan, breed) {
Creature.call(this, name, lifespan); // 呼叫母類別,針對母類別進行初始化
this.breed = breed;
};
Creature.call(this, name, lifespan) 的意思是「借用 Creature 這個建構函式,但讓它的 this 指向現在正在建立的 Dog 實體」——這一步只處理屬性的繼承,Creature 建構子裡對 this 做的初始化,會套用在正在建立的這個 Dog 物件上。
步驟二:讓子類別的 prototype 接上母類別的 prototype
Dog.prototype = Object.create(Creature.prototype);
Dog.prototype.constructor = Dog;
Object.create(Creature.prototype) 建立一個空的中繼物件,這個物件的原型鏈往上連到 Creature.prototype,再把它指派給 Dog.prototype。這一步做完之後,Dog 的實體如果在自己身上跟 Dog.prototype 都找不到某個方法,會再往上一層去 Creature.prototype 找。
只做步驟一不夠:步驟一只繼承了「屬性」(建構子裡 this.xxx = xxx 那些),母類別掛在 prototype 上的共用方法沒有被繼承到,因為步驟一的 call() 只是借用建構函式執行了一次,不會動到原型鏈的連結,這正是需要步驟二的原因。
步驟三:把建構函式指回自己
Dog.prototype.constructor = Dog;
步驟二用 Object.create() 整個換掉了 Dog.prototype,這個新物件的 constructor 屬性預設會指向 Creature(因為它是從 Creature.prototype 複製結構過來的),不修正的話,someDogInstance.constructor 會誤指到 Creature,這一步負責把它導正回 Dog。
完整範例:人的繼承
// 母類別
var Person = function (name, age) {
this.name = name;
this.age = age;
};
Person.prototype.sayHello = function () {
console.log('hello i am ' + this.name);
};
// 子類別:Worker 繼承 Person
var Worker = function (name, age, job) {
Person.call(this, name, age); // 步驟一:呼叫母類別初始化
this.job = job; // 設定自己的額外屬性
};
Worker.prototype = Object.create(Person.prototype); // 步驟二:接上原型鏈
Worker.prototype.constructor = Worker; // 步驟三:建構函式指回自己
Worker.prototype.tellJob = function () {
// Worker 自己的共用方法
console.log('I am a ' + this.job);
};
var henry = new Worker('Henry', 30, 'Teacher');
henry.sayHello(); // hello i am Henry ← 繼承自 Person.prototype
henry.tellJob(); // I am a Teacher ← Worker 自己的方法
henry.sayHello() 這個方法沒有寫在 Worker.prototype 上,henry 自己身上也沒有,會一路往上找到 Person.prototype 才找到——這就是原型鏈繼承實際運作的樣子。
順便記的 ES6 物件寫法
練習物件導向的過程中,也整理了幾個 ES6 讓物件字面量寫起來更精簡的語法,跟上面的繼承主題不算同一件事,但當時是一起記的。
物件縮寫(Object Shorthand)
屬性值剛好跟一個同名變數對應時,可以省略冒號跟重複的名稱:
// 完整寫法
function makePoint(x, y) {
return {
x: x,
y: y,
};
}
// 縮寫
function makePoint(x, y) {
return { x, y };
}
計算屬性(Computed Property Names)
屬性名稱需要動態決定時,用中括號包住運算式:
function createObject(key, value) {
const obj = {
[key + '1']: value, // 動態決定屬性名稱
};
return obj;
}
createObject('name', 'John'); // { name1: 'John' }
createObject('legs', 4); // { legs1: 4 }
函式縮寫(Method Shorthand)
物件裡的方法不用再寫 key: function () {}:
// 完整寫法
const options = {
name: 'Options',
created: function () {},
};
// ES6 縮寫
const options = {
name: 'Options',
created() {},
};
現在還適用嗎
原型鏈這套底層機制到現在完全沒變,class 語法(ES6 就有)背後運作的原理就是這篇整理的 prototype 鏈接,不是另一套全新的東西。物件縮寫、計算屬性、函式縮寫這幾個 ES6 寫法現在也還是日常寫法,沒有過時。
真正變的是手刻繼承這件事的人——現在幾乎沒有人會再手寫 Object.create() 加修正 constructor 這三步驟,class extends 把這一整套樣板程式碼包裝起來,寫法更接近其他物件導向語言、也更不容易漏掉某一步。整理在《ES6 Class:prototype 繼承的語法糖》。