深入理解JavaScript原型

麵包屑的獨白發表於2019-02-24

前言

在軟體工程中,程式碼重用的模式極為重要,因為他們可以顯著地減少軟體開發的成本。在那些主流的基於類的語言(比如Java,C++)中都是通過繼承(extend)來實現程式碼複用,同時類繼承引入了一套型別規範。而JavaScript是一門弱型別的語言,從來不需要型別裝換,在JavaScript中變數可以指向任何型別的value(ES6規範中的類也只是語法糖,基於類的繼承本質上也是通過原型實現)。而基於原型的繼承模式可以說提供了更加豐富的程式碼重用模式(後面再詳細講解JavaScript中的常用繼承模式,本文只專注於JavaScript中的原型),一個物件可以直接繼承另外一個物件,從而獲得新的方法和屬性。

適合人群

  • 對JavaScript原型有一定了解,希望深入瞭解原型。
  • 具有JavaScript相關開發經驗
  • 不適合剛接觸JavaScript人員

物件

要理解JavaScript中的原型關係,首先必須弄清楚物件的基本概念。ECMAScript 5.1規範中描述的物件

An object is a collection of properties and has a single prototype object. The prototype may be the null value.

直譯就是:物件是屬性的集合並且擁有一個原型物件。原型可能是null(除非故意設定一個物件的原型為null,否則只有Object.prototype的原型為null)。我們可以簡單把物件想象成hash表。有一種說法是JavaScript中一切都是物件,這種說法並不準確。How is almost everything in Javascript an object?

原型

JavaScript的原型存在著諸多矛盾。它的某些複雜的語法看起來就像那些基於類的語言,這些語法的問題掩蓋了它的原型機制。它不直接讓物件從其他物件繼承,反而插入一個多餘的間接層:通過構建器函式產生物件。——JavaScript語言精粹第5章節(繼承)

雖然可以直接設定一個物件的原型為另外一個物件,從而獲得新的方法和屬性。如下所示:

// Generic prototype for all letters.
let letter = {
  getNumber() {
    return this.number
  }
}

// 在ES6規範中,已經正式把__proto__屬性新增到規範中
// 也可以通過Object.setPrototypeOf(obj, prototype) Object.getPrototypeOf(obj)
// 設定和獲取物件的原型物件

let a = { number: 1, __proto__: letter }
let b = { number: 2, __proto__: letter }
// ...
let z = { number: 26, __proto__: letter }

console.log(
  a.getNumber(), // 1
  b.getNumber(), // 2
  z.getNumber() // 26
)
複製程式碼

物件之間的關係可以用下圖來表示

原型關係圖

但規範主要介紹瞭如何利用建構函式去構建原型關係。所以JavaScript語言精粹的作者Douglas Crockford才會認為:不讓物件直接繼承另外一個物件,而通過中間層(建構函式)去實現顯得有些複雜而且存在一些弊端。呼叫構造器函式忘記new關鍵字,this將不會繫結到一個新物件上。悲劇的是,this將會繫結到全域性物件上。詳情可以閱讀JavaScript語言精粹繼承章節。

下面利用建構函式來實現上述同樣功能

function Letter(number) {
  this.number = number
}

Letter.prototype.getNumber = function() {
  return this.number
}

let a = new Letter(1)
let b = new Letter(2)
let z = new Letter(26)

console.log(
  a.getNumber(), // 1
  b.getNumber(), // 2
  z.getNumber() // 26
)
複製程式碼

其中原型關係可以下圖表示

原型關係

prototype__proto__屬性

我們看下規範中有關原型介紹的核心,更多詳情請閱讀ECMAScript 5.1 4.2.1章節

...Each constructor is a function that has a property named “prototype” that is used to implement prototype-based inheritance and shared properties...

Every object created by a constructor has an implicit reference (called the object’s prototype) to the value of its constructor’s “prototype” property. Furthermore, a prototype may have a non-null implicit reference to its prototype, and so on; this is called the prototype chain...

通過上面的描述我們可以得出以下結論

  1. 建構函式就是一個函式,函式中包含prototype屬性用來實現基於原型的繼承和共享屬性。通過Function.prototype.bind方法構造出來的函式是個例外,它沒有prototype屬性。
  2. 通過被建構函式建立的物件都有一個隱式的引用(__proto__)指向建構函式的prototype屬性。
  3. 建構函式的prototype屬性值同樣也是普通一個物件,它也有一個隱式的引用(non-null)指向它的原型物件。這樣才形成了原型鏈,所以通過原型鏈去查詢屬性值時候,並不會訪問prototype屬性,而是obj.proto.proto...這樣一層一層去尋找。

建構函式說到底本質上也是一個普通函式,只是該函式專門通過new關鍵字來生成物件。所以JavaScript語言無法確定哪個函式是打算用來做建構函式的。所以每個函式都會得到一個prototype屬性,該屬性值是一個包含constructor屬性且constructor屬性值為該函式的物件,如下所示。

建構函式

只有函式才擁有prototype屬性用來構建原型關係,其他物件並沒有。當所有物件擁有__proto__指向其原型物件,JavaScript引擎可通過內部屬性[[prptotype]]獲取物件的原型物件。

關於這兩個屬性聯絡可以用一句話概括:__proto__ is the actual object that is used in the prototype chain to resolve field,methods, etc. prototype is the object that is used to build __proto__ when you create an object with new.

為什麼要設計建構函式

如果你已經瞭解JavaScript原型,那我們可以來講講JavaScript語法為什麼要設計建構函式。

首先來加深一遍概念:JavaScript是一門基於原型繼承的語言,這意味著物件可以直接從其他物件繼承屬性,該語言是無型別的。 然而這種設計是偏離主流方向的,當時主流語言JavaScript,C++都是通過 new Class 的語法來建立物件。JavaScript顯然對它的原型本質缺乏信心,所以它提供了一套和class語法類似的物件構建語法——也就是建構函式。通過instanceof操作符來判斷物件是否屬於某一型別。

instanceof 操作符

MDN介紹了其內部原理

The instanceof operator tests whether the prototype property of a constructor appears anywhere in the prototype chain of an object.

instanceof操作符的語法

object instanceof constructor

簡單來說:instanceof 操作符就是判斷建構函式的prototype屬性值是否能在object物件的原型鏈中被找到,對就是這麼簡單。這樣通過建構函式語法JavaScript引入了類的概念(偽類)。

最後的彩蛋

知乎使用者wang z在其專欄中釋出一張有關JavaScript原型鏈圖,可以說看懂了圖片也就清楚了JavaScript中的原型關係,感興趣的使用者可以直接瀏覽詳情。如下圖所示

原型鏈圖

筆者就可能讀者遇到的問題備註如下:

  1. Object.proto=== Function.prototype。Object本質上是一個built-in的全域性建構函式,也是Function建構函式的例項。所以Object.proto === Function.prototype.
  2. Number,Date,Array等built-in建構函式都和Object建構函式一樣。
  3. Function.prototype本質也是物件,所以其__proto__指向Object.prototype

最後

如果你最後還是沒有弄清楚JavaScript中的原型關係,可以在評論中進行描述我將盡我所能幫你答疑解惑。 或許你也可以看看參考文獻中的引用連結。

參考文獻

  1. JavaScript. The Core: 2nd Edition
  2. ecma-262
  3. JavaScript Prototype in Plain Language
  4. proto VS. prototype in JavaScript
  5. Javascript語言精粹第五章節

相關文章