繼承與原型鏈

JavaScript 是個沒有實做 class 關鍵字的動態語言,所以會對那些基於類別(class-based)語言(如 Java 或 C++)背景出身的開發者來說會有點困惑。(在 ES2015 有提供 class 關鍵字,但那只是個語法糖,JavaScript 仍然是基於原型(prototype-based)的語言)。

講到繼承,JavaScript 就只有一個建構子:物件。每個物件都有一個連著其他原型(prototype)的私有屬性(private property)物件。原型物件也有著自己的原型,於是原型物件就這樣鏈結,直到撞見 null 為止:null 在定義裡沒有原型、也是原型鏈(prototype chain)的最後一個鏈結。

幾乎所有 JavaScript 的物件,都是在原型鏈最頂端的 Object (en-US) 實例。

雖然這常被認為是 JavaScript 的一個缺陷,但原型繼承模型實際上,比傳統的 classic 模型更強大。舉例來說,使用原型繼承模型建構一個 classic 模型是相當容易的。

使用原型鍊繼承

繼承屬性

JavaScript 物件是一「包」動態的屬性(也就是它自己的屬性)並擁有一個原型物件的鏈結,當物件試圖存取一個物件的屬性時,其不僅會尋找該物件,也會尋找該物件的原型、原型的原型……直到找到相符合的屬性,或是到達原型鏈的尾端。

備註: 遵照 ECMAScript 標準的 someObject.[[Prototype]] 標記,用於指派 someObject 的原型。從 ECMAScript 2015 開始, [[Prototype]] 使用 Object.getPrototypeOf()Object.setPrototypeOf() (en-US) 這兩個訪問器(accessors)訪問,等同於非標準,但各大瀏覽器已實做的 __proto__ 屬性。

不要把 someObject.[[Prototype]] 與函式屬性 func.prototype 混淆了。它在函式被用作建構子的時候,指定 [[Prototype]] 要分派到所有由給定函式建立的物件實例(instance)。Object.prototype 屬性代表了原型屬性 Object (en-US)

以下是嘗試存取屬性時會發生的事:

js
// 利用含有 a 與 b 屬性的 f 函式,建立一個 o 物件:
let f = function () {
  this.a = 1;
  this.b = 2;
};
let o = new f(); // {a: 1, b: 2}

// 接著針對 f 函式的原型添加屬性
f.prototype.b = 3;
f.prototype.c = 4;

// 不要寫 f.prototype = {b:3,c:4}; 因為它會破壞原型鏈
// o.[[Prototype]] 有 b 與 c 的屬性:{b: 3, c: 4}
// o.[[Prototype]].[[Prototype]] 是 Object.prototype.
// 最後 o.[[Prototype]].[[Prototype]].[[Prototype]] 成了 null
// 這是原型鏈的結末,因為 null 按照定義並沒有 [[Prototype]]。
// 因此,整個原型鏈看起來就像:
// {a: 1, b: 2} ---> {b: 3, c: 4} ---> Object.prototype ---> null

console.log(o.a); // 1
// o 有屬性「a」嗎?有,該數值為 1。

console.log(o.b); // 2
// o 有屬性「b」嗎?有,該數值為 2。
// o 還有個原型屬性「b」,但這裡沒有被訪問到。
// 這稱作「property shadowing」。

console.log(o.c); // 4
// o 有屬性「c」嗎?沒有,那就找 o 的原型看看。
// o 在「o.[[Prototype]]」有屬性「c」嗎?有,該數值為 4。

console.log(o.d); // undefined
// o 有屬性「d」嗎?沒有,那就找 o 的原型看看。
// o 在「o.[[Prototype]]」有屬性「d」嗎?沒有,那就找 o.[[Prototype]] 的原型看看。
// o.[[Prototype]].[[Prototype]] 是 Object.prototype,預設並沒有屬性「d」,那再找他的原型看看。
// o 在「o.[[Prototype]].[[Prototype]].[[Prototype]]」是 null,停止搜尋。
// 找不到任何屬性,回傳 undefined。

給物件設定屬性,會令其建立自有的屬性。這個行為規則的唯一例外,就是碰上以 getter 或 setter (en-US) 繼承的屬性時。

繼承方法

Javascript 並沒有其他基於類別語言那般定義的方法。在 Javascript 裡,任何函式都能以屬性的方式加到物件中。一個被繼承的函式的行為就像是其他屬性一樣,其中也包含了上述的 property shadowing(在這種情況下叫做 method overriding)。

當繼承函式執行時,this 值指向繼承的物件,而不是在函式內擁有屬性的原型物件。

js
var o = {
  a: 2,
  m: function () {
    return this.a + 1;
  },
};

console.log(o.m()); // 3
// 在這裡呼叫 o.m 時「this」指的是 o

var p = Object.create(o);
// p 是個從 o 繼承的物件

p.a = 4; // 在 p 建立屬性「a」
console.log(p.m()); // 5
// 呼叫 p.m 時「this」指的是 p
// 因此在 p 繼承 o 的函式 m 時,
// 「this.a」指的是 p.a:也就是 p 的自有屬性「a」

產生物件和原型鏈的幾種方法

含有語法結構的物件

js
var o = { a: 1 };

// 新建的 o 有個自己的 [[Prototype]] 稱為 Object.prototype
// o 自己並沒有稱為「hasOwnProperty」的屬性
// 而 hasOwnProperty 是 Object.prototype 的自有屬性。
// 因此 o 從 Object.prototype 繼承了 hasOwnProperty
// Object.prototype 作為其原型多了個 null
// o ---> Object.prototype ---> null

var a = ["yo", "whadup", "?"];

// 從 Array.prototype 繼承的陣列,含有諸如 indexOf、forEach……之類的方法
// 原型鏈看起來就像:
// a ---> Array.prototype ---> Object.prototype ---> null

function f() {
  return 2;
}

// 從 Function.prototype 繼承的函式,含有諸如 call、bind……之類的方法
// f ---> Function.prototype ---> Object.prototype ---> null

透過建構子

JavaScript 建構子,就、只、是、個、被 new 運算子呼叫的函式。

js
function Graph() {
  this.vertices = [];
  this.edges = [];
}

Graph.prototype = {
  addVertex: function (v) {
    this.vertices.push(v);
  },
};

var g = new Graph();
// g 是個有著「vertices」與「edges」屬性的物件。
// 在執行 new Graph() 時 g.[[Prototype]] 是 Graph.prototype 的值。

Object.create

ECMAScript 5 引入了新方法:Object.create()。呼叫這個方法就可以建立新的物件。這個物件的原型,為函式的第一個參數。

js
var a = { a: 1 };
// a ---> Object.prototype ---> null

var b = Object.create(a);
// b ---> a ---> Object.prototype ---> null
console.log(b.a); // 1 (inherited)

var c = Object.create(b);
// c ---> b ---> a ---> Object.prototype ---> null

var d = Object.create(null);
// d ---> null
console.log(d.hasOwnProperty);
// undefined, because d doesn't inherit from Object.prototype

使用關鍵字 class

ECMAScript 2015 引入了新的類別實做。儘管對那些基於類別的開發者來說,這種結構體令他們感到熟悉,它們依舊不一樣。JavaScript 依舊是基於原型的。新的關鍵字包括 class (en-US)constructorstaticextendssuper

js
"use strict";

class Polygon {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
}

class Square extends Polygon {
  constructor(sideLength) {
    super(sideLength, sideLength);
  }
  get area() {
    return this.height * this.width;
  }
  set sideLength(newLength) {
    this.height = newLength;
    this.width = newLength;
  }
}

var square = new Square(2);

效能

原型鏈上的屬性的查詢時間,可能會對效能有負面影響,對程式碼也因而產生明顯問題。另外,試圖尋找不存在的屬性,就一定會遍歷整個原型鏈。

接著,在迭代物件屬性時,每個原型鏈的枚舉屬性都會抓出來。

要檢查物件本身有沒有指定的屬性、也不需要查找整個原型鏈時,你必須使用由 Object.prototype 繼承的 hasOwnProperty 方法。

js
console.log(g.hasOwnProperty("vertices"));
// true

console.log(g.hasOwnProperty("nope"));
// false

console.log(g.hasOwnProperty("addVertex"));
// false

console.log(g.__proto__.hasOwnProperty("addVertex"));
// true

在 JavaScript 裡面 hasOwnProperty 是唯一能處理、且遍歷整個原型鏈的方法。

註:如果只有檢查屬性是否為 undefined (en-US) 是不夠的。該屬性可能存在,只是數值被設定為 undefined

壞實做:擴充原生的原型

一個常見的誤用,是擴充 Object.prototype 或其他內建的原型。

這種技巧稱為猴子補丁(monkey patching),它會破壞封裝(encapsulation)。儘管有些受歡迎的框架如 Prototype.js 會這麼做,但它們並不是以額外的非標準功能,打亂內建類型的好理由。

擴充內建原型的唯一合理理由,就是反向移植新版 JavaScript 引擎的功能,例如 Array.forEach

示例

B 要繼承自 A

js
function A(a) {
  this.varA = a;
}

// 在 A.prototype.varA 會永遠被 this.varA 鬼隱(shadowed)的情況下,
// 把 varA 包在給定函式的原型內,其意義為何?
A.prototype = {
  varA: null, // 難道不能因為原型啥都沒作,就把 varA 打斷嗎?
  // 也許他打算要分配隱藏類別的空間,以便實行最佳化?
  // https://developers.google.com/speed/articles/optimizing-javascript#Initializing instance variables
  // 如果 varA 沒有針對每個實例作單一初始化,那麼不就要變有效的?
  doSomething: function () {
    // ...
  },
};

function B(a, b) {
  A.call(this, a);
  this.varB = b;
}
B.prototype = Object.create(A.prototype, {
  varB: {
    value: null,
    enumerable: true,
    configurable: true,
    writable: true,
  },
  doSomething: {
    value: function () {
      // override
      A.prototype.doSomething.apply(this, arguments); // call super
      // ...
    },
    enumerable: true,
    configurable: true,
    writable: true,
  },
});
B.prototype.constructor = B;

var b = new B();
b.doSomething();

重點是:

  • 型別被定義在 .prototype
  • 你用了 Object.create() 繼承。

prototypeObject.getPrototypeOf

JavaScript 對那些從 Java 或 C++ 學過來的人來說,可能會有點困惑,因為它動態、永遠是執行狀態(all runtime)、還完全沒有 class。一切都只是實例(物件)。即使是「class」關鍵字,也只是函式物件。

你可能早就發現我們的 function A 有個名為 prototype 的特殊屬性。這個特殊屬性會透過 new 運算子運行之。原型物件的參照,會把新實例的 [[Prototype]] 屬性複製到該物件內部。比方說在你 var a1 = new A() 時,JavaScript 在記憶體裡面建立物件後、還有運行已經定義好 this 的函式 A() 前,會設定 a1.[[Prototype]] = A.prototype。在接著訪問實例的屬性前,JavaScript 首先會直接檢查物件的實例屬性是否存在。如果沒有,就檢查 [[Prototype]]。也就是說,在 prototype 定義的東西,能夠有效地共享給各實,甚至,如果想的話,還可以改變並顯現於現有實例中。

如上例所示,首先 var a1 = new A(); var a2 = new A(); 接著 a1.doSomething 的話,會參照到 Object.getPrototypeOf(a1).doSomething,他會與早就定義好的 A.prototype.doSomething 相同,例如: Object.getPrototypeOf(a1).doSomething == Object.getPrototypeOf(a2).doSomething == A.prototype.doSomething.

簡而言之,prototype 針對型別,而 Object.getPrototypeOf() 則和實例相同。

[[Prototype]] 遞歸性地鏈結著,例如說 a1.doSomething, Object.getPrototypeOf(a1).doSomething, Object.getPrototypeOf(Object.getPrototypeOf(a1)).doSomething 這樣,直到找出 Object.getPrototypeOf 或回傳 null。

因此當你:

js
var o = new Foo();

JavaScript 其實會:

js
var o = new Object();
o.[[Prototype]] = Foo.prototype;
Foo.call(o);

或偶爾這樣:

js
o.someProp;

時,它檢查了 o 有沒有 someProp 屬性。如果沒有,就檢查 Object.getPrototypeOf(o).someProp;再沒有就檢查 Object.getPrototypeOf(Object.getPrototypeOf(o)).someProp,依此類推。

結論

在撰寫複雜的可用程式碼之前,理解原型繼承模型很重要。另外,請注意程式碼內原型鏈的長度、必要時打破它們,以避免潛在的效能問題。再來,除非要處理 JavaScript 新語法的相容性,否則絕對不能擴充原生的原型。