React.js 以高效的UI 渲染著稱,其中一個很重要的原因是它維護了一個虛擬DOM,用戶可以直接在虛擬DOM 上進行操作,React.js 用diff 演算法得出需要對瀏覽器DOM 進行的最小操作,這樣就避免了手動大量修改DOM 的時候造成的效能損失。等等,明明是在中間加了一層,為什麼結果反而變快了呢? React.js 的核心思想是認為 DOM 操作是緩慢的,因此可以需要最小化 DOM 操作,以換取整體的效能提升。 DOM 操作慢是有目共睹的,而其他 JavaScript 腳本的運作速度一定快嗎?
在 V8 出世之前,這個問題的答案是否定的。 Google 早年商業模式建立在Web 的基礎上,當它在瀏覽器中寫出Gmail 這樣一個無比複雜的Web app 的時候,它不可能意識不到瀏覽器難以忍受的性能,而這主要是因為JavaScript 的執行速度太慢。 2008 年 9 月,Google 決定自己打造一個 JavaScript 引擎來改變現狀— V8。當搭載著 V8 的 Chrome 瀏覽器出現在市場上的時候,它的速度遠遠甩開了當時的所有瀏覽器。瀏覽器效能的空前提升讓複雜的 Web app 成為了可能。
近七年過去,瀏覽器的效能隨著 CPU 的效能不斷上升,但再也沒有獲得過 2008 年那樣突破性的成長。 V8 到底用了什麼樣的技術讓 JavaScript 的效能獲得如此大的提升呢?
V8 的最佳化
要說如何讓 JavaScript 變快,就應該先來談談它為什麼會慢。眾所周知 JavaScript 是 Brendan Eich 這個傢伙花了一周多的時間開發出來的,相比現如今如日中天的 Swift 是 Apple 的一個團隊四年工作的成果,你首先可能就不應該對它有過高的期待。事實上,Brendan Eich 並未意識到自己要開發的是這樣一個體量的語言。為了程式設計師編寫時的靈活,他將 JavaScript 設計為弱型別的語言,並且在執行時可以為物件的屬性增添刪改。難倒一大群人的 C++ 中的繼承、多型,還有什麼模板、虛函數、動態綁定這些概念在 JavaScript 中完全不存在了。那這些工作誰來做了呢?自然只有 JavaScript 引擎。由於不知道變數類型,它在運行時做著大量的類型推導工作。在 Parser 完成工作建出一棵抽象語法樹(AST)的時候,引擎會把這棵 AST 翻譯成字節碼(bytecode)交給字節碼解釋器去執行。其中最拖慢性能的一步就是解釋器執行字節碼的階段。回望當時,大家不知道解釋器性能低嗎?其實不是,這樣設計的原因是當時的人們普遍認為JavaScript 作為一種為設計師開發的語言(前端工程師有沒有心裡一涼?),並不需要太高的性能,這樣做符合成本,也滿足需求。
V8 做的工作主要就是去掉了這個拖慢引擎速度的部分,它從 AST 直接產生了 CPU 可執行的機器碼。這種即時編譯的技術稱為 JIT (Just in time)。如果你夠好奇,一個自然的想法就是,這到底是怎麼辦到的?
我們舉一個例子來說:
function Foo(x, y) { this.x = x; this.y = y; } var foo = new Foo(7, 8); var bar = new Foo(8, 7); foo.z = 9;
屬性讀取
首先是資料結構。你打算如何索引物件的屬性?我們已經太熟悉 JSON 中 key: value 的資料結構,但在記憶體中可以以 key 來索引嗎? value 在記憶體中的位置可以確定嗎?當然可以,只要對每個物件維護一個表,裡面存著每個 key 對應的 value 在記憶體中的位置就可以了不是嗎?
這裡的陷阱在於,你需要對每個物件都維護這樣一個表。為什麼?讓我們來看看 C 語言是怎麼做的。
struct Foo { int x, y; }; struct Foo foo, bar; foo.x = 7; foo.y = 8; bar.x = 8; bar.y = 7; // Cant' set foo.z
仔細想想大學時候的教材,foo.x 和 foo.y 的地址是可以直接算出來的呀。這是因為成員 x 和 y 的型別是確定的,JavaScript 裡完全可以 foo.x = "Hello" ,而 C 語言就沒辦法這麼做了。
V8 不想给每个对象都维护一个这样的表。它也想让 JavaScript 拥有 C/C++ 直接用偏移就读出属性的特性。所以它的解决思路就是让动态类型静态化。V8 实现了一个叫做隐藏类(Hidden Class)的特性,即给每个对象分配一个隐藏类。对于 foo 对象,它生成一个类似于这样的类:
class Foo { int x, y; }
当新建一个 bar 对象的时候,它的 x 和 y 属性恰好都是 int 类型,那么它和 foo 对象就共享了这个隐藏类。把类型确定以后,读取属性就只是在内存中增加一个偏移的事情了。而当 foo 新建了 z 属性的时候,V8 发现原来的类不能用了,于是就会给 foo 新建一个隐藏类。修改属性类型也是类似。
Inline caching
由上可知,当访问一个对象的属性的时候,V8 首先要做的就是确定对象当前的隐藏类。但每次这样做的开销也很大,那很容易想到的另一个计算机中常用的解决方案,就是缓存。在第一次访问给定对象属性的时候,V8 将假设所有同一部分代码的其他对象也都使用了这个对象的隐藏类,于是会告诉其他对象直接使用这个类的信息。在访问其他对象的时候,如果校验正确,那么只需要一条指令就可以得到所需的属性,如果失败,V8 就会自动取消刚才的优化。上面这段话用代码来表述就是:
foo.x
# ebx = the foo object cmp [ebx,<hidden class offset>],<cached hidden class> jne <inline cache miss> mov eax,[ebx, <cached x offset>]
这极大提升了 V8 引擎的速度。
随着 Intel 宣布 Tick-Tock 模型的延缓,CPU 处理速度不再能像之前一样稳步增长了,那么浏览器还能继续变快吗?V8 的优化是浏览器性能的终点吗?
JavaScript 的问题在于错误地假设前端工程师都是水平不高的编程人员(如果不是,你应该不会读到这里),岂图让程序员写得舒服而让计算机执行得痛苦。在现代浏览器引擎已经优化到这个地步的时候,我们不禁想问:为什么一定是 JavaScript ?前端工程师是不是可以让出一步,让自己多做一点点事情,而让引擎得以更高效地优化性能?JavaScript 成为事实上的浏览器脚本标准有历史原因,但这不能是我们停止进步的借口。
当 Web Assembly 正式宣布的时候,我才确定了不仅仅是我一个名不见经传的小程序员有这样的想法,那些世界上最顶级的头脑已经开始行动了。浏览器在大量需求的驱动下正在朝着一个高性能的方向前进,浏览器究竟可以有多快,2015 可能是这条路上另一个转折点。