JavaScript 原型精髓 #一篇就夠系列

Linesh發表於2018-10-22

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

很多小夥伴表示不明白 JavaScript 的繼承,說是原型鏈,看起來又像類,究竟是原型還是類?各種 prototype__proto__constructor 內部變數更是傻傻搞不清楚。其實,只要明白繼承的本質就很能理解,繼承是為了程式碼複用。複用並不一定得通過類,JS 就採用了一種輕量簡明的原型方案來實現。Java/C++ 等強型別語言中有類和物件的區別,但 JS 只有物件。它的原型也是物件。只要你完全拋開物件導向的繼承思路來看 JS 的原型繼承,你會發現它輕便但強大。

原文連結:blog.linesh.tw/#/post/2018…

Github:github.com/linesh-simp…

目錄

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

繼承方案的設計要求

前面我們講,繼承的本質是為了更好地實現程式碼複用。再仔細思考,可以發現,這裡的「程式碼」指的一定是「資料+行為」的複用,也就是把一組資料和資料相關的行為進行封裝。為什麼呢?因為,如果只是複用行為,那麼使用函式就足夠了;而如果只是複用資料,這使用 JavaScript 物件就可以了:

const parent = {
  some: 'data',
}
const child = {
  ...parent,
  uniq: 'data',
}
複製程式碼

因此,只有資料+行為(已經類似於一個「物件」的概念)的封裝,才是繼承技術所必須出現的地方。為了滿足這樣的程式碼複用,一個繼承體系的設計需要支援什麼需求呢?

  • 儲存公用的資料和函式
  • 覆蓋被繼承物件資料或函式的能力
  • 向上查詢/呼叫被繼承物件函式的資料或函式的能力
  • 優雅的語法(API)
  • 增加新成員的能力
  • 支援私有資料

「支援私有資料」,這個基本所有方案都沒實現,此階段我們可以不用糾結;而「增加新成員的能力」,基本所有的方案都能做到,也不再贅述,主要來看前四點。

被複用的物件: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() 即可。兼具了功能的實現和語法的優雅。

最後寫出來的程式碼會是這樣:

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__][mdn __proto__],它並不是規範的一部分,因此並不推薦在你的業務程式碼中直接使用 __proto__ 這個變數。

JavaScript Prototypal Inheritance

從上圖可以清楚看到,prototype 是用來儲存型別公共方法的一個物件(正因此每個型別有它基本的方法),而 __proto__ 是用來實現向上查詢的一個引用。任何物件都會有 __proto__Object.prototype__proto__ 是 null,也即是原型鏈的終點。

建構函式又是個啥玩意兒?

再加入 constructor 這個東西,它與 prototype__proto__ 是什麼關係?這個地方,說複雜就很複雜了,讓我們儘量把它說簡單一些。開始之前,我們需要查閱一下[語言規範][ecmascript 2015(es6) specification],看一些基本的定義:

這裡說明了什麼呢?說明了建構函式是函式,它比普通函式多一個 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。至此這兩條契合到了一起。

總結

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

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

是完全的原型繼承。儘管用了 new 關鍵字,但其實只是個語法糖,跟類沒有關係。JavaScript 沒有類。它與類繼承完全不同,只是長得像。好比雷鋒和雷峰塔的關係。

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

prototype 是個物件,只有函式上有。它是用來儲存物件的屬性(資料和方法)的地方,是實現 JavaScript 原型繼承的基礎。

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

__proto__ 是個指向 prototype 的引用。用以輔助原型繼承中向上查詢的實現。雖然它得到了所有瀏覽器的支援,但並不是規範所推薦的做法。嚴謹地說,它是一個指向 [[Prototype]] 的引用。

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

是物件上一個指向建構函式的引用。用來輔助 instanceof 等關鍵字的實現。

參考

相關文章