跳到主要內容
前端開發

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_priceget_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 繼承的語法糖》。

參考資料

分享這篇