本章重點#

  • 探索 prototype 的運作原理
  • 使用函式作為建構器 (constructor)
  • 透過 prototype 擴展物件
  • 避免常見陷阱
  • 使用繼承 (inheritance) 建構類別階層

7.1 理解 Prototype#

在 JavaScript 中,物件是具名屬性 (named properties) 與值的集合。物件屬性可以是簡單值、函式或其他物件,而且可以隨時動態新增、修改和刪除。

Prototype 的核心概念:

  • 每個物件都可以擁有一個對另一個物件的參考,稱為它的 prototype
  • 當搜尋某個屬性時,如果物件本身沒有該屬性,就會委派 (delegate) 給它的 prototype
  • Prototype 本身也可以有自己的 prototype,形成 prototype chain(原型鏈)
  • 搜尋會沿著原型鏈一路向上,直到找到屬性或沒有更多 prototype 可搜尋為止

Figure 7.1: 初始時每個物件只能存取自己的屬性

const yoshi = { skulk: true };
const hattori = { sneak: true };
const kuma = { creep: true };

Object.setPrototypeOf(yoshi, hattori);
Object.setPrototypeOf(hattori, kuma);

// yoshi 可以存取 hattori 的 sneak 和 kuma 的 creep
assert("sneak" in yoshi); // true
assert("creep" in yoshi); // true

物件的 prototype 屬性是內部屬性(標記為 [[prototype]]),無法直接存取。使用內建的 Object.setPrototypeOf 方法來設定原型。

Figure 7.2: 存取物件沒有的屬性時,會搜尋其原型鏈

7.2 物件建構與 Prototype#

建構函式與 new 運算子#

每個函式在建立時都會自動獲得一個 prototype 屬性。當使用 new 運算子呼叫函式時:

  1. 建立一個新的空物件
  2. 將新物件的 [[prototype]] 設定為建構函式的 prototype 屬性
  3. 函式以新物件為 context(this)執行
  4. 回傳新物件
function Ninja() {}
Ninja.prototype.swingSword = function () {
  return true;
};

const ninja = new Ninja();
assert(ninja.swingSword()); // true — 透過 prototype 存取

Figure 7.4: 每個函式建立時都會獲得一個新的 prototype 物件

實例屬性 (Instance Properties)#

在建構函式中透過 this 設定的屬性會直接建立在實例上。當實例屬性與 prototype 屬性同名時,實例屬性優先

function Ninja() {
  this.swung = false;
  this.swingSword = function () {
    // 實例方法
    return !this.swung;
  };
}
Ninja.prototype.swingSword = function () {
  // prototype 方法
  return this.swung;
};

const ninja = new Ninja();
assert(ninja.swingSword()); // true — 呼叫的是實例方法

每個實例都會有自己的方法副本,這會消耗更多記憶體。因此建議將方法定義在 prototype 上,讓所有實例共享,除非需要透過閉包模擬私有變數。

Figure 7.5: 實例屬性優先於原型屬性

JavaScript 動態特性的副作用#

JavaScript 中幾乎所有東西都可以在執行期間修改,包括 prototype:

  • 在物件建立之後才新增到 prototype 的方法,既有實例仍然可以存取(因為實例持有對 prototype 物件的參考)
  • 整個 prototype 被替換為新物件,既有實例仍然參考舊的 prototype,新建立的實例才會參考新的 prototype

Figure 7.9: 函式的 prototype 可隨時替換,但已建構的實例仍引用舊原型

透過建構器進行物件分型 (Object Typing)#

  • typeof 對所有物件都回傳 "object",用處有限
  • instanceof 可判斷物件是否由特定建構函式建立
  • constructor 屬性指向建立該物件的建構函式,可用於型別檢查,甚至用來建立新實例
function Ninja() {}
const ninja = new Ninja();

assert(ninja instanceof Ninja); // true
assert(ninja.constructor === Ninja); // true

// 透過 constructor 建立新實例
const ninja2 = new ninja.constructor();
assert(ninja2 instanceof Ninja); // true

7.3 實現繼承 (Inheritance)#

繼承的關鍵技巧是將子類別的 prototype 設定為父類別的實例:

Figure 7.12: 未正確設定繼承時的測試結果

function Person() {}
Person.prototype.dance = function () {};

function Ninja() {}
Ninja.prototype = new Person(); // 關鍵:建立原型鏈

const ninja = new Ninja();
assert(ninja instanceof Ninja); // true
assert(ninja instanceof Person); // true
assert(typeof ninja.dance === "function"); // true — 繼承了 dance

不要直接使用 Ninja.prototype = Person.prototype,因為這樣對 Ninja prototype 的修改也會影響 Person prototype(它們是同一個物件)。

Figure 7.13: 透過將 Ninja 的 prototype 設為 Person 實例來實現繼承

constructor 屬性被覆蓋的問題#

當設定 Ninja.prototype = new Person() 時,原本的 Ninja prototype(包含 constructor 屬性)會被丟棄。此時 ninja.constructor 會沿著原型鏈找到 Person,而非 Ninja

解決方法:使用 Object.defineProperty 重新定義 constructor

Object.defineProperty(Ninja.prototype, "constructor", {
  enumerable: false,
  value: Ninja,
  writable: true,
});

設定 enumerable: false 可以避免 constructor 出現在 for-in 迴圈中,維持與原生行為一致。

instanceof 運算子#

instanceof 的實際語義是檢查右側函式的 prototype 是否在左側物件的原型鏈中,而非嚴格判斷物件是否由該函式建立。

如果在建立物件之後修改了建構函式的 prototype,instanceof 的結果可能會改變,因為它檢查的是當前的 prototype 參考。

7.4 ES6 的 JavaScript “類別”#

ES6 引入了 class 關鍵字,雖然底層仍然基於 prototype 繼承,但提供了更優雅的語法糖 (syntactic sugar)。

使用 class 關鍵字#

class Ninja {
  constructor(name) {
    this.name = name;
  }
  swingSword() {
    return true;
  }
}

var ninja = new Ninja("Yoshi");
assert(ninja instanceof Ninja);
assert(ninja.name === "Yoshi");
assert(ninja.swingSword());

上述 ES6 class 等同於以下 ES5 程式碼:

function Ninja(name) {
  this.name = name;
}
Ninja.prototype.swingSword = function () {
  return true;
};

靜態方法 (Static Methods):使用 static 關鍵字定義類別層級的方法,只能透過類別本身存取,實例無法存取。

class Ninja {
  constructor(name, level) {
    this.name = name;
    this.level = level;
  }
  static compare(ninja1, ninja2) {
    return ninja1.level - ninja2.level;
  }
}

實現繼承#

使用 extends 關鍵字繼承另一個類別,並使用 super 呼叫父類別的建構函式:

class Person {
  constructor(name) {
    this.name = name;
  }
  dance() {
    return true;
  }
}

class Ninja extends Person {
  constructor(name, weapon) {
    super(name); // 呼叫父類別建構函式
    this.weapon = weapon;
  }
  wieldWeapon() {
    return true;
  }
}

var ninja = new Ninja("Yoshi", "Wakizashi");
assert(ninja instanceof Ninja); // true
assert(ninja instanceof Person); // true
assert(ninja.name === "Yoshi");
assert(ninja.dance()); // 繼承自 Person

使用 ES6 classextends 語法,不需要手動處理 prototype 指定、constructor 屬性修復等繁瑣工作,大幅簡化繼承的實作。

Figure 7.15: ES6 class 繼承的屬性與原型鏈結構

7.5 本章總結#

  • JavaScript 物件是具名屬性與值的簡單集合
  • 每個物件都可以參考一個 prototype,當物件本身沒有某屬性時,會委派給 prototype 搜尋,形成原型鏈
  • 可透過 Object.setPrototypeOf 設定物件的 prototype
  • 每個函式都有 prototype 屬性,會自動成為其建構實例的 prototype
  • prototype 物件的 constructor 屬性指向函式本身,可用於判斷物件的建構來源
  • JavaScript 中幾乎所有東西都可以在執行期間修改,包括物件和函式的 prototype
  • 要實現繼承,將子類別的 prototype 設定為父類別的新實例
  • 屬性具有 configurableenumerablewritable 等特性,可用 Object.defineProperty 設定
  • ES6 的 class 關鍵字提供語法糖,底層仍是 prototype 繼承
  • extends 關鍵字讓繼承變得優雅簡潔