JavaScript 原型繼承之精髓

一篇文章讓你搞清楚 JavaScript 繼承的本質、prototype__proto__constructor 都是什麼。javascript

不少小夥伴表示不明白 JavaScript 的繼承,說是原型鏈,看起來又像類,到底是原型仍是類?各類 prototype__proto__constructor 內部變量更是傻傻搞不清楚。其實,只要明白繼承的本質就很能理解,繼承是爲了代碼複用。複用並不必定得經過類,JS 就採用了一種輕量簡明的原型方案來實現。Java/C++ 等強類型語言中有類和對象的區別,但 JS 只有對象。它的原型也是對象。只要你徹底拋開面向對象的繼承思路來看 JS 的原型繼承,你會發現它輕便但強大。html

目錄

  • 繼承方案的設計要求
  • 被複用的對象:prototype
  • 優雅的 API:ES6 class
  • 簡明的向上查找機制:__proto__
  • 構造函數又是個啥玩意兒
  • 雙鏈合璧:終極全圖
  • 總結
  • 參考

繼承方案的設計要求

前面咱們講,繼承的本質是爲了更好地實現代碼複用。再仔細思考,能夠發現,這裏的「代碼」指的必定是「數據+行爲」的複用,也就是把一組數據和數據相關的行爲進行封裝。爲何呢?由於,若是隻是複用行爲,那麼使用函數就足夠了;而若是隻是複用數據,這使用 JavaScript 對象就能夠了:java

const parent = {
  some: 'data',
}
const child = {
  ...parent,
  uniq: 'data',
}

所以,只有數據+行爲(已經相似於一個「對象」的概念)的封裝,纔是繼承技術所必須出現的地方。爲了知足這樣的代碼複用,一個繼承體系的設計須要支持什麼需求呢?git

  • 存儲公用的數據和函數
  • 覆蓋被繼承對象數據或函數的能力
  • 向上查找/調用被繼承對象函數的數據或函數的能力
  • 優雅的語法(API)
  • 增長新成員的能力
  • 支持私有數據

「支持私有數據」,這個基本全部方案都沒實現,此階段咱們能夠不用糾結;而「增長新成員的能力」,基本全部的方案都能作到,也再也不贅述,主要來看前四點。github

被複用的對象:prototype

JavaScript 的繼承有多種實現方式,具體有哪些,推薦讀者可閱讀:[JavaScript 語言精粹][]一書 和 這篇文章。這裏,咱們直接看一版比較優秀的實現:瀏覽器

function Animal(name) {
  this.name = name
  this.getName = function() {
    return this.name
  }
}

function Cat(name, age) {
  Animal.call(this, name)
  this.age = age || 1
  this.meow = function() {
    return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
  }
}

const cat = new Cat('Lily', 2)
console.log(cat.meow()) // 'Lilyeowww~~~~~, I'm 2 year(s) old'

這個方案,具有增添新成員的能力、調用被繼承對象函數的能力等。一個比較重大的缺陷是:對象的全部方法 getName meow,都會隨每一個實例生成一份新的拷貝。這顯然不是優秀的設計方案,咱們指望的結果是,繼承自同一對象的子對象,其全部的方法都共享自同一個函數實例。數據結構

怎麼辦呢?想法也很簡單,就是把它們放到同一個地方去,而且還要跟這個「對象」關聯起來。如此一想,用來生成這個「對象」的函數自己就是很好的地方。咱們能夠把它放在函數的任一一個變量上,好比:函數

Animal.functions.getName = function() {
  return this.name
}
Cat.functions.meow = function() {
  return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
}

但這樣調用起來,你就要寫 animal.functions.getName(),並不方便。不要怕,JavaScript 這門語言自己已經幫你內置了這樣的支持。它內部所用來存儲公共函數的變量,就是你熟知的 prototype。當你調用對象上的方法時(如 cat.getName()),它會自動去 Cat.prototype 上去幫你找 getName 函數,而你只須要寫 cat.getName() 便可。兼具了功能的實現和語法的優雅。post

最後寫出來的代碼會是這樣:this

function Animal(name) {
  this.name = name
}
Animal.prototype.getName = function() {
  return this.name
}

function Cat(name, age) {
  Animal.call(this, name)
  this.age = age || 1
}
Cat.prototype = Object.create(Animal.prototype, { constructor: Cat })
Cat.prototype.meow = function() {
  return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
}

請注意,只有函數纔有 prototype 屬性,它是用來作原型繼承的必需品。

優雅的 API:ES6 class

然鵝,上面這個寫法仍然並不優雅。在何處呢?一個是 prototype 這種暴露語言實現機制的關鍵詞;一個是要命的是,這個函數內部的 this,依靠的是做爲使用者的你記得使用 new 操做符去調用它才能獲得正確的初始化。可是這裏沒有任何線索告訴你,應該使用 new 去調用這個函數,一旦你忘記了,也不會有任何編譯期和運行期的錯誤信息。這樣的語言特性,與其說是一個「繼承方案」,不如說是一個 bug,一個不該出現的設計失誤。

而這兩個問題,在 ES6 提供的 class 關鍵詞下,已經獲得了很是妥善的解決,儘管它叫一個 class,但本質上實際上是經過 prototype 實現的:

class Animal {
  constructor(name) {
    this.name = name
  }

  getName() {
    return this.name
  }
}

class Cat extends Animal {
  constructor(name, age) {
    super(name)
    this.age = age || 1
  }

  meow() {
    return `${this.getName()}eowww~~~~~, I'm ${this.age} year(s) old`
  }
}
  • 若是你沒有使用 new 操做符,編譯器和運行時都會直接報錯。爲何呢,咱們將在[下一篇文章][]講解
  • extends 關鍵字,會使解釋器直接在底下完成基於原型的繼承功能

如今,咱們已經看到了一套比較完美的繼承 API,也看到其底下使用 prototype 存儲公共變量的地點和原理。接下來,咱們要解決另一個問題:prototype 有了,實例對象應該如何訪問到它呢?這就關係到 JavaScript 的向上查找機制了。

簡明的向上查找機制:__proto__

function Animal(name) {
  this.name = name
}
Animal.prototype.say = function() {
  return this.name
}
const cat = new Animal('kitty')

console.log(cat) // Animal { name: 'kitty' }
cat.hasOwnProperty('say') // false

看上面 👆 一個最簡單的例子。打出來的 cat 對象自己並無 say 方法。那麼,被實例化的 cat 對象自己,是怎樣向上查找到 Animal.prototype 上的 say 方法的呢?若是你是 JavaScript 引擎的設計者,你會怎樣來實現呢?

我拍腦殼這麼一想,有幾種方案:

  • Animal 中初始化實例對象 cat 時,順便存取一個指向 Animal.prototype 的引用
  • Animal 中初始化實例對象時,記錄其「類型」(也便是 Animal
// 方案1
function Animal(name) {
  this.name = name
  // 如下代碼由引擎自動加入
  this.__prototype__ = Animal.prototype
}

const cat = new Animal('kitty')
cat.say() // -> cat.__prototype__.say()

// 方案2
function Animal(name) {
  this.name = name
  // 如下代碼由引擎自動加入
  this.__type__ = Animal
}

const cat = new Animal('kitty')
cat.say() // -> cat.__type__.prototype.say()

究其實質,其實就是:實例對象須要一個指向其函數的引用(變量),以拿到這個公共原型 prototype 來實現繼承方案的向上查找能力。讀者若是有其餘方案,不妨留言討論。

無獨有偶,這兩種方案,在 JavaScript 中都有實現,只不過變量的命名與咱們的取法有所差別:第一種方案中,實際的變量名叫 __proto__ 而不是 __prototype__;第二種方案中,實際的變量名叫 constructor,不叫俗氣的 __type__。實際上,用來實現繼承、作向上查找的這個引用,正是 __proto__;至於 constructor,則另有他用。不過要注意的是,儘管基本全部瀏覽器都支持 __proto__,它並非規範的一部分,所以並不推薦在你的業務代碼中直接使用 __proto__ 這個變量。

JavaScript Prototypal Inheritance

從上圖能夠清楚看到,prototype 是用來存儲類型公共方法的一個對象(正所以每一個類型有它基本的方法),而 __proto__ 是用來實現向上查找的一個引用。任何對象都會有 __proto__Object.prototype__proto__ 是 null,也便是原型鏈的終點。

構造函數又是個啥玩意兒?

再加入 constructor 這個東西,它與 prototype__proto__ 是什麼關係?這個地方,說複雜就很複雜了,讓咱們儘可能把它說簡單一些。開始以前,咱們須要查閱一下語言規範,看一些基本的定義:

這裏說明了什麼呢?說明了構造函數是函數,它比普通函數多一個 prototype 屬性;而函數是對象,對象都有一個原型對象 __proto__。這個東西有什麼做用呢?

上節咱們深挖了用於繼承的原型鏈,它連接的是原型對象。而對象是經過構造函數生成的,也就是說,普通對象、原型對象、函數對象都將有它們的構造函數,這將爲咱們引出另外一條鏈——

JavaScript Constructor Chain

在 JavaScript 中,誰是誰的構造函數,是經過 constructor 來標識的。正常來說,普通對象(如圖中的 cat{ name: 'Lin' } 對象)是沒有 constructor 屬性的,它是從原型上繼承而來;而圖中粉紅色的部分便是函數對象(如 Cat Animal Object 等),它們的原型對象是 Function.prototype,這沒毛病。關鍵是,它們是函數對象,對象就有構造函數,那麼函數的構造函數是啥呢?是 Function。那麼問題又來了,Function 也是函數,它的構造函數是誰呢?是它本身Function.constructor === Function。由此,Function 便是構造函數鏈的終結。

上面咱們提到,constructor 也能夠用來實現原型鏈的向上查找,而後它卻別有他用。有個啥用呢?通常認爲,它是用以支撐 instanceof 關鍵字實現的數據結構。

雙鏈合璧:終極全圖

好了,是時候進入最燒腦的部分了。前面咱們講了兩條鏈:

  • 原型鏈。它用來實現原型繼承,最上層是 Object.prototype,終結於 null,沒有循環
  • 構造函數鏈。它用來代表構造關係,最上層循環終結於 Function

把這兩條鏈結合到一塊兒,你就會看到一條雙螺旋 DNA這幾張你常常看到卻又看不懂的圖:

constructor/prototype/proto

constructor/prototype/proto

圖都是引用自其它文章,點擊圖片可跳轉到原文。其中,第一篇文章 [一張圖理解 JS 的原型][] 是我見過解析得最詳細的,本文的不少靈感也來自這篇文章。

理解了上面兩條鏈之後,這兩個全圖實際上就不難理解了。分享一下,怎麼來讀懂這個圖:

  • 首先看構造函數鏈。全部的普通對象,constructor 都會指向它們的構造函數;而構造函數也是對象,它們最終會一級一級上溯到 Function 這個構造函數。Function 的構造函數是它本身,也即此鏈的終結;
  • FunctionprototypeFunction.prototype,它是個普通的原型對象;
  • 其次看原型鏈。全部的普通對象,__proto__ 都會指向其構造函數的原型對象 [Class].prototype;而全部原型對象,包括構造函數鏈的終點 Function.prototype,都會最終上溯到 Object.prototype,終結於 null。

也便是說,構造函數鏈的終點 Function,其原型又融入到了原型鏈中:Function.prototype -> Object.prototype -> null,最終抵達原型鏈的終點 null。至此這兩條契合到了一塊兒。

總結下來,能夠歸納成這幾句話:

  • JS 世界的變量除了普通類型外都是對象,包括函數也是對象
  • 全部對象都必須由函數生成,包括普通對象、原型對象及函數對象
  • 全部函數最終都生成自 Function,包括 Function 本身
  • 全部對象最終都繼承自 Object.prototype,包括 Function.prototype,終止於 null

這裏還有最後一個所謂「雞生蛋仍是蛋生🐔」的問題:是先有 Object.prorotype,仍是先有 Function?若是先有前者,那麼此時 Function 還不在,這個對象又是由誰建立呢?若是先有後者,那麼 Function 也是個對象,它的原型 Function.prototype.__proto__ 從哪去繼承呢?這個問題,看似無解。但從 這篇文章:從__proto__和prototype來深刻理解JS對象和原型鏈 中,咱們發現了一個合理的解釋,那就是:

Object.prototype 是個神之對象。它不禁 Function 這個函數構造產生。

證據以下:

Object.prototype instanceof Object                // false
Object.prototype instanceof Function              // false
Object.prototype.__proto__ === Function.prototype // false

JS 對象世界的構造次序應該是:Object.prototype -> Function.prototype -> Function -> Object -> ...

總結

講到這裏,我想關於 JavaScript 繼承中的一些基本問題能夠解釋清楚了:

JavaScript 繼承是類繼承仍是原型繼承?不是使用了 new 關鍵字麼,應該跟類有關係吧?

是徹底的原型繼承。儘管用了 new 關鍵字,但其實只是個語法糖,跟類沒有關係。JavaScript 沒有類。它與類繼承徹底不一樣,只是長得像。比如雷鋒和雷峯塔的關係。

prototype 是什麼東西?用來幹啥?

prototype 是個對象,只有函數上有。它是用來存儲對象的屬性(數據和方法)的地方,是實現 JavaScript 原型繼承的基礎。

__proto__ 是什麼東西?用來幹啥?

__proto__ 是個指向 prototype 的引用。用以輔助原型繼承中向上查找的實現。雖然它獲得了全部瀏覽器的支持,但並非規範所推薦的作法。嚴謹地說,它是一個指向 [[Prototype]] 的引用。

constructor 是什麼東西?用來幹啥?

是對象上一個指向構造函數的引用。用來輔助 instanceof 等關鍵字的實現。

🐔生蛋仍是蛋生🐔?

神生雞,雞生蛋。

參考

相關文章
相關標籤/搜索