在 V8 引擎中設定原型(prototypes)

繆宇發表於2019-02-28

在 V8 引擎中設定原型(prototypes)

原型(比如 func.prototype )是用來模擬類的實現。它們通常包含類的所有方法,它們的 __proto__ 就是“父類(superclass)”,它們設定好後就不會修改了。

原型在設定時的效能表現對於應用程式的啟動時間至關重要,因為此時通常要建立起整個類的層次結構。

轉換物件形態(Transitioning object shapes)

物件被編碼的主要方式是將隱藏類(描述)物件(內容)分隔開。當一個物件被例項化,和之前來自同一個建構函式的物件使用相同的初始化隱藏類。當屬性被新增,物件從一個隱藏類切換到另一個隱藏類,通常是在所謂的“轉換樹(transition tree)”中重複之前的轉換。舉個例子,比如我們有以下的建構函式:

function C() {
  this.a = 1;
  this.b = 2;
}
複製程式碼

如果我們例項化一個物件 var o = new C(),它首先會使用一個沒有任何屬性的初始化隱藏類 M0。當 a 被新增,我們將從 M0 切換到一個新的隱藏類 M1,M1 描述屬性 a。接著新增 b 的時候,我們再切換到另一個新的隱藏類來描述 ab

如果我們現在例項化第二個物件 var o2 = new C(),它將重複上面的轉換。從 M0 開始,接著 M1,最後是 M2。ab 被新增完成。

這樣做有三個重要的好處:

  1. 儘管建立第一個物件的開銷是很大的,並且要求我們建立所有隱藏的類和轉換,但是建立後續物件是非常快的。
  2. 結果物件比完整的字典要小。我們只需要在物件中儲存值,而不需要儲存關於屬性的資訊(比如名稱)。
  3. 我們現在在內聯快取(inline cache)和優化程式碼時有一個物件形態可以使用,以後訪問類似形態的物件就可以在同一位置找,方便快捷。

這樣有利於頻繁建立相似形態的物件。同樣的事情也發生在物件字面量中:{a:1, b:2} 內部也會有隱藏類 M0,M1 和 M2。

網上有很多相關知識講解,大家可以去看看 Lars Bak 的視訊:

YouTube 視訊見:V8: an open source JavaScript engine

原型(Prototypes)就像特別的雪花

不同於常規建構函式例項化物件,原型是典型的不與其他物件分享形態的物件。這會帶來三點變化:

  1. 通常來講,沒有物件能從快取的轉換(cached transitions)中受益,而且設定轉換樹(transition tree)的開銷也是沒有必要的。
  2. 建立所有轉換隱藏類的記憶體開銷是很大的。事實上,在改變這個之前,我們通常會看到為了一個簡單的原型就要用上一大堆的隱藏類。
  3. 從一個原型中載入實際上並不像在原型鏈中使用那麼常見。如果我們通過原型鏈從一個原型物件中載入,我們將不會分發原型的隱藏類,以及需要用不同的方法檢查它是否有效。

為了優化原型,V8 對其形態的跟蹤不同於常規的轉換物件,我們不需要跟蹤轉換樹(transition tree),而是將隱藏類調整為原型物件,讓它保持高效能。舉個例子,比如執行 delete object.property 會拖慢物件的效能,但如果是原型就不會出現這種情況。因為我們總是會保持它們的可快取性(有些問題我們還在解決中)。

我們也改變了原型的設定。原型包含了2個重要的階段:設定使用。原型在設定階段被編譯成字典物件(dictionary objects)。在那個狀態下儲存原型的速度非常快的,而且不需要進入 C++ 的執行時(跨邊界的花銷是非常巨大的)。與建立一個轉換隱藏類來初始化物件相比,這是一個巨大的進步,因為前者必須進入C++ 執行時才行。

任何對原型的直接訪問,或者通過原型鏈訪問原型,都會將它切換成使用狀態,這樣確保了所有訪問從此時開始是快速的。當處於使用狀態,即使你刪除屬性,在刪除之後我們也會快速的切換回來。

function Foo() {}
// 現在 proto 物件是"設定"模式。
var proto = Foo.prototype;
proto.method1 = function() { ... }
proto.method2 = function() { ... }

var o = new Foo();
// 切換 proto 到"使用"模式。
o.method1();

// 也會切換 proto 到"使用"模式。
proto.method1.call(o);
複製程式碼

它是原型嗎?

為了用上上面說的優化方法,我們需要知道一個物件是否真的會被作為原型使用。由於 JavaScript 的特性,我們很難在編譯階段分析你的程式碼。出於這個原因,我們甚至沒有嘗試在物件建立過程中確定什麼東西最終會成為原型(當然,以後可能會發生變化)。一旦我們看到一個物件賦值給一個原型,我們將對它進行標記。舉個例子來講:

var o = {x:1};
func.prototype = o;
複製程式碼

一開始我們也不知道 o 用作原型,直到賦值給 func.prototype。我像往常那樣花費巨大的開銷來建立物件。一旦像它那樣被賦值,它就被標記成原型,進入設定階段。當你使用它,就會進入使用階段。

如果你像下面這樣寫,我們會在屬性新增前就知道 o 是一個原型。於是它將在新增屬性前進入設定階段,後面的程式碼執行就會快得多:

var o = {};
func.prototype = o;
o.x = 1;
複製程式碼

注意你也可以這樣使用 var o = func.prototype,因為很顯然 func.prototype 在建立時就知道它是一個原型。

怎樣設定原型(prototypes)?

如果你用下面的方式設定原型,我們在方法新增之前很容易就知道 func.prototype 就是一個原型:

// 如果預設的 Object.prototype 為 __proto__,則省略下面這行程式碼。
func.prototype = Object.create(…);
func.prototype.method1 = …
func.prototype.method2 = …
複製程式碼

雖然已經很不錯了,但事實上我們不得不為每個方法都載入一次 func.prototype。儘管最近我們正在進一步優化 func.prototype 的載入,但這種載入是不必要的,效能和記憶體的使用將比直接訪問本地變數訪問更糟糕。

簡而言之,理想的原型設定方法如下:

var proto = func.prototype = Object.create(…);
proto.method1 = …
proto.method2 = …
複製程式碼

感謝 Benedikt Meurer.


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章