JavaScript 原型系統的變遷,以及 ES6 class

概述

JavaScript 的原型系統是最初就有的語言設計。但隨着 ES 標準的進化和新特性的添加。它也一直在不停進化。這篇文章的目的就是梳理一下早期到 ES5 和如今 ES6,新特性的加入對原型系統的影響。html

若是你對原型的理解還停留在 function + new 這個層面而不知道更深刻的操做原型鏈的技巧,或者你想了解 ES6 class 的知識,相信本文會有所幫助。git

這篇文章是我學習 You Don't Know JS 的副產品,推薦任何想系統性地學習 JavaScript 的人去閱讀此書。es6

JavaScript 原型簡述

不少人應該都對原型(prototype)不陌生。簡單地說,JavaScript 是基於原型的語言。當咱們調用一個對象的屬性時,若是對象沒有該屬性,JavaScript 解釋器就會從對象的原型對象上去找該屬性,若是原型上也沒有該屬性,那就去找原型的原型。這種屬性查找的方式被稱爲原型鏈(prototype chain)。github

對象的原型是沒有公開的屬性名去訪問的(下文再談 __proto__ 屬性)。如下爲了方便稱呼,我把一個對象內部對原型的引用稱爲 [[Prototype]]。web

JavaScript 沒有類的概念,原型鏈的設定就是少數可以讓多個對象共享屬性和方法,甚至模擬繼承的方式。在 ES5 之前,若是咱們想設置對象的 [[Prototype]],只能經過 new 關鍵字,好比:數組

function User() {
  this._name = 'David'
}

User.prototype.getName = function() {
  return this._name
}

var user = new User()
user.getName()                  // "David"
user.hasOwnProperty('getName')  // false

User 函數被 new 關鍵字調用時,它就相似於一個構造函數,其生成的對象的 [[Prototype]] 會引用 User.prototype 。由於 User.prototype 也是一個對象,它的 [[Prototype]] 是 Object.prototype瀏覽器

通常咱們對這種構造函數命名都會採用 CamelCase ,並把它稱呼爲「類」,這不只是爲了跟 OOP 的理念保持一致,也是由於 JavaScript 的內建「類」也是這種命名。app

SomeClass 生成的對象,其 [[Prototype]] 是 SomeClass.prototype。除了稍顯繁瑣,這套邏輯是能夠自圓其說的,好比:框架

  1. 咱們用 {..} 建立的對象的 [[Prototype]] 都是 Object.prototype,也是原型鏈的頂點。ide

  2. 數組的 [[Prototype]] 是 Array.prototype

  3. 字符串的 [[Prototype]] 是 String.prototype

  4. Array.prototypeString.prototype 的 [[Prototype]] 是 Object.prototype

模擬繼承

模擬繼承是自定義原型鏈的典型使用場景。但若是用 new 的方式則比較麻煩。一種常見的解法是:子類的 prototype 等於父類的實例。這就涉及到定義子類的時候調用父類的構造函數。爲了不父類的構造函數在類定義過程當中的潛在影響,咱們通常會建造一個臨時類去作代替父類 new 的過程。

function Parent() {}
function Child() {}

function createSubProto(proto) {
  // fn 在這裏就是臨時類
  var fn = function() {}
  fn.prototype = proto
  return new fn()
}

Child.prototype = createSubProto(Parent.prototype)
Child.prototype.constructor = Child

var child = new Child()
child instanceof Child   // true
child instanceof Parent  // true

ES5: 自由地操控原型鏈

既然原型鏈本質上只是創建對象之間的關聯,那咱們可不能夠直接操做對象的 [[Prototype]] 呢?

在 ES5(準確的說是 5.1)以前,咱們沒有辦法直接獲取對象的原型,只能經過 [[Prototype]] 的 constructor

var user = new User()
user.constructor.prototype          // User
user.hasOwnProperty('constructor')  // false

類能夠經過 prototype 屬性獲取生成的對象的 [[Prototype]]。[[Prototype]] 裏的 constructor 屬性又會反過來引用函數自己。由於 user 的原型是 User.prototype ,它天然也可以經過 constructor 獲取到 User 函數,進而獲取到本身的 [[Prototype]]。比較繞是吧?

ES5.1 以後加了幾個新的 API 幫助咱們操做對象的 [[Prototype]],自此之後 JavaScript 才真的有自由操控原型的能力。它們是:

  • Object.prototype.isPrototypeOf

  • Object.create

  • Object.getPrototypeOf

  • Object.setPrototypeOf

注:以上方法並不徹底是 ES5.1 的,isPrototypeOf 是 ES3 就有的,setPrototypeOf 是 ES6 纔有的。但它們的規範都在 ES6 中修改了一部分。

下面的例子裏,Object.create 建立 child 對象,並把 [[Prototype]] 設置爲 parent 對象。Object.getPrototypeOf 能夠直接獲取對象的 [[Prototype]]。isPrototypeOf 可以判斷一個對象是否在另外一個對象的原型鏈上。

var parent = {
  _name: 'David',
  getName: function() { return this._name },
}

var child = Object.create(parent)

Object.getPrototypeOf(child)           // parent
parent.isPrototypeOf(child)            // true
Object.prototype.isPrototypeOf(child)  // true
child instanceof Object                // true

既然有 Object.getPrototypeOf,天然也有 Object.setPrototypeOf 。這個函數能夠修改任何對象的 [[Prototype]] ,包括內建類型。

var anotherParent = {
  name: 'Alex'
}

Object.setPrototypeOf(child, anotherParent)
Object.getPrototypeOf(child)  // anotherParent

// 修改數組的 [[Prototype]]
var a = []
Object.setPrototypeOf(a, anotherParent)
a instanceof Array        // false
Object.getPrototypeOf(a)  // anotherParent

靈活使用以上的幾個方法,咱們能夠很是輕鬆地建立原型鏈,或者在已知原型鏈中插入自定義的對象,玩法只取決於想象力。咱們以此修改一下上面的模擬繼承的例子:

function Parent() {}
function Child() {}

Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child

由於 Object.create(..) 傳入的參數會做爲 [[Prototype]] ,因此這裏有一個有意思的小技巧。咱們能夠用 Object.create(null) 建立一個沒有任何屬性的對象。這個技巧適合作 proxy 對象,有點相似 Ruby 中的 BasicObject

尷尬的私生子 __proto__

說到操做 [[Prototype]] 就不得不提 __proto__ 。這個屬性是一個 getter/setter ,能夠用來獲取和設置任意對象的 [[Prototype]] 。

child.__proto__           // equal to Object.getPrototypeOf(child)
child.__proto__ = parent  // equal to Object.setPrototypeOf(child, parent)

它原本不是 ES 的標準,無奈衆多瀏覽器早早地都實現了這個屬性,並且應用得還挺普遍的。到了 ES6 爲了向下兼容性只好接納它成爲標準的一部分。這是典型的現實倒逼標準的例子。

看看 MDN 的描述都充滿了怨念。

The use of proto is controversial, and has been discouraged. It was never originally included in the EcmaScript language spec, but modern browsers decided to implement it anyway. Only recently, the proto property has been standardized in the ECMAScript 6 language specification for web browsers to ensure compatibility, so will be supported into the future. It is deprecated in favor of Object.getPrototypeOf/Reflect.getPrototypeOf and Object.setPrototypeOf/Reflect.setPrototypeOf (though still, setting the [[Prototype]] of an object is a slow operation that should be avoided if performance is a concern).

__proto__ 是不被推薦的用法。大部分狀況下咱們仍然應該用 Object.getPrototypeOfObject.setPrototypeOf 。什麼是少數狀況,待會再講。

ES6: class 語法糖

不得不說開發者世界受 OO 的影響很是之深,雖然 ES5 給了咱們足夠靈活的 API ,可是:

  • 不少人仍是傾向於用 class 來組織代碼。

  • 不少類庫、框架創造了本身的 API 來實現 class 的功能。

產生這一現象的緣由有不少,但事實如此。並且若是用別人的輪子,有些事是咱們沒法選擇的。也許是看到了這一現象,ES6 時代終於有了 class 語法,有望統一各個類庫和框架不一致的類實現方式。來看一個例子:

class User {
  constructor(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }

  fullName() {
    return `${this.firstName} ${this.lastName}`
  }
}

let user = new User('David', 'Chen')
user.fullName()  // David Chen

以上的類定義語法很是直觀,它跟如下的 ES5 語法是一個意思:

function User(firstName, lastName) {
  this.firstName = firstName
  this.lastName = lastName
}

User.prototype.fullName = function() {
  return '' + this.firstName + this.lastName
}

ES6 並無改變 JavaScript 基於原型的本質,只是在此之上提供了一些語法糖。class 就是其中之一。其餘的還有 extendssuperstatic 。它們大多數均可以轉換成等價的 ES5 語法。

咱們來看看另外一個繼承的例子:

class Child extends Parent {
  constructor(firstName, lastName, age) {
    super(firstName, lastName)
    this.age = age
  }
}

其基本等價於:

function Child(firstName, lastName, age) {
  Parent.call(this, firstName, lastName)
  this.age = age
}

Child.prototype = Object.create(Parent.prototype)
Child.constructor = Child

無疑上面的例子更加直觀,代碼組織更加清晰。這也是加入新語法的目的。不過雖然新語法的本質仍是基於原型的,但新加入的概念或多或少會引發一些連帶的影響。

extends 繼承內建類的能力

由於語言內部設計緣由,咱們沒有辦法自定義一個類來繼承 JavaScript 的內建類的。繼承類每每會有各類問題。ES6 的 extends 的最大的賣點,就是不只能夠繼承自定義類,還能夠繼承 JavaScript 的內建類,好比這樣:

class MyArray extends Array {
}

這種方式可讓開發者繼承內建類的功能創造出符合本身想要的類。全部 Array 已有的屬性和方法都會對繼承類生效。這確實是個不錯的誘惑,也是繼承最大的吸引力。

但現實老是悲催的。extends 內建類會引起一些奇怪的問題,不少屬性和方法沒辦法在繼承類中正常工做。舉個例子:

var a = new Array(1, 2, 3)
a.length  // 3

var b = new MyArray(1, 2, 3)
b.length  // 0

若是說語法糖能夠用 Babel.js 這種 transpiler 去編譯成 ES5 解決 ,擴充的 API 能夠用 polyfill 解決,可是這種內建類的繼承機制顯然是須要瀏覽器支持的。而目前惟一支持這個特性的瀏覽器是………… Microsoft Edge 。

好在這並非什麼致命的問題。大多數此類需求均可以用封裝類去解決,無非是多寫一點 wrapper API 而已。並且我的認爲封裝和組合反而是比繼承更靈活的解決方案。

super 帶來的新概念(坑?)

super 在 constructor 和普通方法裏的不一樣

在 constructor 裏面,super 的用法是 super(..)。它至關於一個函數,調用它等於調用父類的 constructor 。但在普通方法裏面,super 的用法是 super.prop 或者 super.method()。它至關於一個指向對象的 [[Prototype]] 的屬性。這是 ES6 標準的規定。

class Parent {
  constructor(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }

  fullName() {
    return `${this.firstName} ${this.lastName}`
  }
}

class Child extends Parent {
  constructor(firstName, lastName, age) {
    super(firstName, lastName)
    this.age = age
  }

  fullName() {
    return `${super.fullName()} (${this.age})`
  }
}

注意:Babel.js 對方法裏調用 super(..) 也能編譯出正確的結果,但這應該是 Babel.js 的 bug ,咱們不應以此得出 super(..) 也能夠在非 constructor 裏用的結論。

super 在子類的 constructor 裏必須先於 this 調用

若是寫子類的 constructor 須要操做 this ,那麼 super 必須先調用!這是 ES6 的規則。因此寫子類的 constructor 時儘可能把 super 寫在第一行。

class Child extends Parent {
  constructor() {
    this.xxx()  // invalid
    super()
  }
}

super 是編譯時肯定,不是運行時肯定

什麼意思呢?先看代碼:

class Child extends Parent {
  fullName() {
    super.fullName()
  }
}

以上代碼中 fullName 方法的 ES5 等價代碼是:

fullName() {
  Parent.prototype.fullName.call(this)
}

而不是

fullName() {
  Object.getPrototypeOf(this).fullName.call(this)
}

這就是 super 編譯時肯定的特性。不過爲何要這樣設計?我的理解是,函數的 this 只有在運行時才能肯定。所以在運行時根據 this 的原型鏈去得到上層方法並不太符合 class 的常規思惟,在某些狀況下更容易產生錯誤。好比 child.fullName.call(anotherObj)

super 對 static 的影響,和類的原型鏈

static 至關於類方法。由於編譯時肯定的特性,如下代碼中:

class Child extends Parent {
  static findAll() {
    return super.findAll()
  }
}

findAll 的 ES5 等價代碼是:

findAll() {
  return Parent.findAll()
}

static 貌似和原型鏈不要緊,但這不妨礙咱們討論一個問題:類的原型鏈是怎樣的?我沒查到相關的資料,不過咱們能夠測試一下:

Object.getPrototypeOf(Child) === Parent             // true
Object.getPrototypeOf(Parent) === Object            // false
Object.getPrototypeOf(Parent) === Object.prototype  // false

proto = Object.getPrototypeOf(Parent)
typeof proto                             // function
proto.toString()                         // function () {}
proto === Object.getPrototypeOf(Object)  // true
proto === Object.getPrototypeOf(String)  // true

new proto()  //TypeError: function () {} is not a constructor

可見自定義類的話,子類的 [[Prototype]] 是父類,而全部頂層類的 [[Prototype]] 都是同一個函數對象,不論是內建類如 Object 仍是自定義類如 Parent 。但這個函數是不能用 new 關鍵字初始化的。雖然這種設計沒有 Ruby 的對象模型那麼巧妙,不過也是可以自圓其說的。

直接定義 object 並設定 [[Prototype]]

除了經過 classextends 的語法設定 [[Prototype]] 以外,如今定義對象也能夠直接設定 [[Prototype]] 了。這就要用到 __proto__ 屬性了。「定義對象並設置 [[Prototype]]」 是惟一建議用 __proto__ 的地方。另外,另外注意 super 只有在 method() {} 這種語法下才能用。

let parent = {
  method1() { .. },
  method2() { .. },
}

let child = {
  __proto__: parent,

  // valid
  method1() {
    return super.method1()
  },

  // invalid
  method2: function() {
    return super.method2()
  },
}

總結

JavaScript 的原型是頗有意思的設計,從某種程度上說它是更加純粹的面向對象設計(而不是面向類的設計)。ES5 和 ES6 加入的 API 能更有效地操控原型鏈。語言層面支持的 class 也能讓忠於類設計的開發者用更加統一的方式去設計類。雖然目前 class 僅僅提供了一些基本功能。但隨着標準的進步,相信它還會擴充出更多的功能。

本文的主題是原型系統的變遷,因此並無涉及 getter/setter 和 defineProperty 對原型鏈的影響。想系統地學習原型,你能夠去看 You Don't Know JS: this & Object Prototypes

參考資料

You Don't Know JS: this & Object Prototypes
You Don't Know JS: ES6 & Beyond
Classes in ECMAScript 6 (final semantics)
MDN: Object.prototype.__proto__

相關文章
相關標籤/搜索