JavaScript 性能優化殺手

2022-01-13 21CTO

引言

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

一些 V8 背景

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

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

mov eax, a

mov ebx, b

call RuntimeAdd

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

mov eax, a

mov ebx, b

add eax, ebx

相比而言這會遠快於調用需要處理複雜 JavaScript 運行時語義的函數.

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

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

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

1. 工具和方法

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

test.js

// 包含需要審查的用法的函數 (這裡是 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);

執行:

$ node --trace_opt --trace_deopt --allow-natives-syntax test.js

Function is not optimized

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

$ 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. 不支持的語法

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

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

比如這樣做是沒用的:

if (DEVELOPMENT) {

debugger;

}

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

目前不會被優化的有:

generator 函數

包含 for…of 語句的函數

包含 try…catch 的函數

包含 try…finally 的函數

包含複合 let 賦值語句的函數 (原文為 compound let assignment)

包含複合 const 賦值語句的函數 (原文為 compound const assignment)

包含含有 __proto__ 或者 get/set 聲明的對象字面量的函數

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

包含 debugger 語句的函數

包含字面調用 eval() 的函數

包含 with 語句的函數

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

function containsObjectLiteralWithProto() {

return { __proto__: 3 };

}

function containsObjectLiteralWithGetter() {

return {

get prop() {

return 3;

}

};

}

function containsObjectLiteralWithSetter() {

return {

set prop(val) {

this.val = val;

}

};

}

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

處理方法

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

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

function defaultArgsReassign(a, b) {

if (arguments.length < 2) b = 5;

}

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

function reAssignParam(a, b_) {

var b = b_;

// 與 b_ 不同, b 可以安全地被重新賦值

if (arguments.length < 2) b = 5;

}

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

function reAssignParam(a, b) {

if (b === void 0) b = 5;

}

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

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

3.2. 洩露 arguments:

function leaksArguments1() {

return arguments;

}

function leaksArguments2() {

var args = [].slice.call(arguments);

}

function leaksArguments3() {

var a = arguments;

return function() {

return a;

};

}

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

處理方法則是使用內聯的代碼創建數組:

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 代碼.

function doesntLeakArguments() {

INLINE_SLICE(args, arguments);

return args;

}

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

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 賦值

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

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 語句的函數就無法被優化.

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 不快」 或者類似的原因.

鍵不是局部變量:

function nonLocalKey1() {

var obj = {}

for(var key in obj);

return function() {

return key;

};

}

var key;

function nonLocalKey2() {

var obj = {}

for(key in obj);

}

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

5.2. 被枚舉的對象不是一個 「簡單的可枚舉對象」

5.2.1. 處於 「哈希表模式」 的對象 (即 「普通化的對象」, 「字典模式」 – 以哈希表為數據輔助結構的對象) 不是簡單的可枚舉對象.

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. 對象的原型鏈中有可枚舉的屬性

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;

function iteratesOverArray() {

var arr = [1, 2, 3];

for (var index in arr) {

}

}

所以使用 for…in 遍歷數組不僅比 for 循環慢, 還會導致包含它的整個函數無法被優化.

如果傳遞一個非簡單的可枚舉對象到 for…in, 會導致整個函數無法被優化.

處理方法: 總是使用 Object.keys 再使用 for 循環遍歷數組. 如果的確需要原型鏈上的所有屬性, 創建一個單獨的輔助函數.

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.

英文:Petka Antonov

譯者:掌門1對1

網址:http://dev.zm1v1.com/2015/08/19/javascript-optimization-killers/

關於21CTO社區

21CTO.com是中國網際網路第一技術人脈與社交平臺。我們為國內最優秀的開發者提供社交、學習等產品,幫助企業快速對接開發者,包括人才招聘,項目研發,顧問諮詢服務。

看微信文章不過癮,請移步到網站,誠摯歡迎您加入社區作者團隊。

網站地址:www.21cto.com

投稿郵箱:info@21cto.com

QQ群: 79309783 (歡迎掃描下列二維碼關注本微信號)

相關焦點

  • JavaScript 的性能優化:加載和執行
    ,從而使 JavaScript 在瀏覽器中的性能成為開發者所面臨的最重要的可用性問題。</p></body></html>然而這種常規的做法卻隱藏著嚴重的性能問題。這是優化 JavaScript 的首要規則:將腳本放在底部。03-組織腳本由於每個<script>標籤初始下載時都會阻塞頁面渲染,所以減少頁面包含的<script>標籤數量有助於改善這一情況。這不僅針對外鏈腳本,內嵌腳本的數量同樣也要限制。
  • 網頁性能之html css javascript
    DOCTYPE html><html>  <head>    <title>test</title>  </head>  <body>    <img src="download-button.png" alt="HTML代碼優化 網頁性能 JavaScript技巧 Javascript
  • JavaScript性能優化小知識總結
    javascript理解的還不夠透徹異或是自己太笨,更多的是自己不擅於思考懶得思考以至於裡面說的一些精髓都沒有太深入的理解。  鑑於想讓自己有一個提升,進不了一個更加廣闊的天地,總得找一個屬於自己的居所好好生存,所以平時會有意無意的去積累一些使用jQuerry的常用知識,特別是對於性能要求這一塊,總是會想是不是有更好的方式來實現。  下面是我總結的一些小技巧,僅供參考。
  • JavaScript 性能優化的小知識總結
    ,也有看過《犀利開發 Jquery 內核詳解與實踐》,對這本書的評價只有兩個字犀利,可能是對 javascript 理解的還不夠透徹異或是自己太笨,更多的是自己不擅於思考懶得思考以至於裡面說的一些精髓都沒有太深入的理解。
  • Javascript 性能測試 [每日前端夜話0xB1]
    正文共:916 字預計閱讀時間:7 分鐘作者:Deepak Gupta翻譯:瘋狂的技術宅來源:codeburst我們都知道 for 循環比 each 或 javascript 函數更快,因為在javascript函數的引擎下可能會使用for循環或其他我不確定的東西。
  • 14個 JavaScript 代碼優化技巧
    /high-performance-javascript/9781449382308/ch04.html為了減少計算變量的次數,可以使用閉包。較小的文件會提升你的網站性能,因為瀏覽器只需下載較小的資產即可。這類壓縮手段最多可以減少 80%的文件大小。
  • 不容忽視:MongoDB的JavaScript性能
    【IT168 技術】mongodb使用javascript做shell, mongodb的db.eval可以提供給數據驅動與這種javascript shell類似的js接口。
  • 【第696期】React 性能優化
    作者從六個方面點提供了優化的方法。正文從這開始~React 會在任意時間點在狀態改變後高效的重繪整個用戶界面,其對虛擬DOM diff法保證了DOM的最小化重繪。在大多數情況下,React 對DOM的渲染效率可以滿足我們需求。但少數情況下,我們需要更精細化的渲染來進一提高運行效率。本文介紹一些簡單的優化方法,你可以在需要時參考使用。
  • 前端開發規範(三、CSS性能優化)
    性能優化慎重選擇高消耗的樣式高消耗屬性在繪製前需要瀏覽器進行大量計算:box-shadowsborder-radiustransparencytransformsCSS filters(性能殺手)避免過分重排
  • Android性能優化--內存優化
    本文來自Dotry投稿,連結:https://www.jianshu.com/p/38b627adaecd上一篇文章關於Android性能優化--啟動優化探討了啟動優化相關的知識點,在本篇將介紹內存優化的相關優化。
  • JavaScript性能優化之分時函數(timeChunk)函數的實現
    「同樣也是為了讓JS的性能更加的出色,避免在短時間調用大量數據的時候產生的卡頓感!說白了一起都是為了用戶體驗!畢竟體驗為王嘛!」,麥克回答道姜小白又問:「舉個真實案例OK嗎?」「這個在網上有很多,比如手機版的今日頭條,我們在刷新聞或者視頻的時候,只要產生滾動就會加載數據沒錯吧?」
  • 說說那些經典的web前端面試題-開發及性能優化
    1、規避javascript多人開發函數重名問題命名空間封閉空間js模塊化mvc(數據層、表現層、控制層)seajs4、web前端開發,如何提高頁面性能優化?不要在 HTML 中使用縮放圖片使用恰當的圖片格式使用 CSS Sprites 技巧對圖片優化5、前端開發中,如何優化圖像?
  • JavaScript 事件對內存和性能的影響
    我們來分析一下:事件處理程序本質上是一種函數,是一種對象,存放在內存中,設置大量的事件處理程序會使內存中的對象變多,Web 程序的性能會變得越來越差,用戶體驗很不好。為了更好地利用好事件處理程序,便出現了事件委託,用來提升性能。
  • 最強大、最牛逼的javascript視頻
    是一門非常強大的腳本語言,應用的範圍非常廣泛,每一個web開發者學好javascript也是必須的,本套視頻教程詳細的講解了javascript各個知識點、關鍵點,其中涉及到高深的函數概念、原型概念、接口概念、單體概念、更是詳細的講解了javascript設計模式。 本視
  • Python 性能優化
    infoq上有一篇文章,提到禁用Python的GC機制後,Instagram性能提升了10%。感興趣的讀者可以去細讀。Be pythonic我們都知道 過早的優化是罪惡之源,一切優化都需要基於profile。
  • 【性能優化】面向存儲引擎,優化基礎度量值得到海量性能提升
    性能優化,在 DAX 中是很重要的問題,對 DAX 的性能優化大致可以歸結為針對
  • 性能優化之PHP優化
    在我們平常寫代碼的過程中,除了資料庫的優化,針對與文件的優化,我們還需要對PHP執行優化,當然對於老司機來說,這都是毛毛雨咯~但是畢竟有新手嘛,於是,我整理這麼一片文章。(未完待續...)性能優化之PHP優化(一):PHP結構1.字符串
  • 性能優化-一個命令發現性能問題
    為了取得程序的一丁點性能提升而大幅度增加技術的複雜性和晦澀性能,這個買賣做不得,這不僅僅是因為複雜的代碼容器滋生bug,也因為他會使日後的閱讀和維護工作要更加艱難。《Unix編程藝術》為什麼要性能優化也許是想要支持更高的吞吐量,想要更小的延遲,或者提高資源的利用率等,這些都是性能優化的目標之一。
  • 系統架構性能優化思路
    來源:https://4m.cn/rN8IB今天談下業務系統性能問題分析診斷和性能優化方面的內容。這篇文章重點還是談已經上線的業務系統後續出現性能問題後的問題診斷和優化重點。資料庫性能調優拿Oracle資料庫來說,影響資料庫性能的因素包括:系統、資料庫、網絡。資料庫的優化包括:優化資料庫磁碟I/O、優化回滾段、優化Rrdo日誌、優化系統全局區、優化資料庫對象。
  • Android性能優化總結
    這是來自一位粉絲「MeloDev」的投稿,講真,我這裡投稿的不少,但是只有我自己覺得很不錯的才會通過,這篇文章我覺得對大家有用,而且性能優化也算是我面試必問的一個話題了,所以這裡推薦給大家。程序執行效率:糟糕的代碼會嚴重影響程序的運行效率,UI線程過多的任務會阻塞應用的正常運行,長時間持有某個對象會導致潛在的內存洩露,頻繁的IO操作、網絡操作而不用緩存會嚴重影響程序的運行效率。一、布局複雜度的優化關於布局的優化,主要分兩個大方向1.