但是,你應該問自己幾個問題:
- 在我的程式碼裡,是否可以使程式碼更高效一些
- 主流的JavaScript引擎都做了哪些優化
- 什麼是引擎無法優化的,垃圾回收器(GC)是否能回收我所期望的東西
載入快速的網站就像是一輛快速的跑車,需要用到特別定製的零件. 圖片來源: dHybridcars.
編寫高效能程式碼時有一些常見的陷阱,在這篇文章中,我們將展示一些經過驗證的、更好的編寫程式碼方式。
那麼,JavaScript在V8裡是如何工作的?
如果你對JS引擎沒有較深的瞭解,開發一個大型Web應用也沒啥問題,就好比會開車的人也只是看過引擎蓋而沒有看過車蓋內的引擎一樣。鑑於Chrome是我的瀏覽器首選,所以談一下它的JavaScript引擎。V8是由以下幾個核心部分組成:
- 一個基本的編譯器,它會在程式碼執行前解析JavaScript程式碼並生成本地機器碼,而不是執行位元組碼或簡單地解釋它。這些程式碼最開始並不是高度優化的。
- V8將物件構建為物件模型。在JavaScript中物件表現為關聯陣列,但是在V8中物件被看作是隱藏的類,一個為了優化查詢的內部型別系統。
- 執行時分析器監視正在執行的系統,並標識了“hot”的函式(例如花費很長時間執行的程式碼)。
- 優化編譯器重新編譯和優化那些被執行時分析器標識為“hot”的程式碼,並進行“內聯”等優化(例如用被呼叫者的主體替換函式呼叫的位置)。
- V8支援去優化,這意味著優化編譯器如果發現對於程式碼優化的假設過於樂觀,它會捨棄優化過的程式碼。
- V8有個垃圾收集器,瞭解它是如何工作的和優化JavaScript一樣重要。
垃圾回收是記憶體管理的一種形式,其實就是一個收集器的概念,嘗試回收不再被使用的物件所佔用的記憶體。在JavaScript這種垃圾回收語言中,應用程式中仍在被引用的物件不會被清除。
手動消除物件引用在大多數情況下是沒有必要的。通過簡單地把變數放在需要它們的地方(理想情況下,儘可能是區域性作用域,即它們被使用的函式裡而不是函式外層),一切將運作地很好。
垃圾回收器嘗試回收記憶體. 圖片來源: Valtteri Mäki.
在JavaScript中,是不可能強制進行垃圾回收的。你不應該這麼做,因為垃圾收集過程是由執行時控制的,它知道什麼是最好的清理時機。“消除引用”的誤解
網上有許多關於JavaScript記憶體回收的討論都談到delete這個關鍵字,雖然它可以被用來刪除物件(map)中的屬性(key),但有部分開發者認為它可以用來強制“消除引用”。建議儘可能避免使用delete,在下面的例子中delete o.x 的弊大於利,因為它改變了o的隱藏類,並使它成為一個"慢物件"。
你會很容易地在流行的JS庫中找到引用刪除——這是具有語言目的性的。這裡需要注意的是避免在執行時修改”hot”物件的結構。JavaScript引擎可以檢測出這種“hot”的物件,並嘗試對其進行優化。如果物件在生命週期中其結構沒有較大的改變,引擎將會更容易優化物件,而delete操作實際上會觸發這種較大的結構改變,因此不利於引擎的優化。
對於null是如何工作也是有誤解的。將一個物件引用設定為null,並沒有使物件變“空”,只是將它的引用設定為空而已。使用o.x= null比使用delete會更好些,但可能也不是很必要。
如果此引用是當前物件的最後引用,那麼該物件將被作為垃圾回收。如果此引用不是當前物件的最後引用,則該物件是可訪問的且不會被垃圾回收。
另外需要注意的是,全域性變數在頁面的生命週期裡是不被垃圾回收器清理的。無論頁面開啟多久,JavaScript執行時全域性物件作用域中的變數會一直存在。
全域性物件只會在重新整理頁面、導航到其他頁面、關閉標籤頁或退出瀏覽器時才會被清理。函式作用域的變數將在超出作用域時被清理,即退出函式時,已經沒有任何引用,這樣的變數就被清理了。
經驗法則
為了使垃圾回收器儘早收集儘可能多的物件,不要hold著不再使用的物件。這裡有幾件事需要記住:
- 正如前面提到的,在合適的範圍內使用變數是手動消除引用的更好選擇。即一個變數只在一個函式作用域中使用,就不要在全域性作用域宣告它。這意味著更乾淨省心的程式碼。
- 確保解綁那些不再需要的事件監聽器,尤其是那些即將被銷燬的DOM物件所繫結的事件監聽器。
- 如果使用的資料快取在本地,確保清理一下快取或使用老化機制,以避免大量不被重用的資料被儲存。
接下來,我們談談函式。正如我們已經說過,垃圾收集的工作原理,是通過回收不再是訪問的記憶體塊(物件)。為了更好地說明這一點,這裡有一些例子。
當foo返回時,bar指向的物件將會被垃圾收集器自動回收,因為它已沒有任何存在的引用了。
對比一下:
現在我們有一個引用指向bar物件,這樣bar物件的生存週期就從foo的呼叫一直持續到呼叫者指定別的變數b(或b超出範圍)。
閉包(CLOSURES)
當你看到一個函式,返回一個內部函式,該內部函式將獲得範圍外的訪問權,即使在外部函式執行之後。這是一個基本的閉包 —— 可以在特定的上下文中設定的變數的表示式。例如:
在sum呼叫上下文中生成的函式物件(sumIt)是無法被回收的,它被全域性變數(sumA)所引用,並且可以通過sumA(n)呼叫。
讓我們來看看另外一個例子,這裡我們可以訪問變數largeStr嗎?
是的,我們可以通過a()訪問largeStr,所以它沒有被回收。下面這個呢?
我們不能再訪問largeStr了,它已經是垃圾回收候選人了。【譯者注:因為largeStr已不存在外部引用了】
定時器
最糟的記憶體洩漏地方之一是在迴圈中,或者在setTimeout()/ setInterval()中,但這是相當常見的。思考下面的例子:
如果我們執行myObj.callMeMaybe();來啟動定時器,可以看到控制檯每秒列印出“Time is running out!”。如果接著執行myObj = null,定時器依舊處於啟用狀態。為了能夠持續執行,閉包將myObj傳遞給setTimeout,這樣myObj是無法被回收的。
相反,它引用到myObj的因為它捕獲了myRef。這跟我們為了保持引用將閉包傳給其他的函式是一樣的。
同樣值得牢記的是,setTimeout/setInterval呼叫(如函式)中的引用,將需要執行和完成,才可以被垃圾收集。
當心效能陷阱
永遠不要優化程式碼,直到你真正需要。現在經常可以看到一些基準測試,顯示N比M在V8中更為優化,但是在模組程式碼或應用中測試一下會發現,這些優化真正的效果比你期望的要小的多。
做的過多還不如什麼都不做. 圖片來源: Tim Sheerman-Chase.
比如我們想要建立這樣一個模組:- 需要一個本地的資料來源包含數字ID
- 繪製包含這些資料的表格
- 新增事件處理程式,當使用者點選的任何單元格時切換單元格的css class
面對這些問題最開始(天真)的做法是使用物件儲存資料並放入陣列中,使用jQuery遍歷資料繪製表格並append到DOM中,最後使用事件繫結我們期望地點選行為。
注意:這不是你應該做的
這段程式碼簡單有效地完成了任務。
但在這種情況下,我們遍歷的資料只是本應該簡單地存放在陣列中的數字型屬性ID。有趣的是,直接使用DocumentFragment和本地DOM方法比使用jQuery(以這種方式)來生成表格是更優的選擇,當然,事件代理比單獨繫結每個td具有更高的效能。
要注意雖然jQuery在內部使用DocumentFragment,但是在我們的例子中,程式碼在迴圈內呼叫append並且這些呼叫涉及到一些其他的小知識,因此在這裡起到的優化作用不大。希望這不會是一個痛點,但請務必進行基準測試,以確保自己程式碼ok。
對於我們的例子,上述的做法帶來了(期望的)效能提升。事件代理對簡單的繫結是一種改進,可選的DocumentFragment也起到了助推作用。
接下來看看其他提升效能的方式。你也許曾經在哪讀到過使用原型模式比模組模式更優,或聽說過使用JS模版框架效能更好。有時的確如此,不過使用它們其實是為了程式碼更具可讀性。對了,還有預編譯!讓我們看看在實踐中表現的如何?
事實證明,在這種情況下的帶來的效能提升可以忽略不計。模板和原型的選擇並沒有真正提供更多的東西。也就是說,效能並不是開發者使用它們的原因,給程式碼帶來的可讀性、繼承模型和可維護性才是真正的原因。
更復雜的問題包括高效地在canvas上繪製圖片和操作帶或不帶型別陣列的畫素資料。
在將一些方法用在你自己的應用之前,一定要多瞭解這些方案的基準測試。也許有人還記得JS模版的shoot-off和隨後的擴充套件版。你要搞清楚基準測試不是存在於你看不到的那些虛擬應用,而是應該在你的實際程式碼中去測試帶來的優化。
V8優化技巧
詳細介紹了每個V8引擎的優化點在本文討論範圍之外,當然這裡也有許多值得一提的技巧。記住這些技巧你就能減少那些效能低下的程式碼了。
- 特定模式可以使V8擺脫優化的困境,比如說try-catch。欲瞭解更多有關哪些函式能或不能進行優化,你可以在V8的指令碼工具d8中使用–trace-opt file.js命令。
- 如果你關心速度,儘量使你的函式職責單一,即確保變數(包括屬性,陣列,函式引數)只使用相同隱藏類包含的物件。舉個例子,別這麼幹: [*]
- 不要載入未初始化或已刪除的元素。如果這麼做也不會出現什麼錯誤,但是這樣會使速度變慢。
- 不要使函式體過大,這樣會使得優化更加困難。
物件VS陣列:我應該用哪個?
- 如果你想儲存一串數字,或者一些相同型別的物件,使用一個陣列。
- 如果你語義上需要的是一堆的物件的屬性(不同型別的),使用一個物件和屬性。這在記憶體方面非常高效,速度也相當快。
- 整數索引的元素,無論儲存在一個陣列或物件中,都要比遍歷物件的屬性快得多。
- 物件的屬性比較複雜:它們可以被setter們建立,具有不同的列舉性和可寫性。陣列中則不具有如此的定製性,而只存在有和無這兩種狀態。在引擎層面,這允許更多儲存結構方面的優化。特別是當陣列中存在數字時,例如當你需要容器時,不用定義具有x,y,z屬性的類,而只用陣列就可以了。
使用物件時的技巧
- 使用一個建構函式來建立物件。這將確保它建立的所有物件具有相同的隱藏類,並有助於避免更改這些類。作為一個額外的好處,它也略快於Object.create()
- 你的應用中,對於使用不同型別的物件和其複雜度(在合理的範圍內:長原型鏈往往是有害的,呈現只有一個極少數屬性的物件比大物件會快一點)是有沒限制的。對於“hot”物件,儘量保持短原型鏈,並且少屬性。
對於應用程式開發人員,物件克隆是一個常見的問題。雖然各種基準測試可以證明V8對這個問題處理得很好,但仍要小心。複製大的東西通常是較慢的——不要這麼做。JS中的for..in迴圈尤其糟糕,因為它有著惡魔般的規範,並且無論是在哪個引擎中,都可能永遠不會比任何物件快。
當你一定要在關鍵效能程式碼路徑上覆制物件時,使用陣列或一個自定義的“拷貝建構函式”功能明確地複製每個屬性。這可能是最快的方式:
模組模式中快取函式
使用模組模式時快取函式,可能會導致效能方面的提升。參閱下面的例子,因為它總是建立成員函式的新副本,你看到的變化可能會比較慢。
另外請注意,使用這種方法明顯更優,不僅僅是依靠原型模式(經過jsPerf測試確認)。
使用模組模式或原型模式時的效能提升
這是一個原型模式與模組模式的效能對比測試:使用陣列時的技巧
接下來說說陣列相關的技巧。在一般情況下,不要刪除陣列元素,這樣將使陣列過渡到較慢的內部表示。當索引變得稀疏,V8將會使元素轉為更慢的字典模式。
陣列字面量
陣列字面量非常有用,它可以暗示VM陣列的大小和型別。它通常用在體積不大的陣列中。
儲存單一型別VS多型別
將混合型別(比如數字、字串、undefined、true/false)的資料存在陣列中絕不是一個好想法。例如var arr = [1, “1”, undefined, true, “true”]
型別推斷的效能測試
正如我們所看到的結果,整數的陣列是最快的。
稀疏陣列與滿陣列
當你使用稀疏陣列時,要注意訪問元素將遠遠慢於滿陣列。因為V8不會分配一整塊空間給只用到部分空間的陣列。取而代之的是,它被管理在字典中,既節約了空間,但花費訪問的時間。
稀疏陣列與滿陣列的測試
預分配空間VS動態分配
不要預分配大陣列(如大於64K的元素),其最大的大小,而應該動態分配。在我們這篇文章的效能測試之前,請記住這隻適用部分JavaScript引擎。
空字面量與預分配陣列在不同的瀏覽器進行測試
Nitro (Safari)對預分配的陣列更有利。而在其他引擎(V8,SpiderMonkey)中,預先分配並不是高效的。
預分配陣列測試
優化你的應用
在Web應用的世界中,速度就是一切。沒有使用者希望用一個要花幾秒鐘計算某列總數或花幾分鐘彙總資訊的表格應用。這是為什麼你要在程式碼中壓榨每一點效能的重要原因。
圖片來源: Per Olof Forsberg.
理解和提高應用程式的效能是非常有用的同時,它也是困難的。我們推薦以下的步驟來解決效能的痛點:
- 測量:在您的應用程式中找到慢的地方(約45%)
- 理解:找出實際的問題是什麼(約45%)
- 修復它! (約10%)
基準化(BENCHMARKING)
有很多方式來執行JavaScript程式碼片段的基準測試其效能——一般的假設是,基準簡單地比較兩個時間戳。這中模式被jsPerf團隊指出,並在SunSpider和Kraken的基準套件中使用:
在這裡,要測試的程式碼被放置在一個迴圈中,並執行一個設定的次數(例如6次)。在此之後,開始日期減去結束日期,就得出在迴圈中執行操作所花費的時間。
然而,這種基準測試做的事情過於簡單了,特別是如果你想執行在多個瀏覽器和環境的基準。垃圾收集器本身對結果是有一定影響的。即使你使用window.performance這樣的解決方案,也必須考慮到這些缺陷。
不管你是否只執行基準部分的程式碼,編寫一個測試套件或編碼基準庫,JavaScript基準其實比你想象的更多。如需更詳細的指南基準,我強烈建議你閱讀由Mathias Bynens和John-David Dalton提供的Javascript基準測試。
分析(PROFILING)
Chrome開發者工具為JavaScript分析有很好的支援。可以使用此功能檢測哪些函式佔用了大部分時間,這樣你就可以去優化它們。這很重要,即使是程式碼很小的改變會對整體表現產生重要的影響。
Chrome開發者工具的分析皮膚
分析過程開始獲取程式碼效能基線,然後以時間線的形式體現。這將告訴我們程式碼需要多長時間執行。“Profiles”選項卡給了我們一個更好的視角來了解應用程式中發生了什麼。JavaScript CPU分析檔案展示了多少CPU時間被用於我們的程式碼,CSS選擇器分析檔案展示了多少時間花費在處理選擇器上,堆快照顯示多少記憶體正被用於我們的物件。
利用這些工具,我們可以分離、調整和重新分析來衡量我們的功能或操作效能優化是否真的起到了效果。
“Profile”選項卡展示了程式碼效能資訊。
一個很好的分析介紹,閱讀Zack Grossbart的 JavaScript Profiling With The Chrome Developer Tools。
提示:在理想情況下,若想確保你的分析並未受到已安裝的應用程式或擴充套件的任何影響,可以使用--user-data-dir <empty_directory>標誌來啟動Chrome。在大多數情況下,這種方法優化測試應該是足夠的,但也需要你更多的時間。這是V8標誌能有所幫助的。
避免記憶體洩漏——3快照技術
在谷歌內部,Chrome開發者工具被Gmail等團隊大量使用,用來幫助發現和排除記憶體洩漏。
Chrome開發者工具中的記憶體統計
記憶體統計出我們團隊所關心的私有記憶體使用、JavaScript堆的大小、DOM節點數量、儲存清理、事件監聽計數器和垃圾收集器正要回收的東西。推薦閱讀Loreena Lee的“3快照”技術。該技術的要點是,在你的應用程式中記錄一些行為,強制垃圾回收,檢查DOM節點的數量有沒有恢復到預期的基線,然後分析三個堆的快照來確定是否有記憶體洩漏。單頁面應用的記憶體管理
單頁面應用程式(例如AngularJS,Backbone,Ember)的記憶體管理是非常重要的,它們幾乎永遠不會重新整理頁面。這意味著記憶體洩漏可能相當明顯。移動終端上的單頁面應用充滿了陷阱,因為裝置的記憶體有限,並在長期執行Email客戶端或社交網路等應用程式。能力愈大責任愈重。
有很多辦法解決這個問題。在Backbone中,確保使用dispose()來處理舊檢視和引用(目前在Backbone(Edge)中可用)。這個函式是最近加上的,移除新增到檢視“event”物件中的處理函式,以及通過傳給view的第三個引數(回撥上下文)的model或collection的事件監聽器。dispose()也會被檢視的remove()呼叫,處理當元素被移除時的主要清理工作。Ember 等其他的庫當檢測到元素被移除時,會清理監聽器以避免記憶體洩漏。
Derick Bailey的一些明智的建議:
與其瞭解事件與引用是如何工作的,不如遵循的標準規則來管理JavaScript中的記憶體。如果你想載入資料到的一個存滿使用者物件的Backbone集合中,你要清空這個集合使它不再佔用記憶體,那必須這個集合的所有引用以及集合內物件的引用。一旦清楚了所用的引用,資源就會被回收。這就是標準的JavaScript垃圾回收規則。在文章中,Derick涵蓋了許多使用Backbone.js時的常見記憶體缺陷,以及如何解決這些問題。
Felix Geisendörfer的在Node中除錯記憶體洩漏的教程也值得一讀,尤其是當它形成了更廣泛SPA堆疊的一部分。
減少迴流(REFLOWS)
當瀏覽器重新渲染文件中的元素時需要 重新計算它們的位置和幾何形狀,我們稱之為迴流。迴流會阻塞使用者在瀏覽器中的操作,因此理解提升迴流時間是非常有幫助的。
迴流時間圖表
你應該批量地觸發迴流或重繪,但是要節制地使用這些方法。儘量不處理DOM也很重要。可以使用DocumentFragment,一個輕量級的文件物件。你可以把它作為一種方法來提取文件樹的一部分,或建立一個新的文件“片段”。與其不斷地新增DOM節點,不如使用文件片段後只執行一次DOM插入操作,以避免過多的迴流。
例如,我們寫一個函式給一個元素新增20個div。如果只是簡單地每次append一個div到元素中,這會觸發20次迴流。
要解決這個問題,可以使用DocumentFragment來代替,我們可以每次新增一個新的div到裡面。完成後將DocumentFragment新增到DOM中只會觸發一次迴流。
可以參閱 Make the Web Faster,JavaScript Memory Optimization 和 Finding Memory Leaks。
JS記憶體洩漏探測器
為了幫助發現JavaScript記憶體洩漏,谷歌的開發人員((Marja Hölttä和Jochen Eisinger)開發了一種工具,它與Chrome開發人員工具結合使用,檢索堆的快照並檢測出是什麼物件導致了記憶體洩漏。
一個JavaScript記憶體洩漏檢測工具
有完整的文章介紹瞭如何使用這個工具,建議你自己到記憶體洩漏探測器專案頁面看看。
如果你想知道為什麼這樣的工具還沒整合到我們的開發工具,其原因有二。它最初是在Closure庫中幫助我們捕捉一些特定的記憶體場景,它更適合作為一個外部工具。
V8優化除錯和垃圾回收的標誌位
Chrome支援直接通過傳遞一些標誌給V8,以獲得更詳細的引擎優化輸出結果。例如,這樣可以追蹤V8的優化:
在開發應用程式時,下面的V8標誌都可以使用。
- trace-opt —— 記錄優化函式的名稱,並顯示跳過的程式碼,因為優化器不知道如何優化。
- trace-deopt —— 記錄執行時將要“去優化”的程式碼。
- trace-gc —— 記錄每次的垃圾回收。
如果你有興趣瞭解更多關於V8的標誌和V8的內部是如何工作的,強烈建議 閱讀Vyacheslav Egorov的excellent post on V8 internals。
HIGH-RESOLUTION TIME 和 NAVIGATION TIMING API
高精度時間(HRT)是一個提供不受系統時間和使用者調整影響的亞毫秒級高精度時間介面,可以把它當做是比 new Date 和 Date.now()更精準的度量方法。這對我們編寫基準測試幫助很大。
高精度時間(HRT)提供了當前亞毫秒級的時間精度
目前HRT在Chrome(穩定版)中是以window.performance.webkitNow()方式使用,但在Chrome Canary中字首被丟棄了,這使得它可以通過window.performance.now()方式呼叫。Paul Irish在HTML5Rocks上了關於HRT更多內容的文章。
現在我們知道當前的精準時間,那有可以準確測量頁面效能的API嗎?好吧,現在有個Navigation Timing API可以使用,這個API提供了一種簡單的方式,來獲取網頁在載入呈現給使用者時,精確和詳細的時間測量記錄。可以在console中使用window.performance.timing來獲取時間資訊:
顯示在控制檯中的時間資訊
我們可以從上面的資料獲取很多有用的資訊,例如網路延時為responseEnd – fetchStart,頁面載入時間為loadEventEnd – responseEnd,處理導航和頁面載入的時間為loadEventEnd – navigationStart。
正如你所看到的,perfomance.memory的屬性也能顯示JavaScript的記憶體資料使用情況,如總的堆大小。
更多Navigation Timing API的細節,閱讀 Sam Dutton的 Measuring Page Load Speed With Navigation Timing。
ABOUT:MEMORY 和 ABOUT:TRACING
Chrome中的about:tracing提供了瀏覽器的效能檢視,記錄了Chrome的所有執行緒、tab頁和程式。
About:Tracing提供了瀏覽器的效能檢視
這個工具的真正用處是允許你捕獲Chrome的執行資料,這樣你就可以適當地調整JavaScript執行,或優化資源載入。
Lilli Thompson有一篇寫給遊戲開發者的使用about:tracing分析WebGL遊戲的文章,同時也適合JavaScript的開發者。
在Chrome的導航欄裡可以輸入about:memory,同樣十分實用,可以獲得每個tab頁的記憶體使用情況,對定位記憶體洩漏很有幫助。
總結
我們看到,JavaScript的世界中有很多隱藏的陷阱,且並沒有提升效能的銀彈。只有把一些優化方案綜合使用到(現實世界)測試環境,才能獲得最大的效能收益。即便如此,瞭解引擎是如何解釋和優化程式碼,可以幫助你調整應用程式。
測量,理解,修復。不斷重複這個過程。
圖片來源: Sally Hunter
謹記關注優化,但為了便利可以捨棄一些很小的優化。例如,有些開發者選擇.forEach和Object.keys代替for和for..in迴圈,儘管這會更慢但使用更方便。要保證清醒的頭腦,知道什麼優化是需要的,什麼優化是不需要的。
同時注意,雖然JavaScript引擎越來越快,但下一個真正的瓶頸是DOM。迴流和重繪的減少也是重要的,所以必要時再去動DOM。還有就是要關注網路,HTTP請求是珍貴的,特別是移動終端上,因此要使用HTTP的快取去減少資源的載入。
記住這幾點可以保證你獲取了本文的大部分資訊,希望對你有所幫助!
評論(2)