JavaScript 效能優化殺手

發表於2015-09-21

引言

這篇文件包含了如何避免使程式碼效能遠低於預期的建議. 尤其是一些會導致 V8 (牽涉到 Node.js, Opera, Chromium 等) 無法優化相關函式的問題.

一些 V8 背景

在 V8 中並沒有直譯器, 但卻有兩個不同的編譯器: 通用編譯器和優化編譯器. 這意味著你的 JavaScript 程式碼總是會被編譯為機器碼後直接執行. 這樣一定很快咯? 並不是. 僅僅是編譯為原生程式碼並不能明顯提高效能. 它只是消除了直譯器的開銷, 但如果未被優化, 程式碼依舊很慢.

舉個例子, 使用通用編譯器, a + b 會變成這個樣子:

換言之它僅僅是呼叫了執行時的函式. 如果 a 和 b 一定是整數, 那可以像這樣:

相比而言這會遠快於呼叫需要處理複雜 JavaScript 執行時語義的函式.

通常來說, 通用編譯器得到的是第一種結果, 而優化編譯器則會得到第二種結果. 使用優化編譯器編譯的程式碼可以很容易比通用編譯器編譯的程式碼快上 100 倍. 但這裡有個坑, 並非所有的 JavaScript 程式碼都能被優化. 在 JavaScript 中有很多種寫法, 包括具備語義的, 都不能被優化編譯器編譯 (回落到通用編譯器*).

記下一些會導致整個函式無法使用優化編譯器的用法很重要. 一次程式碼優化的是一整個函式, 優化過程中並不會關心其他程式碼做了什麼 (除非程式碼在已經被優化的函式中).

這個指南會涵蓋多數會導致整個函式掉進 “反優化火獄” 的例子. 由於編譯器一直在不斷更新, 未來當它能夠識別下面的一些情況時, 這裡提到的處理方法可能也就不必要了.

1. 工具和方法

你可以通過新增一些 V8 標記來使用 Node.js 驗證不同的用法如何影響優化結果. 通常可以寫一個包含了特定用法的函式, 使用所有可能的引數型別去呼叫它, 再使用 V8 的內部函式去優化和審查.

test.js

執行:

作為是否被優化的對比, 註釋掉 with 語句再來一次:

使用這個方法來驗證處理方法有效且必要是很重要的.

2. 不支援的語法

優化編譯器不支援一些特定的語句, 使用這些語法會使包含它的函式無法得到優化.

有一點請注意, 即使這些語句無法到達或者不會被執行, 它們也會使相關函式無法被優化.

比如這樣做是沒用的:

上面的程式碼會導致包含它的整個函式不被優化, 即使從來不會執行到 debugger 語句.

目前不會被優化的有:

  • generator 函式
  • 包含 for…of 語句的函式
  • 包含 try…catch 的函式
  • 包含 try…finally 的函式
  • 包含複合 let 賦值語句的函式 (原文為 compound let assignment)
  • 包含複合 const 賦值語句的函式 (原文為 compound const assignment)
  • 包含含有 __proto__ 或者 get/set 宣告的物件字面量的函式

可能永遠不會被優化的有:

  • 包含 debugger 語句的函式
  • 包含字面呼叫 eval() 的函式
  • 包含 with 語句的函式

最後一點明確一下, 如果有下面任何的情況, 整個函式都無法被優化:

提一下直接使用 eval 和 with 的情況, 因為它們會造成相關巢狀的函式作用域變為動態的. 這樣一來則有可能也影響其他很多函式, 因為這種情況下無法從詞法上判斷相關變數的有效範圍.

處理方法

之前提到過的一些語句在生產環境中是無法避免的, 比如 try...finally 和try...catch. 為了是代價最小, 它們必須被隔離到一個最小化的函式, 以保證主要的程式碼不受影響.

3. 使用 arguments

有不少使用 arguments 的方式會導致相關函式無法被優化. 所以在使用arguments 的時候需要非常留意.

3.1. 給一個已經定義的引數重新賦值, 並且在相關語句主體中引用 (僅限非嚴格模式). 典型的例子:

處理方法則是賦值該引數給一個新的變數:

如果僅僅是在這種情況下在函式中用到了 arguments, 也可以寫為是否為undefined 的判斷:

然而如果之後這個函式中用到 arguments, 維護程式碼的同學可能會容易忘掉要把重新賦值的語句留下**.

第二個處理方法: 對整個檔案或者函式開啟嚴格模式 ('use strict').

3.2. 洩露 arguments:

arguments 物件不能被傳遞或者洩露到任何地方.

處理方法則是使用內聯的程式碼建立陣列:

寫一堆程式碼很讓人惱火, 所以分析是否值得這麼做是值得的. 接下來更多的優化總是會帶來更多的程式碼, 而更多的程式碼又意味著語義上更顯而易見的退化.

然而如果你有一個 build 的過程, 這其實可以被一個不必要求 source map 的巨集來實現, 同時保證原始碼是有效的 JavaScript 程式碼.

上面的技巧就用到了 Bluebird 中, 在 build 後會被擴充為下面這樣:

3.3. 對 arguments 賦值

在非嚴格模式下, 這其實是可能的:

處理方法: 沒必要寫這麼蠢的程式碼. 說來在嚴格模式下, 它也會直接丟擲異常.

怎樣安全地使用 arguments?

僅使用:

  • arguments.length
  • arguments[i] 這裡 i 必須一直是 arguments 的整數索引, 並且不能超出邊界
  • 除了 .length 和 [i], 永遠不要直接使用 arguments (嚴格地說 x.apply(y, arguments) 是可以的, 但其他的都不行, 比如 .sliceFunction#apply 比較特殊)

另外關於用到 arguments 會造成 arguments 物件的分配這一點的 FUD (恐懼), 在使用限於上面提到的安全的方式時是不必要的.

4. switch…case

一個 switch…case 語句目前可以有最多 128 個 case 從句, 如果超過了這個數量, 包含這個 switch 語句的函式就無法被優化.

所以請保證 switch 語句的 case 從句不超過 128 個, 可以使用函式陣列或者 if…else 代替.

5. for…in

for…in 語句在一些情況下可能導致包含它的函式無法被優化.

以下解釋了 “for…in 不快” 或者類似的原因.

鍵不是區域性變數:

因此鍵既不能是上級作用於的變數, 也不能被子作用域引用. 它必須是一個本地變數.

5.2. 被列舉的物件不是一個 “簡單的可列舉物件”

5.2.1. 處於 “雜湊表模式” 的物件 (即 “普通化的物件”, “字典模式” – 以雜湊表為資料輔助結構的物件) 不是簡單的可列舉物件.

如果你 (在建構函式外) 動態地新增太多屬性到一個物件, 刪除屬性, 使用不是合法識別符號 (identifier) 的屬性名稱, 這個物件就會變為雜湊表模式. 換言之, 如果你把一個物件當做雜湊表來使用, 它就會轉變為一個雜湊表. 不要再 for…in 中使用這樣的物件. 判斷一個物件是否為雜湊表模式, 可以在開啟 Node.js 的 --allow-natives-syntax 選項時呼叫console.log(%HasFastProperties(obj)).

5.2.2. 物件的原型鏈中有可列舉的屬性

新增上面的程式碼會使所有的物件 (除了 Object.create(null) 建立的物件) 的原型鏈中都存在一個可列舉的屬性. 由此任何包含 for…in 語句的函式都無法得到優化 (除非僅列舉 Object.create(null) 建立的物件).

你可以通過 Object.defineProperty 來建立不可列舉的屬性 (不推薦執行時呼叫, 但是高效地定義一些靜態的東西, 比如原型屬性, 還是可以的).

5.2.3. 物件包含可列舉的陣列索引

一個屬性是否是陣列索引是在 ECMAScript 規範 中定義的.

A property name P (in the form of a String value) is an array index if and only if ToString(ToUint32(P)) is equal to P and ToUint32(P) is not equal to 232−1. A property whose property name is an array index is also called an element

通常來說這些物件是陣列, 但普通的物件也可以有陣列索引: normalObj[0] = value;

所以使用 for…in 遍歷陣列不僅比 for 迴圈慢, 還會導致包含它的整個函式無法被優化.

如果傳遞一個非簡單的可列舉物件到 for…in, 會導致整個函式無法被優化.

處理方法: 總是使用 Object.keys 再使用 for 迴圈遍歷陣列. 如果的確需要原型鏈上的所有屬性, 建立一個單獨的輔助函式.

6. 退出條件較深或者不明確的無限迴圈

寫程式碼的時候, 有時會知道自己需要一個迴圈, 但不清楚迴圈內的程式碼會寫成什麼樣子. 所以你放了一個 while (true) { 或者 for (;;) {, 之後再在一定條件下中斷迴圈接續之後的程式碼, 最後忘了這麼一件事. 重構的時間到了, 你發現這個函式很慢, 或者發現一個反優化的情況 – 可能它就是罪魁.

將迴圈的退出條件重構到迴圈自己的條件部分可能並不容易. 如果程式碼的退出條件是結尾 if 語句的一部分, 並且程式碼至少會執行一次, 那可以重構為 do { } while (); 迴圈. 如果退出條件在迴圈開頭, 把它放進迴圈本身的條件部分. 如果退出條件在中間, 你可以嘗試 “滾動” 程式碼: 每每從開頭移動一部分程式碼到末尾, 也複製一份到迴圈開始之前. 一旦退出條件可以放置在迴圈的條件部分, 或者至少是一個比較淺的邏輯判斷, 這個迴圈應該就不會被反優化了.

* 原文 it “bails out”.
** 原文 maintenance could easily forget to leave the re-assignent there though.

相關文章