2015年12月10日 星期四

JavaScript 中的繼承與原型鏈介紹

對於那些熟悉基於類的物件導向語言(Java 或者 C++)的開發者來說,JavaScript 的語法是比較怪異的,這是由於 JavaScript 是一門動態語言,而且它沒有類的概念( ES6 新增了class 關鍵字,但只是語法糖,JavaScript 仍舊是基於原型)。

說到繼承這一方面,JavaScript 中的每個物件都有一個內部的鏈接指向另一個物件,這個物件就是原物件的原型(prototype)。這個原型物件也有自己的原型,直到物件的原型為 null 為止(也就是沒有原型)。這種一級一級的鏈結構就稱為原型鏈(prototype chain)。

雖然這通常會被稱作 JavaScript 的弱點之一,實際上這種原型繼承的模型要比經典的繼承模型還要強大。雖然在原型模型上構建一個經典模型是相當瑣碎的,但如果採取其他方式實現則會更加困難。
基於原型鏈的繼承
繼承屬性

JavaScript 物件是動態的裝著許多屬性的「袋子」(指其自己的屬性)。JavaScript 物件有一個指向一個原型物件的鏈接。當試圖訪問一個物件的屬性時,不僅會試圖訪問該在物件上的,而且訪問該物件的原型上的,以及該物件的原型的原型上的,依此類推,直到找到一個名字匹配的屬性或達到了原型鏈的末尾。

根據 ECMAScript 標準,someObject.[[Prototype]] 符號是用於指派 someObject 的原型。這個等同於 JavaScript 的 __proto__  屬性(現已棄用)。從 ECMAScript 6 開始, [[Prototype]] 可以用Object.getPrototypeOf()和Object.setPrototypeOf()訪問器來訪問。

下面的程式碼則示範了當訪問一個物件的屬性時發生的行為:

// 假定有一個物件 o, 其自身的屬性(own properties)有 a 和 b:
// {a: 1, b: 2}
// o.[[Prototype]]有屬性 b 和 c:
// {b: 3, c: 4}
// 最後, o.[[Prototype]].[[Prototype]] 是 null.
// 這就是原型鏈的末尾,即 null,
// 根據定義,null 沒有[[Prototype]].
// 綜上,整個原型鏈如下:
// {a:1, b:2} ---> {b:3, c:4} ---> null

console.log(o.a); // 1
// a是o的自身屬性嗎?是的,該屬性的值為1

console.log(o.b); // 2
// b是o的自身屬性嗎?是的,該屬性的值為2
// o.[[Prototype]]上還有一個'b'屬性,但是它不會被訪問到.這種情況稱為"屬性遮蔽 (property shadowing)".

console.log(o.c); // 4
// c是o的自身屬性嗎?不是,那看看o.[[Prototype]]上有沒有.
// c是o.[[Prototype]]的自身屬性嗎?是的,該屬性的值為4

console.log(o.d); // undefined
// d是o的自身屬性嗎?不是,那看看o.[[Prototype]]上有沒有.
// d是o.[[Prototype]]的自身屬性嗎?不是,那看看o.[[Prototype]].[[Prototype]]上有沒有.
// o.[[Prototype]].[[Prototype]]為null,停止搜索,
// 沒有d屬性,返回undefined

新增一個物件它自己的屬性的方法就是設置這個物件的屬性。唯一例外的獲取和設置的行為規則就是當有一個 getter或者一個setter 被設置成繼承的屬性的時候。
繼承方法

JavaScript 並沒有其他基於類的語言所定義的「方法」。在 JavaScript 裡,任何函式都可以增加到物件上作為物件的屬性。繼承的函式與其他的屬性是基本沒有差別的,包括上面的「屬性遮蔽」(這種情況相當於其他語言的方法重寫)。

當繼承的函式被呼叫時,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是一個物件, p.[[Prototype]]是o.

p.a = 12; // 新增 p 的自身屬性a.
console.log(p.m()); // 13
// 呼叫 p.m 時, 'this'指向 p.
// 又因為 p 繼承 o 的 m 函式
// 此時的'this.a' 即 p.a,即 p 的自身屬性 'a'

使用不同的方法來新增物件和產生原型鏈
使用普通語法新增物件

var o = {a: 1};

// o這個物件繼承了Object.prototype上面的所有屬性
// 所以可以這樣使用 o.hasOwnProperty('a').
// hasOwnProperty 是Object.prototype的自身屬性。
// 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.vertexes = [];
  this.edges = [];
}

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

var g = new Graph();
// g是產生的物件,他的自身屬性有'vertexes'和'edges'.
// 在g被範例化時,g.[[Prototype]]指向了Graph.prototype.

使用 Object.create 新增物件

ECMAScript 5 中引入了一個新方法:Object.create()。可以呼叫這個方法來新增一個新物件。新物件的原型就是呼叫 create 方法時傳入的第一個參數:

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

var b = Object.create(a);
// b ---> a ---> Object.prototype ---> null
console.log(b.a); // 1 (繼承而來)

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

var d = Object.create(null);
// d ---> null
console.log(d.hasOwnProperty); // undefined, 因為d沒有繼承Object.prototype

使用 class 關鍵字

ECMAScript6引入了一套新的關鍵字用來實現 class。使用基於類語言的開發人員會對這些結構感到熟悉,他們是不一樣的。 JavaScript仍然是基於原型的。這些新的關鍵字包括 class, constructor, static, extends, 和 super.

"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);

性能

在原型鏈上查找屬性比較耗時,對性能有副作用,這在性能要求苛刻的情況下很重要。另外,試圖訪問不存在的屬性時會迭代整個原型鏈。

迭代物件的屬性時,原型鏈上的每個屬性都是可枚舉的。

檢測物件的屬性是定義在自身上還是在原型鏈上,有必要使用 hasOwnProperty 方法,該方法由所有物件繼承自 Object.proptotype。

hasOwnProperty 是 JavaScript 中唯一一個只涉及物件自身屬性而不會迭代原型鏈的方法。

注意:僅僅通過判斷值是否為 undefined 還不足以檢測一個屬性是否存在,一個屬性可能存在而其值恰好為 undefined。
不好的實踐:擴展原生物件的原型

一個經常使用的不好實踐是擴展 Object.prototype 或者其他內置物件的原型。

該技術被稱為 monkey patching,它破壞了物件的封裝性。雖然一些流行的框架(如 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() 去繼承

prototype 和 Object.getPrototypeOf

對於從Java或C ++來的開發人員來說JavaScript是有點讓人困惑的,因為它全部都是動態的,所有的執行時,而且並不存在類(classes)。這一切都只是範例(物件)。模擬的「classes」也只是一個函式物件。

你可能已經注意到,我們的函式 A 有一個特殊的屬性叫做原型。這種特殊的屬性與JavaScript 的new 運算子一起工作。參考原型物件複製到新範例的內部[[Prototype]]屬性。例如,當你這樣做: var a1 = new A(), JavaScript 就會這樣做:a1.[[Prototype]] = A.prototype(在記憶體中新增物件後,而在執行 this 綁定的函式 A()之前)。然後當您訪問範例的屬性,JavaScript首先檢查它們是否直接在該物件上存在(即是否是該物件的自身屬性),如果不是,它可能是在[[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() 是用於範例的(instances),兩者功能一致。

[[Prototype]] 看起來就像遞歸一樣, 如a1.doSomething,Object.getPrototypeOf(a1).doSomething,Object.getPrototypeOf(Object.getPrototypeOf(a1)).doSomething 等等, 直到它找到 doSomething 這個屬性或者 Object.getPrototypeOf 返回 null。

因此,當你這樣的時候:

var o = new Foo();

JavaScript 實際上是這樣的:

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

(或者類似上面這樣的),之後當你這樣做的時候:

o.someProp;

它會檢查是否存在 someProp 屬性。如果沒有,它會檢查 Object.getPrototypeOf(o).someProp 並且如果仍舊沒有,它就會檢查Object.getPrototypeOf(Object.getPrototypeOf(o)).someProp ,如此這般一直做下去,直到它找到這個屬性 或者 Object.getPrototypeOf() 返回 null 的時候。
結論

在編寫使用到原型繼承模型的複雜程式碼前理解原型繼承模型十分重要。同時,還要清楚程式碼中原型鏈的長度,並在必要時結束原型鏈,以避免可能存在的性能問題。更進一步,除非為了兼容新 JavaScript 特性,否則永遠不要擴展原生物件的原型。

沒有留言:

張貼留言