1 引言
本期精讀的文章是:JS 引擎基礎之 Shapes and Inline Caches
一起了解下 JS 引擎是如何運作的吧!
JS 的運作機制可以分為 AST 分析、引擎執(zhí)行兩個步驟:

JS 源碼通過 parser(分析器)轉化為 AST(抽象語法樹),再經過 interperter(解釋器)解析為 bytecode(字節(jié)碼)。
為了提高運行效率,optimizing compiler(優(yōu)化編輯器)負責生成 optimized code(優(yōu)化后的機器碼)。
本文主要從 AST 之后說起。
2 概述
JS 的解釋器、優(yōu)化器
JS 代碼可能在字節(jié)碼或者優(yōu)化后的機器碼狀態(tài)下執(zhí)行,而生成字節(jié)碼速度很快,而生成機器碼就要慢一些了。

V8 也類似,V8 將 interpreter 稱為 Ignition(點火器),將 optimizing compiler 成為 TurboFan(渦輪風扇發(fā)動機)。

可以理解為將代碼先點火啟動后,逐漸進入渦輪發(fā)動機提速。
代碼先快速解析成可執(zhí)行的字節(jié)碼,在執(zhí)行過程中,利用執(zhí)行中獲取的數(shù)據(jù)(比如執(zhí)行頻率),將一些頻率高的方法,通過優(yōu)化編譯器生成機器碼以提速。

火狐使用的 Mozilla 引擎有一點點不同,使用了兩個優(yōu)化編譯器,先將字節(jié)碼優(yōu)化為部分機器碼,再根據(jù)這個部分優(yōu)化后的代碼運行時拿到的數(shù)據(jù)進行最終優(yōu)化,生成高度優(yōu)化的機器碼,如果優(yōu)化失敗將會回退到部分優(yōu)化的機器碼。
筆者:不同前端引擎對 JS 優(yōu)化方式大同小異,后面會繼續(xù)列舉不同前端引擎在解析器、編譯器部分優(yōu)化的方式。

微軟的 Edge 瀏覽器,使用的 Chakra 引擎,優(yōu)化方式與 Mozilla 很像,區(qū)別是第二個最終優(yōu)化的編譯器同時接收字節(jié)碼和部分優(yōu)化的機器碼產生的數(shù)據(jù),并且在優(yōu)化失敗后回退到第一步字節(jié)碼而不是第二步。

Safari、React Native 使用的 JSC 引擎則更為極端,使用了三個優(yōu)化編譯器,其優(yōu)化是一步步漸進的,優(yōu)化失敗后都會回退到第一步部分優(yōu)化的機器碼。
為什么不同前端引擎會使用不同的優(yōu)化策略呢?這是由于 JS 要么使用解釋器快速執(zhí)行(生成字節(jié)碼),或者優(yōu)化成機器碼后再執(zhí)行,但優(yōu)化消耗時間的并不總是小于字節(jié)碼低效運行損耗的時間,所以有些引擎選擇了多個優(yōu)化編譯器,逐層優(yōu)化,盡可能在解析時間與執(zhí)行效率中找到一個平衡點。
JS 的對象模型
JS 是基于面向對象的,那么 JS 引擎是如何實現(xiàn) JS 對象模型的呢?他們用了哪些技巧加速訪問 JS 對象的屬性?
和解析器、優(yōu)化器一樣,大部分主流 JS 引擎在對象模型實現(xiàn)上也很類似。

ECMAScript 規(guī)范確定了對象模型就是一個以字符串為 key 的字典,除了其值以外,還定義了 Writeable Enumerable Configurable 這些配置,表示這個 key 能否被重寫、遍歷訪問、配置。
雖然規(guī)范定義了 [[]] 雙括號的寫法,那這不會暴露給用戶,暴露給用戶的是 Object.getOwnPropertyDescriptor 這個 API,可以拿到某個屬性的配置。
在 JS 中,數(shù)組是對象的特殊場景,相比對象,數(shù)組擁有特定的下標,根據(jù) ECMAScript 規(guī)范規(guī)定,數(shù)組下標的長度最大為 232?1。同時數(shù)組擁有 length 屬性:

length 只是一個不可枚舉、不可配置的屬性,并且在數(shù)組賦值時,會自動更新數(shù)值:

所以數(shù)組是特殊的對象,結構完全一致。
屬性訪問效率優(yōu)化
屬性訪問是最常見的,所以 JS 引擎必須對屬性訪問做優(yōu)化。
Shapes
JS 編程中,給不同對象相同的 key 名很常見,訪問不同對象的同一個 propertyKey 也很常見:
const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };
function logX(object) {
console.log(object.x);
// ^^^^^^^^
}
logX(object1);
logX(object2);
這時 object1 與 object2 擁有一個相同的 shape。拿擁有 x、y 屬性的對象來看:

如果訪問 object.y,JS 引擎會先找到 key y,再查找 [[value]]。
如果將屬性值也存儲在 JSObject 中,像 object1 object2 就會出現(xiàn)許多冗余數(shù)據(jù),因此引擎單獨存儲 Shape,與真實對象隔離:

這樣具有相同結構的對象可以共享 Shape。所有 JS 引擎都是用這種方式優(yōu)化對象,但并不都稱為 Shape,這里就不詳細羅列了,可以去原文查看在各引擎中 Shape 的別名。
Transition chains 和 Transition trees
如果給一個對象增加了 key,JS 引擎如何生成新的 Shape 呢?
這種 Shape 鏈式創(chuàng)建的過程,稱為 Transition chains:

開始創(chuàng)建空對象時,JSObject 和 Shape 都是空,當為 x 賦值 5 時,在 JSObject 下標 0 的位置添加了 5,并且 Shape 指向了擁有字段 x 的 Shape(x),當賦值 y 為 6 時,在 JSObject 下標 1 的位置添加了 6,并將 Shape 指向了擁有字段 x 和 y 的 Shape(x, y)。
而且可以再優(yōu)化,Shape(x, y) 由于被 Shape(x) 指向,所以可以省略 x 這個屬性:

筆者:當然這里說的主要是優(yōu)化技巧,我們可以看出來,JS 引擎在做架構設計時沒有考慮優(yōu)化問題,而在架構設計完后,再回過頭對時間和空間進行優(yōu)化,這是架構設計的通用思路。
如果沒有連續(xù)的父 Shape,比如分別創(chuàng)建兩個對象:
const object1 = {};
object1.x = 5;
const object2 = {};
object2.y = 6;
這時要通過 Transition trees 來優(yōu)化:

可以看到,兩個 Shape(x) Shape(y) 別分繼承 Shape(empty)。當然也不是任何時候都會創(chuàng)建空 Shape,比如下面的情況:
const object1 = {};
object1.x = 5;
const object2 = { x: 6 };
生成的 Shape 如下圖所示:

可以看到,由于 object2 并不是從空對象開始的,所以并不會從 Shape(empty) 開始繼承。
Inline Caches
大概可以翻譯為“局部緩存”,JS 引擎為了提高對象查找效率,需要在局部做高效緩存。
比如有一個函數(shù) getX,從 o.x 獲取值:
function getX(o) {
return o.x;
}
JSC 引擎 生成的字節(jié)碼結構是這樣的:

get_by_id 指令是獲取 arg1 參數(shù)指向的對象 x,并存儲在 loc0,第二步則返回 loc0。
當執(zhí)行函數(shù) getX({ x: 'a' }) 時,引擎會在 get_by_id 指令中緩存這個對象的 Shape:

這個對象的 Shape 記錄了自己擁有的字段 x 以及其對應的下標 offset:

執(zhí)行 get_by_id 時,引擎從 Shape 查找下標,找到 x,這就是 o.x 的查找過程。但一旦找到,引擎就會將 Shape 保存的 offset 緩存起來,下次開始直接跳過 Shape 這一步:

以后訪問 o.x 時,只要 Shape 相同,引擎直接從 get_by_id 指令中緩存的下標中可以直接命中要查找的值,而這個緩存在指令中的下標就是 Inline Cache.
數(shù)組存儲優(yōu)化
和對象一樣,數(shù)組的存儲也可以被優(yōu)化,而由于數(shù)組的特殊性,不需要為每一項數(shù)據(jù)做完整的配置。
比如這個數(shù)組:
const array = ["#jsconfeu"];
JS 引擎同樣通過 Shape 與數(shù)據(jù)分離的方式存儲:

JS 引擎將數(shù)組的值單獨存儲在 Elements 結構中,而且它們通常都是可讀可配置可枚舉的,所以并不會像對象一樣,為每個元素做配置。
但如果是這種例子:
// 永遠不要這么做
const array = Object.defineProperty([], "0", {
value: "Oh noes!!1",
writable: false,
enumerable: false,
configurable: false
});
JS 引擎會存儲一個 Dictionary Elements 類型,為每個數(shù)組元素做配置:

這樣數(shù)組的優(yōu)化就沒有用了,后續(xù)的賦值都會基于這種比較浪費空間的 Dictionary Elements 結構。所以永遠不要用 Object.defineProperty 操作數(shù)組。
通過對 JS 引擎原理的認識,作者總結了下面兩點代碼中的注意事項:
- 盡量以相同方式初始化對象,因為這樣會生成較少的
Shapes。 - 不要混淆對象的
propertyKey與數(shù)組的下標,雖然都是用類似的結構存儲,但 JS 引擎對數(shù)組下標做了額外優(yōu)化。
3 精讀
這次原理系列解讀是針對 JS 引擎執(zhí)行優(yōu)化這個點的,而網頁渲染流程大致如下:

可以看到 Script 在整個網頁解析鏈路中位置是比較靠前的,JS 解析效率會直接影響網頁的渲染,所以 JS 引擎通過解釋器(parser)和優(yōu)化器(optimizing compiler)盡可能 對 JS 代碼提效。
Shapes
需要特別說明的是,Shapes 并不是 原型鏈,原型鏈是面向開發(fā)者的概念,而 Shapes 是面向 JS 引擎的概念。
比如如下代碼:
const a = {};
const b = {};
const c = {};
顯然對象 a b c 之間是沒有關聯(lián)的,但共享一個 Shapes。
另外理解引擎的概念有助于我們站在語法層面對立面的角度思考問題:在 JS 學習階段,我們會執(zhí)著于思考如下幾種創(chuàng)建對象方式的異同:
const a = {};
const b = new Object();
const c = new f1();
const d = Object.create(null);
比如上面四種情況,我們要理解在什么情況下,用何種方式創(chuàng)建對象性能最優(yōu)。
但站在 JS 引擎優(yōu)化角度去考慮,JS 引擎更希望我們都通過 const a = {} 這種看似最沒有難度的方式創(chuàng)建對象,因為可以共享 Shape。而與其他方式混合使用,可能在邏輯上做到了優(yōu)化,但阻礙了 JS 引擎做自動優(yōu)化,可能會得不償失。
Inline Caches
對象級別的優(yōu)化已經很極致了,工程代碼中也沒有機會幫助 JS 引擎做得更好,值得注意的是不要對數(shù)組使用 Object 對象下的方法,尤其是 defineProperty,因為這會讓 JS 引擎在存儲數(shù)組元素時,使用 Dictionary Elements 結構替代 Elements,而 Elements 結構是共享 PropertyDescriptor 的。
但也有難以避免的情況,比如使用 Object.defineProperty 監(jiān)聽數(shù)組變化時,就不得不破壞 JS 引擎渲染了。
筆者寫 dob 的時候,使用 proxy 監(jiān)聽數(shù)組變化,這并不會改變 Elements 的結構,所以這也從另一個側面證明了使用 proxy 監(jiān)聽對象變化比 Object.defineProperty 更優(yōu),因為 Object.defineProperty 會破huai JS 引擎對數(shù)組做的優(yōu)化。
4 總結
本文主要介紹了 JS 引擎兩個概念: Shapes 與 Inline Caches,通過認識 JS 引擎的優(yōu)化方式,在編程中需要注意以下兩件事:
- 盡量以相同方式初始化對象,因為這樣會生成較少的
Shapes。 - 不要混淆對象的
propertyKey與數(shù)組的下標,雖然都是用類似的結構存儲,但 JS 引擎對數(shù)組下標做了額外優(yōu)化。
5 更多討論
討論地址是:精讀《JS 引擎基礎之 Shapes and Inline Caches》 · Issue #91 · dt-fe/weekly
如果你想參與討論,請點擊這里,每周都有新的主題,周末或周一發(fā)布。