編寫高效的JavaScript程序
英文原文:Writing Fast, Memory-Efficient JavaScript
Addy Osmani是谷歌公司Chrome團隊中的一名程序開發工程師。他是一位JavaScript愛好者,曾經編寫過一本開放源碼方面的書籍《Learning JavaScript Design Patterns》以及《Developing Backbone Applications》。為Modernizr和jQuery社區貢獻了開源項目,目前正在從事‘Yeoman’項目,旨在為開發者提供一系列健壯的工具、程序庫和工作流,幫助他們快速構建出漂亮、引人注目的Web應用。本文作者將帶領大家探索高效編寫代碼的測試驗證方法。
文章內容如下:
JavaScript引擎包括Google V8(Chrome,Node)都是專為快速執行大型JavaScript程序而設計的。在開發過程中,如果你在乎內存使用率和性能情況,那么你應該會關心在用戶的瀏覽器中JavaScript引擎背后是怎么樣的。無論是V8、SpiderMonkey (Firefox)、Carakan (Opera)、Chakra (IE) 還是其他,有了它們可以幫助你更好的優化應用程序。
我們應該時不時地詢問自己:
- 我還能做些什么使代碼更加有效?
- 主流的JavaScript引擎做了哪些優化?
- 什么是引擎無法優化的,我能期待利用垃圾回收進行清潔嗎?
快速的加載Web網頁就如同汽車一樣,需要使用特殊工具。
當涉及到編寫高效的內存和快速創建代碼時總會出現一些常見的弊端,在這篇文章中我們將探索高效編寫代碼的測試驗證方法。
一、JavaScript如何在V8中工作?
如果你對JS引擎沒有較深的了解,開發一個大型Web應用也沒啥問題,就好比會開車的人也只是看過引擎蓋而沒有看過車蓋內的引擎一樣(這里將Web網頁比如成汽車)。Chrome瀏覽器是我的優先選擇,這里我將談下V8的核心組件:
- 一個基本的編譯器,在代碼執行前分析JavaScript、生成本地機器代碼而非執行字節代碼或是簡單的解釋,該段代碼之初不是高度優化的。
- V8用對象模型“表述”對象。在JavaScript中,對象是一個關聯數組,但是V8中,對象被“表述”為隱藏類,這種隱藏類是V8的內部類型,用于優化后的查找。
- 運行時分析器監視正在運行的系統并優化“hot”(活躍)函數。(比如,終結運行已久的代碼)
- 通過運行時分析器把優化編譯器重新編譯和被運行時分析器標識為“hot”的代碼 ,這是一種有效的編譯優化技術,(例如用被調用者的主體替換函數調用的位置)。
- V8支持去優化,也就是說當你發現一些假設的優化代碼太過樂觀,優化編譯器可以退出已生成的代碼。
- 垃圾回收,了解它是如何工作的,如同優化JavaScript一樣同等重要。
二、垃圾回收
垃圾回收是內存管理的一種形式,它試圖通過將不再使用的對象修復從而釋放內存占用率。垃圾回收語言(比如JavaScript)是指在JavaScript這種垃圾回收語言中,應用程序中仍在被引用的對象不會被清除。手動消除對象引用在大多數情況下是沒有必要的。通過簡單地把變量放在需要它們的地方(理想情況下,盡可能是局部作用域,即它們被使用的函數里而不是函數外層),一切將運作地很好。
垃圾回收清除內存
在JavaScript中強制執行垃圾回收是不可取的,當然,你也不會想這么做,因為垃圾回收進程被運行時控制著,它知道什么時候才是適合清理代碼的最好時機。
1. “消除引用”的誤解(De-Referencing Misconceptions)
在JavaScript中回收內存在網上引發了許多爭論,雖然它可以被用來刪除對象(map)中的屬性(key),但有部分開發者認為它可以用來強制“消除引用”。建議盡可能避免使用delete,在下面的例子中delete o.x 的弊大于利,因為它改變了o的隱藏類,使它成為通用的慢對象。
var o = { x: 1 }; delete o.x; // true o.x; // undefined
目的是為了在運行時避免修改活躍對象的結構,JavaScript引擎可以刪除類似“hot”對象,并試圖對其進行優化。如果該對象的結果沒有太大改變,超過生命周期,刪除可能會導致其改變。
對于null是如何工作也是有誤解的。將一個對象引用設置為null,并沒有使對象變“空”,只是將它的引用設置為空而已。使用o.x=null比使用delete會更好些,但可能也不是很必要。
var o = { x: 1 }; o = null; o; // null o.x // TypeError
如果這個引用是最后一個引用對象,那么該對象可進行垃圾回收;倘若不是,那么此方法不可行。注意,無論您的網頁打開多久,全局變量不能被垃圾回收清理。
var myGlobalNamespace = {};
當你刷新新頁面時,或導航到不同的頁面,關閉標簽頁或是退出瀏覽器,才可進行全局清理;當作用域不存在這個函數作用域變量時,這個變量才會被清理,即該函數被退出或是沒有被調用時,變量才能被清理。
經驗法則:
為了給垃圾回收創造機會,盡可能早的收集對象,盡量不要隱藏不使用的對象。這一點主要是自動發生,這里有幾點需要謹記:
- 正如之前我們提到的,手動引用在合適的范圍內使用變量是個更好的選擇,而不是將全局變量清空,只需使用不再需要的局部函數變量。也就是說我們不要為清潔代碼而擔心。
- 確保移除不再需要的事件偵聽器,尤其是當DOM對象將要被移除時。
- 如果你正在使用本地數據緩存,請務必清潔該緩存或使用老化機制來避免存儲那些不再使用的大量數據。
2. 函數(Functions)
正如我們前面提到的垃圾回收的工作原理是對內存堆中已經死亡的或者長時間沒有使用的對象進行清除和回收。下面的例子能夠更好的說明這一點:
function foo() { var bar = new LargeObject(); bar.someCall(); }
當foo返回時,bar自動指向垃圾回收對象,這是因為沒被調用,這里我們將做個對比:
function foo() { var bar = new LargeObject(); bar.someCall(); return bar; } // somewhere else var b = foo();
這里有個調用對象且被一直調用著直到這個調用交給b(或是超出b范圍)。
3. 閉包(Closures)
當你看到一個函數返回到內部函數,該內部函數可以訪問外部函數,即使外部函數正在被執行。這基本上是一個封閉的,可以在特定的范圍內設置變量的表達式。比如:
function sum (x) { function sumIt(y) { return x + y; }; return sumIt; } // Usage var sumA = sum(4); var sumB = sumA(3); console.log(sumB); // Returns 7
在sum調用上下文中生成的函數對象(sumIt)是無法被回收的,它被全局變量(sumA)所引用,并且可以通過sumA(n)調用。
這里有個示例演示如何訪問largeStr?
var a = function () { var largeStr = new Array(1000000).join('x'); return function () { return largeStr; }; }();
我們可以通過a():
var a = function () { var smallStr = 'x'; var largeStr = new Array(1000000).join('x'); return function (n) { return smallStr; }; }();
此時,我們不能訪問了,因為它是垃圾回收的候選者。
4. 計時器(Timers)
最糟糕的莫過于在循環中泄露,或者在setTimeout()/setInterval()中,但這卻是常見的問題之一。
var myObj = { callMeMaybe: function () { var myRef = this; var val = setTimeout(function () { console.log('Time is running out!'); myRef.callMeMaybe(); }, 1000); } };
如果我們運行:
myObj.callMeMaybe();
在計時器開始前,我們看到每一秒“時間已經不多了”,這時,我們將運行:
myObj = null;
三、當心性能陷阱
除非你真正需要,否則永遠不要優化代碼。在V8中你能輕易的看到一些細微的基準測試顯示比如N比M更佳,但是在真實的模塊代碼中或是在實際的應用程序中測試,這些優化所帶來的影響要比你想象中要小的多。
創建一個模塊,這里有三點:
- 采用本地的數據源包含ID數值
- 繪制一個包含這些數據的表格
- 添加事件處理程序,當用戶點擊的任何單元格時切換單元格的css class
如何存儲數據?如何高效的繪制表格并追加到DOM?怎樣處理表單上的事件?
注意:下面的這段代碼,千萬不能做:
var moduleA = function () { return { data: dataArrayObject, init: function () { this.addTable(); this.addEvents(); }, addTable: function () { for (var i = 0; i < rows; i++) { $tr = $('<tr></tr>'); for (var j = 0; j < this.data.length; j++) { $tr.append('<td>' + this.data[j]['id'] + '</td>'); } $tr.appendTo($tbody); } }, addEvents: function () { $('table td').on('click', function () { $(this).toggleClass('active'); }); } }; }();
很簡單,但是卻能把工作完成的很好。
請注意,直接使用DocumentFragment和本地DOM方法生成表格比使用jQuery更佳,事件委托通常比單獨綁定每個td更具備高性能。jQuery一般在內部使用DocumentFragment,但是在這個例子中,通過內循環調用代碼append() ,因此,無法在這個例子中進行優化,但愿這不是一個詬病,但請務必將代碼進行基準測試。
這里,我們通過opting for documentFragment提高性能,事件代理對簡單的綁定是一種改進,可選的DocumentFragment也起到了助推作用。
var moduleD = function () { return { data: dataArray, init: function () { this.addTable(); this.addEvents(); }, addTable: function () { var td, tr; var frag = document.createDocumentFragment(); var frag2 = document.createDocumentFragment(); for (var i = 0; i < rows; i++) { tr = document.createElement('tr'); for (var j = 0; j < this.data.length; j++) { td = document.createElement('td'); td.appendChild(document.createTextNode(this.data[j])); frag2.appendChild(td); } tr.appendChild(frag2); frag.appendChild(tr); } tbody.appendChild(frag); }, addEvents: function () { $('table').on('click', 'td', function () { $(this).toggleClass('active'); }); } }; }();
我們不妨看看其他提供性能的方法,也許你曾讀過使用原型模式或是使用JavaScript模板框架進行高度優化。但是使用這些僅針對可讀的代碼。此外,還有預編譯。我們一起來實踐下:
moduleG = function () {}; moduleG.prototype.data = dataArray; moduleG.prototype.init = function () { this.addTable(); this.addEvents(); }; moduleG.prototype.addTable = function () { var template = _.template($('#template').text()); var html = template({'data' : this.data}); $tbody.append(html); }; moduleG.prototype.addEvents = function () { $('table').on('click', 'td', function () { $(this).toggleClass('active'); }); }; var modG = new moduleG();
事實證明,選擇模板和原型并沒有給我們帶來多大好處。
四、V8引擎優化技巧:
特定的模式會導致V8優化產生故障。很多函數無法得到優化,你可以在V8平臺使用--trace-opt file.js搭配d8實用程序。
如果你關心速度,那么盡最大努力確保單態函數(functions monomorphic),確保變量(包括屬性,數組和函數參數)只適應同樣的隱藏類包含的對象。
下面的代碼演示了,我們不可這么做:
function add(x, y) { return x+y; } add(1, 2); add('a','b'); add(my_custom_object, undefined);
未初始化時不要加載和執行刪除操作,因為它們并沒有輸出差異,這樣做反而會使程序變得更慢。不要編寫大量函數,函數越多越難優化。
1. Objects使用技巧:
適應構造函數來創建對象。這將確保所創建的所有對象具備相同的隱藏類并有幫助避免更改這些類。
在程序或者復雜性上不要限制多種對象類型。(原因:長原型鏈中傾向于傷害,只有極少數的對象屬性得到一個特殊的委托)對于活躍對象保持短原型鏈以及低字段計數。
2. 對象克隆(Object Cloning)
對象克隆對于應用開發者來說是一種常見的現象。雖然在V8中這是實現各種類型問題的基準,但是當你進行復制時,一定要當心。當復制較大的程序時通常很會慢,因此,盡量不要這么做。在JavaScript循環中此舉是非常糟糕的。這里有個最快的技巧方案,你不妨學習下:
function clone(original) { this.foo = original.foo; this.bar = original.bar; } var copy = new clone(original);
3. 模塊模式中的緩存功能
在模塊模式中使用緩存功能也許在性能方面會有所提升。請參閱下面的例子,通過jsPerf test測試。注,使用這種方法比依靠原型模式更佳。
// Prototypal pattern Klass1 = function () {} Klass1.prototype.foo = function () { log('foo'); } Klass1.prototype.bar = function () { log('bar'); } // Module pattern Klass2 = function () { var foo = function () { log('foo'); }, bar = function () { log('bar'); }; return { foo: foo, bar: bar } } // Module pattern with cached functions var FooFunction = function () { log('foo'); }; var BarFunction = function () { log('bar'); }; Klass3 = function () { return { foo: FooFunction, bar: BarFunction } } // Iteration tests // Prototypal var i = 1000, objs = []; while (i--) { var o = new Klass1() objs.push(new Klass1()); o.bar; o.foo; } // Module pattern var i = 1000, objs = []; while (i--) { var o = Klass2() objs.push(Klass2()); o.bar; o.foo; } // Module pattern with cached functions var i = 1000, objs = []; while (i--) { var o = Klass3() objs.push(Klass3()); o.bar; o.foo; } // See the test for full details
4. 數組使用技巧:
一般情況下,我們不要刪除數組元素。它是使數組過渡到較慢的內部表現形式。當密鑰集變得稀疏時,V8最終將切換到字典模式,這是變慢的原因之一。
五、應用優化技巧:
在Web應用領域里,速度就是一切。沒有用戶希望在啟動電子表格時需要等上幾秒鐘,或者花上幾分鐘時間來整理信息。這也是為什么在性能方面,需要格外注意的一點,有人甚至將編碼階段稱為至關重要的一部分。
理解和提升性能方面是非常有用的,但它也有一定的難度。這里推薦幾個步驟來幫你解決:
- 測試:在應用程序中找到慢的節點 (~45%)
- 理解:查找問題所在(~45%)
- 修復:(~10%)
當然,還有許多工具或是技術方案幫助解決以上這些問題:
1. 基準測試。在JavaScript上有許多方法可進行基準測試。
2. 剖析。Chrome開發工具能夠很好的支持JavaScript分析器。你可以使用這些性能進行檢測,哪些功能占用的時間比較長,然后對其進行優化。最重要的是,即使是很小的改變也能影響整體的表現。關于這款分析工具,這里有份詳細的介紹。
3. 避免內存泄露-3快照技術。谷歌開發團隊通常會使用Chrome開發工具包括Gmail來幫助他們發現和修復內存泄露;此外,3 snapshot也是不錯的選擇。該技術允許在程序中記錄一些行為、強制垃圾回收、查詢,如果DOM節點無法返回預期的基線上,3 snapshot幫助分析確定是否存在內存泄露。
4. 單頁面程序上的內存管理。當你在編寫單頁程序時(比如,AngularJS,Backbone,Ember)),內存管理非常重要,他們從未得到刷新。這就意味著內存泄露很明顯。在移動單頁程序上存在著巨大的陷阱,因為內存有限,長期運行的程序比如email客戶端或者社交網絡應用。因此,它肩負著巨大的責任。
Derick發表了這篇《memory pitfalls》教您如何使用Backbone.js以及如何進行修復。Felix Geisendörfer的這篇在Node中調試內存泄露也值得一讀。
5. 最小化回流。回流是指在瀏覽器中用戶阻止此操作,所以它是有助于理解如何提高回流時間,你可以使用DocumentFragment一個輕量級的文檔對象來處理。
6. Javascript內存泄露檢測器。由Marja Hölttä和Jochen Eisinger兩人開發的這款工具,你不妨試試。
7. V8 flags調試優化和內存回收。Chrome支持通過flags和js-flags flag獲取更詳細的輸出:
例如:
"/Applications/Google Chrome/Google Chrome" --js-flags="--trace-opt --trace-deopt"
Windows用戶運行chrome.exe --js-flags="--trace-opt --trace-deopt"。
當開發應用時,可以使用下面的V8 flags:
- trace-opt –日志名稱的優化功能,顯示優化跳過的代碼
- trace-deopt –記錄運行時將要“去優化”的代碼。
- trace-gc – 對每次垃圾回收時進行跟蹤
結束語:
正如上面提到的,在JavaScript引擎中有許多隱藏的陷阱,世界上沒有什么好的銀彈能夠幫助你提高性能,只有通過在測試環境中進行優化,實現最大的性能收益。因此,了解引擎如何輸出和優化代碼可以幫助你調整應用程序。
因此,測試它、理解它、修復它,如此往復!