一圖介紹 JS 原型鏈的來龍去脈

前言

在面向對象編程中,繼承是非常實用也非常核心的功能,這一切都基於面向類語言中的。然而,javascript和麪向類的語言不同,它沒有類作爲藍圖,javascript中只有對象,但抽象繼承思想又是如此重要,於是聰明絕頂的javascript開發者們就利用javascript原型鏈的特性實現了和類繼承功能一樣的繼承方式。

何爲原型

要想弄清楚原型鏈,我們得先把原型搞清楚,原型可以理解爲是一種設計模式。以下是《你不知道的 javascript》對原型的描述:

javascript中的對象有一個特殊的 [[Prototype]] 內置屬性,其實就是對其他對象的引用。幾乎所有的對象在創建時 [[Prototype]] 都會被賦予一個非空的值。

《javascript 高級程序設計》這樣描述原型:

每個函數都會創建一個prototype屬性,這個屬性是一個對象,包含應該由特定引用類型的實例共享的屬性和方法。實際上,這個對象就是通過調用構造函數創建的對象的原型。使用原型對象的好處是,在它上面定義的屬性和方法都可以被對象實例共享。原來在構造函數中直接賦給對象實例的值,可以直接賦值給它們的原型。

我們通過一段代碼來理解這兩段話:

function Person() { }

// 在Person的原型對象上掛載屬性和方法
Person.prototype.name = '滑稽鴨'
Person.prototype.age = 22
Person.prototype.getName = function () {
  return this.name
}

const hjy = new Person()

console.log('hjy: ',hjy)
console.log('getName: ',hjy.getName())
複製代碼

這是上面這段代碼在chrome控制檯中顯示的結果:

可以看到,我們先是創建了一個空的構造函數Person,然後創建了一個Person的實例hjyhjy本身是沒有掛載任何屬性和方法的,但是它有一個[[Prototype]]內置屬性,這個屬性是個對象,裏面有name、age屬性和getName函數,定睛一看,這玩意兒可不就是上面寫的Person.prototype對象嘛。事實上,Person.prototypehjy[[Prototype]]都指向同一個對象,這個對象對於Person構造函數而言叫做原型對象,對於hjy實例而言叫做原型。下面一張圖直觀地展示上述代碼中構造函數、實例、原型之間的關係:

原型. png

因此,構造函數、原型和實例的關係是這樣的:每個構造函數都有一個原型對象(實例的原型),原型有一個constructor屬性指回構造函數,而實例有一個內部指針指向原型。chrome、firefox、safari瀏覽器環境中這個指針就是__proto__,其他環境下沒有訪問[[Prototype]]的標準方式。

這其中還有更多細節建議大家閱讀《javascript 高級程序設計》

原型鏈

在上述原型的基礎上,如果hjy的原型是另一個類型的實例呢?於是hjy的原型本身又有一個內部指針指向另一個原型,相應的另一個原型也有一個指針指向另一個構造函數。這樣,實例和原型之間形成了一條長長的鏈條,這就是原型鏈。

所有普通的[[Prototype]]都會指向內置的Object.prototype,而Object[[Prototype]]指向null。也就是說所有的普通對象都源於Object.prototype,它包含javascript中許多通用的功能。

在原型鏈中,如果在對象上找不到需要的屬性或者方法,引擎就會繼續在[[Prototype]]指向的原型上查找,同理,如果在後者也沒有找到需要的東西,引擎就會繼續查找它的[[Prototype]]指向的原型。上圖理解一下:

原型鏈 1.png

理解繼承

繼承是面向對象編程的三大特徵之一(封裝、繼承、多態)。多個類中存在相同的屬性和行爲時,將這些內容抽取到單獨一個類中,那麼多個類無需再定義這些屬性和行爲,只需要繼承那個類即可。多個類可以稱爲子類,單獨這個類稱爲父類或者超類,基類等。子類可以直接訪問父類中的非私有的屬性和行爲。

以咱們人類爲例,咱全地球人都是一個腦袋、雙手雙腳,很多基本特徵都是一樣的。但人類也可以細分種類,有黃種人、白種人、黑種人,咱們如果要定義這三種人,無需再說一個腦袋、雙手雙腳之類的共同特徵,黃種人就是在人類的基礎上將皮膚變爲黃色,白種人皮膚爲白色,黑種人爲黑色,如果有其他特徵就再新增即可,例如藍眼睛、黃頭髮等等。

renleiclass.png

如果用代碼封裝,咱們就可以將人類定義爲基類或者超類,擁有腦袋、手、足等屬性,說話、走路等行爲。黃種人、白種人、黑種人爲子類,自動複製父類的屬性和行爲到自身,然後在此基礎上新增或者重寫某些屬性和行爲,例如黃種人擁有黃皮膚、黑頭髮。這就是繼承的思想。

js 中的繼承(原型繼承)

在其他面向類語言中,繼承意味着複製操作,子類是實實在在地將父類的屬性和方法複製了過來,但javascript中的繼承不是這樣的。根據原型的特性,js中繼承的本質是一種委託機制,對象可以將需要的屬性和方法委託給原型,需要用的時候就去原型上拿,這樣多個對象就可以共享一個原型上的屬性和方法,這個過程中是沒有複製操作的。

javascript中的繼承主要還是依靠於原型鏈,原型處於原型鏈中時即可以是某個對象的原型也可以是另一個原型的實例,這樣就能形成原型之間的繼承關係。

然而,依託原型鏈的繼承方式是有很多弊病的,我們需要輔以各種操作來消除這些缺點,在這個探索的過程中,出現了很多通過改造原型鏈繼承而實現的繼承方式。

js 六種繼承方式

原型鏈繼承

直接利用原型鏈特徵實現的繼承,讓構造函數的prototype指向另一個構造函數的實例。

function Person() {
  this.head = 1
  this.hand = 2
}

function YellowRace() { }
YellowRace.prototype = new Person()

const hjy = new YellowRace()

console.log(hjy.head) // 1
console.log(hjy.hand) // 2
複製代碼

上述代碼中的Person構造函數YellowRace構造函數hjy實例之間的關係如下圖:

根據原型鏈的特性,當我們查找hjy實例的headhand屬性時,由於hjy本身並沒有這兩個屬性,引擎就會去查找hjy的原型,還是沒有,繼續查找hjy原型的原型,也就是Person原型對象,結果就找到了。就這樣,YellowRacePerson之間通過原型鏈實現了繼承關係。

但這種繼承是有問題的:

  1. 創建hjy實例時不能傳參,也就是YellowRace構造函數本身不接受參數。

  2. 當原型上的屬性是引用數據類型時,所有實例都會共享這個屬性,即某個實例對這個屬性重寫會影響其他實例。

針對第二點,我們通過一段代碼來看一下:

function Person() {
  this.colors = ['white''yellow''black']
}

function YellowRace() { }
YellowRace.prototype = new Person()

const hjy = new YellowRace()
hjy.colors.push('green')
 console.log(hjy.colors) // ['white''yellow''black''green']

const laowang = new YellowRace()
console.log(laowang.colors) // ['white''yellow''black''green']
複製代碼

可以看到,hjy只是想給自己的生活增添一點綠色,但是卻被laowang給享受到了,這肯定不是我們想看到的結果。

爲了解決不能傳參以及引用類型屬性共享的問題,一種叫盜用構造函數的實現繼承的技術應運而生。

盜用構造函數

盜用構造函數也叫作 “對象僞裝” 或者“經典繼承”,原理就是通過在子類中調用父類構造函數實現上下文的綁定。

function Person(eyes) {
  this.eyes = eyes
  this.colors = ['white''yellow''black']
}

function YellowRace() {
  Person.call(this, 'black') // 調用構造函數並傳參
}

const hjy = new YellowRace()
hjy.colors.push('green')
console.log(hjy.colors) // ['white''yellow''black''green']
console.log(hjy.eyes) // black

const laowang = new YellowRace()
console.log(laowang.colors) // ['white''yellow''black']
console.log(laowang.eyes) // black
複製代碼

上述代碼中,YellowRace在內部使用call調用構造函數,這樣在創建YellowRace的實例時,Person就會在YellowRace實例的上下文中執行,於是每個YellowRace實例都會擁有自己的colors屬性,而且這個過程是可以傳遞參數的,Person.call()接受的參數最終會賦給YellowRace的實例。它們之間的關係如下圖所示:

daoyonggouzao.png

雖然盜用構造函數解決了原型鏈繼承的兩大問題,但是它也有自己的缺點:

  1. 必須在構造函數中定義方法,通過盜用構造函數繼承的方法本質上都變成了實例自己的方法,不是公共的方法,因此失去了複用性。

  2. 子類不能訪問父類原型上定義的方法,因此所有類型只能使用構造函數模式,原因如上圖所示,YellowRace構造函數、hjylaowang實例都沒有和Person的原型對象產生聯繫。

針對第二點,我們看一段代碼:

function Person(eyes) {
  this.eyes = eyes
  this.getEyes = function () {
    return this.eyes
  }
}

Person.prototype.ReturnEyes = function () {
  return this.eyes
}

function YellowRace() {
  Person.call(this, 'black')
}

const hjy = new YellowRace()
console.log(hjy.getEyes()) // black
console.log(hjy.ReturnEyes()) // TypeError: hjy.ReturnEyes is not a function
複製代碼

可以看到,hjy實例能繼承Person構造函數內部的方法getEyes(),對於Person原型對象上的方法,hjy是訪問不到的。

組合繼承

原型鏈繼承和盜用構造函數繼承都有各自的缺點,而組合繼承綜合了前兩者的優點,取其精華去其糟粕,得到一種可以將方法定義在原型上以實現重用又可以讓每個實例擁有自己的屬性的繼承方案。

組合繼承的原理就是先通過盜用構造函數實現上下文綁定和傳參,然後再使用原型鏈繼承的手段將子構造函數的prototype指向父構造函數的實例,代碼如下:

function Person(eyes) {
  this.eyes = eyes
  this.colors = ['white''yellow''black']
}

Person.prototype.getEyes = function () {
  return this.eyes
}

function YellowRace() {
  Person.call(this, 'black') // 調用構造函數並傳參
}
YellowRace.prototype = new Person() // 再次調用構造函數

const hjy = new YellowRace()
hjy.colors.push('green')

const laowang = new YellowRace()

console.log(hjy.colors) // ['white''yellow''black''green']
console.log(laowang.colors) // ['white''yellow''black']
console.log(hjy.getEyes()) // black
複製代碼

hjy終於鬆了口氣,自己終於能獨享生活的一點 “綠”,再也不會被老王分享去了。

此時Person構造函數、YellowRace構造函數、hjylaowang實例之間的關係如下圖:

組合繼承. png

相較於盜用構造函數繼承,組合繼承額外的將YellowRace的原型對象(同時也是hjylaowang實例的原型)指向了Person的原型對象,這樣就集合了原型鏈繼承和盜用構造函數繼承的優點。

但組合繼承還是有一個小小的缺點,那就是在實現的過程中調用了兩次Person構造函數,有一定程度上的性能浪費。這個缺點在最後的寄生式組合繼承可以改善。

原型式繼承

2006 年,道格拉斯. 克羅克福德寫了一篇文章《Javascript 中的原型式繼承》。這片文章介紹了一種不涉及嚴格意義上構造函數的繼承方法。他的出發點是即使不自定義類型也可以通過原型實現對象之間的信息共享。

文章最終給出了一個函數:

const object = function (o) {
  function F() { }
  F.prototype = o
  return new F()
}
複製代碼

其實不難看出,這個函數將原型鏈繼承的核心代碼封裝成了一個函數,但這個函數有了不同的適用場景:如果你有一個已知的對象,想在它的基礎上再創建一個新對象,那麼你只需要把已知對象傳給object函數即可。

const object = function (o) {
  function F() { }
  F.prototype = o
  return new F()
}

const hjy = {
  eyes: 'black',
  colors: ['white''yellow''black']
}

const laowang = object(hjy)
console.log(laowang.eyes) // black
console.log(laowang.colors) // ['white''yellow''black']
複製代碼

ES5新增了一個方法Object.create()將原型式繼承規範化了。相比於上述的object()方法,Object.create()可以接受兩個參數,第一個參數是作爲新對象原型的對象,第二個參數也是個對象,裏面放入需要給新對象增加的屬性(可選)。第二個參數與Object.defineProperties()方法的第二個參數是一樣的,每個新增的屬性都通過自己的屬性描述符來描述,以這種方式添加的屬性會遮蔽原型上的同名屬性。當Object.create()只傳入第一個參數時,功效與上述的object()方法是相同的。

const hjy = {
  eyes: 'black',
  colors: ['white''yellow''black']
}

const laowang = Object.create(hjy, {
  name: {
    value: '老王',
    writable: false,
    enumerable: true,
    configurable: true
  },
  age: {
    value: '32',
    writable: true,
    enumerable: true,
    configurable: false
  }
})
console.log(laowang.eyes) // black
console.log(laowang.colors) // ['white''yellow''black']
console.log(laowang.name) // 老王
console.log(laowang.age) // 32
複製代碼

稍微需要注意的是,object.create()通過第二個參數新增的屬性是直接掛載到新建對象本身,而不是掛載在它的原型上。原型式繼承非常適合不需要單獨創建構造函數,但仍然需要在對象間共享信息的場合。

上述代碼中各個對象之間的關係仍然可以用一張圖展示:

原型式繼承. png

這種關係和原型鏈繼承中原型與實例之間的關係基本是一致的,不過上圖中的F構造函數是一箇中間函數,在object.create()執行完後它就隨着函數作用域一起被回收了。那最後hjyconstructor會指向何處呢?下面分別是瀏覽器和node環境下的打印結果:

查閱資料得知chrome打印的結果是它內置的,不是javascript語言標準。具體是個啥玩意兒我也不知道了🤣。

既然原型式繼承和原型鏈繼承的本質基本一致,那麼原型式繼承也有一樣的缺點:

  1. 不能傳參,使用手寫的object()不能傳,但使用Object.create()是可以傳參的。

  2. 原對象中的引用類型的屬性會被新對象共享。

寄生式繼承

寄生式繼承與原型式繼承很接近,它的思想就是在原型式繼承的基礎上以某種方式增強對象,然後返回這個對象。

function inherit(o) {
  let clone = Object.create(o)
  clone.sayHi = function () { // 增強對象
    console.log('Hi')
  }
  return clone
}

const hjy = {
  eyes: 'black',
  colors: ['white''yellow''black']
}

const laowang = inherit(hjy)

console.log(laowang.eyes) // black
console.log(laowang.colors) // ['white''yellow''black']
laowang.sayHi() // Hi
複製代碼

這是一個最簡單的寄生式繼承案例,這個例子基於hjy對象返回了一個新的對象laowanglaowang擁有hjy的所有屬性和方法,還有一個新方法sayHai()

可能有的小夥伴就會問了,寄生式繼承就只是比原型式繼承多掛載一個方法嗎?這也太low了吧。其實沒那麼簡單,這裏只是演示一下掛載一個新的方法來增強新對象,但我們還可以用別的方法呀,比如改變原型的constructor指向,在下面的寄生式組合繼承中就會用到。

寄生式組合繼承

寄生式組合繼承通過盜用構造函數繼承屬性,但使用混合式原型鏈繼承方法。基本思路就是使用寄生式繼承來繼承父類的原型對象,然後將返回的新對象賦值給子類的原型對象。

首先實現寄生式繼承的核心邏輯:

function inherit(Father, Son) {
  const prototype = Object.create(Father.prototype) // 獲取父類原型對象副本
  prototype.constructor = Son // 將獲取的副本的constructor指向子類,以此增強副本原型對象
  Son.prototype = prototype // 將子類的原型對象指向副本原型對象
}
複製代碼

這裏沒有將新建的對象返回出來,而是賦值給了子類的原型對象。

接下來就是改造組合式繼承,將第二次調用構造函數的邏輯替換爲寄生式繼承:

function Person(eyes) {
  this.eyes = eyes
  this.colors = ['white''yellow''black']
}

Person.prototype.getEyes = function () {
  return this.eyes
}

function YellowRace() {
  Person.call(this, 'black') // 調用構造函數並傳參
}

inherit(YellowRace, Person) // 寄生式繼承,不用第二次調用構造函數

const hjy = new YellowRace()
hjy.colors.push('green')

const laowang = new YellowRace()

console.log(hjy.colors)
console.log(laowang.colors)
console.log(hjy.getEyes())
複製代碼

上述寄生式組合繼承只調用了一次Person造函數,避免了在Person.prototype上面創建不必要、多餘的屬性。於此同時,原型鏈依然保持不變,效率非常之高效。

如圖,寄生組合式繼承與組合式繼承中的原型鏈關係是一樣的:

組合繼承. png

判斷構造函數與實例關係

原型與實例的關係可以用兩種方式來確定:instanceof操作符和isPrototypeOf()方法。

instanceof

instanceof操作符左側是一個普通對象,右側是一個函數。

o instanceof Foo爲例,instanceof關鍵字做的事情是:判斷o的原型鏈上是否有Foo.prototype指向的對象。

function Perosn(name) {
  this.name = name
}

const hjy = new Perosn('滑稽鴨')

const laowang = {
  name: '老王'
}

console.log(hjy instanceof Perosn) // true
console.log(laowang instanceof Perosn) // false
複製代碼

根據instanceof的特性,我們可以實現一個自己instanceof,思路就是遞歸獲取左側對象的原型,判斷其是否和右側的原型對象相等,這裏使用Object.getPrototypeOf()獲取原型:

const myInstanceof = (left, right) ={
  // 邊界判斷
  if (typeof left !== 'object' && typeof left !== 'function' || left === null) return false
  let proto = Object.getPrototypeOf(left)   // 獲取左側對象的原型
  while (proto !== right.prototype) {  // 找到了就終止循環
    if (proto === null) return false     // 找不到返回 false
    proto = Object.getPrototypeOf(proto)   // 沿着原型鏈繼續獲取原型
  }
  return true
}
複製代碼

isPrototypeOf()

isPrototypeOf()不關心構造函數,它只需要一個可以用來判斷的對象就行。以Foo.prototype.isPrototypeOf(o)爲例,isPrototypeOf()做的事情是:判斷在a的原型鏈中是否出現過Foo.prototype

function Perosn(name) {
  this.name = name
}

const hjy = new Perosn('滑稽鴨')

const laowang = {
  name: '老王'
}

console.log(Perosn.prototype.isPrototypeOf(hjy))
console.log(Perosn.prototype.isPrototypeOf(laowang))
複製代碼

new 關鍵字

在實現各種繼承方式的過程中,經常會用到new關鍵字,那麼new關鍵字起到的作用是什麼呢?

簡單來說,new關鍵字就是綁定了實例與原型的關係,並且在實例的的上下文中調用構造函數。下面就是一個最簡版的new的實現:

const myNew = function (Fn, ...args) {
  const o = {}
  o.__proto__ = Fn.prototype
  Fn.apply(o, args)
  return o
}

function Person(name, age) {
  this.name = name
  this.age = age
  this.getName = function () {
    return this.name
  }
}

const hjy = myNew(Person, '滑稽鴨', 22)
console.log(hjy.name)
console.log(hjy.age)
console.log(hjy.getName())
複製代碼

實際上,真正的new關鍵字會做如下幾件事情:

  1. 創建一個細新的javaScript對象(即 {} )

  2. 爲步驟 1 新創建的對象添加屬性proto ,將該屬性鏈接至構造函數的原型對象

  3. this指向這個新對象

  4. 執行構造函數內部的代碼(例如給新對象添加屬性)

  5. 如果構造函數返回非空對象,則返回該對象,否則返回剛創建的新對象。

代碼如下:

const myNew = function (Fn, ...args) {
  const o = {}
  o.__proto__ = Fn.prototype
  const res = Fn.apply(o, args)
  if (res && typeof res === 'object' || typeof res === 'function') {
    return res
  }
  return o
}
複製代碼

有些小夥伴可能會疑惑最後這個判斷是爲了什麼?因爲語言的標準肯定是嚴格的,需要考慮各種情況下的處理。比如const res = Fn.apply(o, args)這一步,如果構造函數有返回值,並且這個返回值是對象或者函數,那麼new的結果就應該取這個返回值,所以纔有了這一層判斷。

結語

功力不夠,時間來湊。本人還是一個 22 屆即將畢業的非科班本科生,剛開始寫的時候感覺無從下筆,每天只能磨一點點,畫圖也花了不少時間,不過這也是成長的一部分,之前對原型鏈的概念一直模模糊糊,這個寫作探索的過程中對知識的鞏固理解非常有幫助。如果看完此文對你有點幫助,還請手下留贊🤣,感謝感謝。

借鑑文章

詳解 JS 原型鏈與繼承 | louis blog (louiszhai.github.io)[1]

《你不知道的 javascript》

《javascript 高級程序設計》

關於本文

作者:滑稽鴨

https://juejin.cn/post/7075354546096046087

高級前端進階 網易 & 螞蟻前端,專注前端進階領域,已幫助無數前端跳槽漲薪。每日一題「Daily-Interview-Question」 Github 收穫近 25000 顆小星星,各公司面試官都在使用。接下來帶你走進高級前端的世界,在進階的路上,共勉!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/AiE8-bozZUPn3lamwAg_bg