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

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

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

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

使用原型鍊繼承

繼承屬性

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

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

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

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

// 利用含有 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]] 成了 null
// 這是原型鏈的結末,因為 null 按照定義並沒有 [[Prototype]]。
// 因此,整個原型鏈看起來就像:
// {a: 1, b: 2} ---> {b: 3, c: 4} ---> 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 在「o.[[Prototype]].[[Prototype]]」是 null,停止搜尋。
// 找不到任何屬性,回傳 undefined。

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

繼承方法

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

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

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 is 時「this」指的是 p
// 因此在 p 繼承 o 的函式 m 時,
// 「this.a」指的是 p.a:也就是 p 的自有屬性「a」

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

含有語法結構的物件

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 操作符呼叫的函式。

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()。呼叫這個方法就可以建立新的物件。這個物件的原型,為函式的第一個參數。

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 依舊是基於原型的。新的關鍵字包括 classconstructorstaticextendssuper

'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 方法。

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

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

壞實做:擴充原生的原型

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

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

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

示例

B 要繼承自 A

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

// What is the purpose of including varA in the prototype when A.prototype.varA will always be shadowed by
// this.varA, given the definition of function A above?
A.prototype = {
  varA: null,  // Shouldn't we strike varA from the prototype as doing nothing?
      // perhaps intended as an optimization to allocate space in hidden classes?
      // https://developers.google.com/speed/articles/optimizing-javascript#Initializing instance variables
      // would be valid if varA wasn't being initialized uniquely for each instance
  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」關鍵字,也只是函式物件。

You probably already noticed that our function A has a special property called prototype. This special property works with the JavaScript new operator. The reference to the prototype object is copied to the internal [[Prototype]] property of the new instance. For example, when you do var a1 = new A(), JavaScript (after creating the object in memory and before running function A() with this defined to it) sets a1.[[Prototype]] = A.prototype. When you then access properties of the instance, JavaScript first checks whether they exist on that object directly, and if not, it looks in [[Prototype]]. This means that all the stuff you define in prototype is effectively shared by all instances, and you can even later change parts of prototype and have the changes appear in all existing instances, if you wanted to.

If, in the example above, you do var a1 = new A(); var a2 = new A(); then a1.doSomething would actually refer to Object.getPrototypeOf(a1).doSomething, which is the same as the A.prototype.doSomething you defined, i.e. Object.getPrototypeOf(a1).doSomething == Object.getPrototypeOf(a2).doSomething == A.prototype.doSomething.

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

[[Prototype]] is looked at recursively, i.e. a1.doSomething, Object.getPrototypeOf(a1).doSomething, Object.getPrototypeOf(Object.getPrototypeOf(a1)).doSomething etc., until it's found or Object.getPrototypeOf returns null.

因此當你:

var o = new Foo();

JavaScript actually just does

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

或(有時)這樣:

o.someProp;

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

結論

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

文件標籤與貢獻者

此頁面的貢獻者: iigmir, WellyHong, hiiamyes, Snailpool
最近更新: iigmir,