引言
這篇文件包含了如何避免使程式碼效能遠低於預期的建議. 尤其是一些會導致 V8 (牽涉到 Node.js, Opera, Chromium 等) 無法優化相關函式的問題.
一些 V8 背景
在 V8 中並沒有直譯器, 但卻有兩個不同的編譯器: 通用編譯器和優化編譯器. 這意味著你的 JavaScript 程式碼總是會被編譯為機器碼後直接執行. 這樣一定很快咯? 並不是. 僅僅是編譯為原生程式碼並不能明顯提高效能. 它只是消除了直譯器的開銷, 但如果未被優化, 程式碼依舊很慢.
舉個例子, 使用通用編譯器, a + b
會變成這個樣子:
1 2 3 |
mov eax, a mov ebx, b call RuntimeAdd |
換言之它僅僅是呼叫了執行時的函式. 如果 a
和 b
一定是整數, 那可以像這樣:
1 2 3 |
mov eax, a mov ebx, b add eax, ebx |
相比而言這會遠快於呼叫需要處理複雜 JavaScript 執行時語義的函式.
通常來說, 通用編譯器得到的是第一種結果, 而優化編譯器則會得到第二種結果. 使用優化編譯器編譯的程式碼可以很容易比通用編譯器編譯的程式碼快上 100 倍. 但這裡有個坑, 並非所有的 JavaScript 程式碼都能被優化. 在 JavaScript 中有很多種寫法, 包括具備語義的, 都不能被優化編譯器編譯 (回落到通用編譯器*).
記下一些會導致整個函式無法使用優化編譯器的用法很重要. 一次程式碼優化的是一整個函式, 優化過程中並不會關心其他程式碼做了什麼 (除非程式碼在已經被優化的函式中).
這個指南會涵蓋多數會導致整個函式掉進 “反優化火獄” 的例子. 由於編譯器一直在不斷更新, 未來當它能夠識別下面的一些情況時, 這裡提到的處理方法可能也就不必要了.
1. 工具和方法
你可以通過新增一些 V8 標記來使用 Node.js 驗證不同的用法如何影響優化結果. 通常可以寫一個包含了特定用法的函式, 使用所有可能的引數型別去呼叫它, 再使用 V8 的內部函式去優化和審查.
test.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// 包含需要審查的用法的函式 (這裡是 with 語句) function containsWith() { return 3; with({}) { } } function printStatus(fn) { switch(%GetOptimizationStatus(fn)) { case 1: console.log("Function is optimized"); break; case 2: console.log("Function is not optimized"); break; case 3: console.log("Function is always optimized"); break; case 4: console.log("Function is never optimized"); break; case 6: console.log("Function is maybe deoptimized"); break; } } // 告訴編譯器型別資訊 containsWith(); // 為了使狀態從 uninitialized 變為 pre-monomorphic, 再變為 monomorphic, 兩次呼叫是必要的 containsWith(); %OptimizeFunctionOnNextCall(containsWith); // 下一次呼叫 containsWith(); // 檢查 printStatus(containsWith); |
執行:
1 2 |
$ node --trace_opt --trace_deopt --allow-natives-syntax test.js Function is not optimized |
作為是否被優化的對比, 註釋掉 with
語句再來一次:
1 2 3 |
$ node --trace_opt --trace_deopt --allow-natives-syntax test.js [optimizing 000003FFCBF74231 <JS Function containsWith (SharedFunctionInfo 00000000FE1389E1)> - took 0.345, 0.042, 0.010 ms] Function is optimized |
使用這個方法來驗證處理方法有效且必要是很重要的.
2. 不支援的語法
優化編譯器不支援一些特定的語句, 使用這些語法會使包含它的函式無法得到優化.
有一點請注意, 即使這些語句無法到達或者不會被執行, 它們也會使相關函式無法被優化.
比如這樣做是沒用的:
1 2 3 |
if (DEVELOPMENT) { debugger; } |
上面的程式碼會導致包含它的整個函式不被優化, 即使從來不會執行到 debugger 語句.
目前不會被優化的有:
- generator 函式
- 包含 for…of 語句的函式
- 包含 try…catch 的函式
- 包含 try…finally 的函式
- 包含複合
let
賦值語句的函式 (原文為 compoundlet
assignment) - 包含複合
const
賦值語句的函式 (原文為 compoundconst
assignment) - 包含含有 __proto__ 或者 get/set 宣告的物件字面量的函式
可能永遠不會被優化的有:
- 包含
debugger
語句的函式 - 包含字面呼叫
eval()
的函式 - 包含
with
語句的函式
最後一點明確一下, 如果有下面任何的情況, 整個函式都無法被優化:
1 2 3 |
function containsObjectLiteralWithProto() { return { __proto__: 3 }; } |
1 2 3 4 5 6 7 |
function containsObjectLiteralWithGetter() { return { get prop() { return 3; } }; } |
1 2 3 4 5 6 7 |
function containsObjectLiteralWithSetter() { return { set prop(val) { this.val = val; } }; } |
提一下直接使用 eval
和 with
的情況, 因為它們會造成相關巢狀的函式作用域變為動態的. 這樣一來則有可能也影響其他很多函式, 因為這種情況下無法從詞法上判斷相關變數的有效範圍.
處理方法
之前提到過的一些語句在生產環境中是無法避免的, 比如 try...finally
和try...catch
. 為了是代價最小, 它們必須被隔離到一個最小化的函式, 以保證主要的程式碼不受影響.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
var errorObject = { value: null }; function tryCatch(fn, ctx, args) { try { return fn.apply(ctx, args); } catch(e) { errorObject.value = e; return errorObject; } } var result = tryCatch(mightThrow, void 0, [1,2,3]); // 不帶歧義地判斷是否呼叫丟擲了異常 (或其他值) if(result === errorObject) { var error = errorObject.value; } else { // 結果是返回值 } |
3. 使用 arguments
有不少使用 arguments
的方式會導致相關函式無法被優化. 所以在使用arguments
的時候需要非常留意.
3.1. 給一個已經定義的引數重新賦值, 並且在相關語句主體中引用 (僅限非嚴格模式). 典型的例子:
1 2 3 |
function defaultArgsReassign(a, b) { if (arguments.length < 2) b = 5; } |
處理方法則是賦值該引數給一個新的變數:
1 2 3 4 5 |
function reAssignParam(a, b_) { var b = b_; // 與 b_ 不同, b 可以安全地被重新賦值 if (arguments.length < 2) b = 5; } |
如果僅僅是在這種情況下在函式中用到了 arguments
, 也可以寫為是否為undefined
的判斷:
1 2 3 |
function reAssignParam(a, b) { if (b === void 0) b = 5; } |
然而如果之後這個函式中用到 arguments
, 維護程式碼的同學可能會容易忘掉要把重新賦值的語句留下**.
第二個處理方法: 對整個檔案或者函式開啟嚴格模式 ('use strict'
).
3.2. 洩露 arguments:
1 2 3 |
function leaksArguments1() { return arguments; } |
1 2 3 |
function leaksArguments2() { var args = [].slice.call(arguments); } |
1 2 3 4 5 6 |
function leaksArguments3() { var a = arguments; return function() { return a; }; } |
arguments
物件不能被傳遞或者洩露到任何地方.
處理方法則是使用內聯的程式碼建立陣列:
1 2 3 4 5 6 7 8 9 10 |
function doesntLeakArguments() { // .length 只是一個整數, 它不會洩露 // arguments 物件本身 var args = new Array(arguments.length); for(var i = 0; i < args.length; ++i) { // i 始終是 arguments 物件的有效索引 args[i] = arguments[i]; } return args; } |
寫一堆程式碼很讓人惱火, 所以分析是否值得這麼做是值得的. 接下來更多的優化總是會帶來更多的程式碼, 而更多的程式碼又意味著語義上更顯而易見的退化.
然而如果你有一個 build 的過程, 這其實可以被一個不必要求 source map 的巨集來實現, 同時保證原始碼是有效的 JavaScript 程式碼.
1 2 3 4 |
function doesntLeakArguments() { INLINE_SLICE(args, arguments); return args; } |
上面的技巧就用到了 Bluebird 中, 在 build 後會被擴充為下面這樣:
1 2 3 4 |
function doesntLeakArguments() { var $_len = arguments.length;var args = new Array($_len); for(var $_i = 0; $_i < $_len; ++$_i) {args[$_i] = arguments[$_i];} return args; } |
3.3. 對 arguments 賦值
在非嚴格模式下, 這其實是可能的:
1 2 3 4 |
function assignToArguments() { arguments = 3; return arguments; } |
處理方法: 沒必要寫這麼蠢的程式碼. 說來在嚴格模式下, 它也會直接丟擲異常.
怎樣安全地使用 arguments
?
僅使用:
arguments.length
arguments[i]
這裡i
必須一直是 arguments 的整數索引, 並且不能超出邊界- 除了
.length
和[i]
, 永遠不要直接使用arguments
(嚴格地說x.apply(y, arguments)
是可以的, 但其他的都不行, 比如.slice
.Function#apply
比較特殊)
另外關於用到 arguments
會造成 arguments 物件的分配這一點的 FUD (恐懼), 在使用限於上面提到的安全的方式時是不必要的.
4. switch…case
一個 switch…case 語句目前可以有最多 128 個 case 從句, 如果超過了這個數量, 包含這個 switch 語句的函式就無法被優化.
1 2 3 4 5 6 7 8 9 10 |
function over128Cases(c) { switch(c) { case 1: break; case 2: break; case 3: break; ... case 128: break; case 129: break; } } |
所以請保證 switch 語句的 case 從句不超過 128 個, 可以使用函式陣列或者 if…else 代替.
5. for…in
for…in 語句在一些情況下可能導致包含它的函式無法被優化.
以下解釋了 “for…in 不快” 或者類似的原因.
鍵不是區域性變數:
1 2 3 4 5 6 7 |
function nonLocalKey1() { var obj = {} for(var key in obj); return function() { return key; }; } |
1 2 3 4 5 |
var key; function nonLocalKey2() { var obj = {} for(key in obj); } |
因此鍵既不能是上級作用於的變數, 也不能被子作用域引用. 它必須是一個本地變數.
5.2. 被列舉的物件不是一個 “簡單的可列舉物件”
5.2.1. 處於 “雜湊表模式” 的物件 (即 “普通化的物件”, “字典模式” – 以雜湊表為資料輔助結構的物件) 不是簡單的可列舉物件.
1 2 3 4 |
function hashTableIteration() { var hashTable = {"-": 3}; for(var key in hashTable); } |
如果你 (在建構函式外) 動態地新增太多屬性到一個物件, 刪除屬性, 使用不是合法識別符號 (identifier) 的屬性名稱, 這個物件就會變為雜湊表模式. 換言之, 如果你把一個物件當做雜湊表來使用, 它就會轉變為一個雜湊表. 不要再 for…in 中使用這樣的物件. 判斷一個物件是否為雜湊表模式, 可以在開啟 Node.js 的 --allow-natives-syntax
選項時呼叫console.log(%HasFastProperties(obj))
.
5.2.2. 物件的原型鏈中有可列舉的屬性
1 |
Object.prototype.fn = function() {}; |
新增上面的程式碼會使所有的物件 (除了 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;
1 2 3 4 5 6 |
function iteratesOverArray() { var arr = [1, 2, 3]; for (var index in arr) { } } |
所以使用 for…in 遍歷陣列不僅比 for 迴圈慢, 還會導致包含它的整個函式無法被優化.
如果傳遞一個非簡單的可列舉物件到 for…in, 會導致整個函式無法被優化.
處理方法: 總是使用 Object.keys
再使用 for 迴圈遍歷陣列. 如果的確需要原型鏈上的所有屬性, 建立一個單獨的輔助函式.
1 2 3 4 5 6 7 |
function inheritedKeys(obj) { var ret = []; for(var key in obj) { ret.push(key); } return ret; } |
6. 退出條件較深或者不明確的無限迴圈
寫程式碼的時候, 有時會知道自己需要一個迴圈, 但不清楚迴圈內的程式碼會寫成什麼樣子. 所以你放了一個 while (true) {
或者 for (;;) {
, 之後再在一定條件下中斷迴圈接續之後的程式碼, 最後忘了這麼一件事. 重構的時間到了, 你發現這個函式很慢, 或者發現一個反優化的情況 – 可能它就是罪魁.
將迴圈的退出條件重構到迴圈自己的條件部分可能並不容易. 如果程式碼的退出條件是結尾 if 語句的一部分, 並且程式碼至少會執行一次, 那可以重構為 do { } while ();
迴圈. 如果退出條件在迴圈開頭, 把它放進迴圈本身的條件部分. 如果退出條件在中間, 你可以嘗試 “滾動” 程式碼: 每每從開頭移動一部分程式碼到末尾, 也複製一份到迴圈開始之前. 一旦退出條件可以放置在迴圈的條件部分, 或者至少是一個比較淺的邏輯判斷, 這個迴圈應該就不會被反優化了.
* 原文 it “bails out”.
** 原文 maintenance could easily forget to leave the re-assignent there though.