1. 程式人生 > >JavaScript 啟動效能瓶頸分析與解決方案

JavaScript 啟動效能瓶頸分析與解決方案

2818587350-58a1c3129da12_articlex

在 Web 開發中,隨著需求的增加與程式碼庫的擴張,我們最終釋出的 Web 頁面也逐漸膨脹。不過這種膨脹遠不止意味著佔據更多的傳輸頻寬,其還意味著使用者瀏覽網頁時可能更差勁的效能體驗。瀏覽器在下載完某個頁面依賴的指令碼之後,其還需要經過語法分析、解釋與執行這些步驟。而本文則會深入分析瀏覽器對於 JavaScript 的這些處理流程,挖掘出那些影響你應用啟動時間的罪魁禍首,並且根據我個人的經驗提出相對應的解決方案。回顧過去,我們還沒有專門地考慮過如何去優化 JavaScript 解析/編譯這些步驟;我們預想中的是解析器在發現<script>標籤後會瞬時完成解析操作,不過這很明顯是痴人說夢。下圖是對於 V8 引擎工作原理的概述:

4148870312-58a1c312ca0b8_articlex下面我們深入其中的關鍵步驟進行分析。

到底是什麼拖慢了我們應用的啟動時間?

在啟動階段,語法分析,編譯與指令碼執行佔據了 JavaScript 引擎執行的絕大部分時間。換言之,這些過程造成的延遲會真實地反應到使用者可互動時延上;譬如使用者已經看到了某個按鈕,但是要好幾秒之後才能真正地去點選操作,這一點會大大影響使用者體驗。
362536194-58a1c31153320_articlex上圖是我們使用 Chrome Canary 內建的 V8 RunTime Call Stats 對於某個網站的分析結果;需要注意的是桌面瀏覽器中語法解析與編譯佔用的時間還是蠻長的,而在移動端中佔用的時間則更長。實際上,對於 Facebook, Wikipedia, Reddit 這些大型網站中語法解析與編譯所佔的時間也不容忽視:

586206652-58a1c312b73de_articlex上圖中的粉色區域表示花費在 V8 與 Blink’s C++ 中的時間,而橙色和黃色分別表示語法解析與編譯的時間佔比。Facebook 的 Sebastian Markbage 與 Google 的 Rob Wormald 也都在 Twitter 發文表示過 JavaScript 的語法解析時間過長已經成為了不可忽視的問題,後者還表示這也是 Angular 啟動時主要的消耗之一。
2595386084-58a1c31163009_articlex

隨著移動端浪潮的湧來,我們不得不面對一個殘酷的事實:移動端對於相同包體的解析與編譯過程要花費相當於桌面瀏覽器2~5倍的時間。當然,對於高配的 iPhone 或者 Pixel 這樣的手機相較於 Moto G4 這樣的中配手機表現會好很多;這一點提醒我們在測試的時候不能僅用身邊那些高配的手機,而應該中高低配兼顧:

2429676219-58a1c312afca2_articlex

上圖是部分桌面瀏覽器與移動端瀏覽器對於 1MB 的 JavaScript 包體進行解析的時間對比,顯而易見的可以發現不同配置的移動端手機之間的巨大差異。當我們應用包體已經非常巨大的時候,使用一些現代的打包技巧,譬如程式碼分割,TreeShaking,Service Workder 快取等等會對啟動時間有很大的影響。另一個角度來看,即使是小模組,你程式碼寫的很糟或者使用了很糟的依賴庫都會導致你的主執行緒花費大量的時間在編譯或者冗餘的函式呼叫中。我們必須要清醒地認識到全面評測以挖掘出真正效能瓶頸的重要性。

JavaScript 語法解析與編譯是否成為了大部分網站的瓶頸?

我曾不止一次聽到有人說,我又不是 Facebook,你說的 JavaScript 語法解析與編譯到
底會對其他網站造成什麼樣的影響呢?對於這個問題我也很好奇,於是我花費了兩個月的時間對於超過 6000 個網站進行分析;這些網站囊括了 React,Angular,Ember,Vue 這些流行的框架或者庫。大部分的測試是基於 WebPageTest 進行的,因此你可以很方便地重現這些測試結果。光纖接入的桌面瀏覽器大概需要 8 秒的時間才能允許使用者互動,而 3G 環境下的 Moto G4 大概需要 16 秒 才能允許使用者互動。
1835415460-58a1c312ac3d0_articlex大部分應用在桌面瀏覽器中會耗費約 4 秒的時間進行 JavaScript 啟動階段(語法解析、編譯、執行)
2737769012-58a1c314168f4_articlex

而在移動端瀏覽器中,大概要花費額外 36% 的時間來進行語法解析:
4177290094-58a1c313ba81b_articlex

另外,統計顯示並不是所有的網站都甩給使用者一個龐大的 JS 包體,使用者下載的經過 Gzip 壓縮的平均包體大小是 410KB,這一點與 HTTPArchive 之前釋出的 420KB 的資料基本一致。不過最差勁的網站則是直接甩了 10MB 的指令碼給使用者,簡直可怕。

253409307-58a1c3138d8f5_articlex

通過上面的統計我們可以發現,包體體積固然重要,但是其並非唯一因素,語法解析與編譯的耗時也不一定隨著包體體積的增長而線性增長。總體而言小的 JavaScript 包體是會載入地更快(忽略瀏覽器、裝置與網路連線的差異),但是同樣 200KB 的大小,不同開發者的包體在語法解析、編譯上的時間卻是天差地別,不可同日而語。

現代 JavaScript 語法解析 & 編譯效能評測

Chrome DevTools

開啟 Timeline( Performance panel ) > Bottom-Up/Call Tree/Event Log 就會顯示出當前網站在語法解析/編譯上的時間佔比。如果你希望得到更完整的資訊,那麼可以開啟 V8 的 Runtime Call Stats。在 Canary 中,其位於 Timeline 的 Experims > V8 Runtime Call Stats 下。
3080077276-58a1c3145056c_articlex

Chrome Tracing

開啟 about:tracing 頁面,Chrome 提供的底層的追蹤工具允許我們使用disabled-by-default-v8.runtime_stats來深度瞭解 V8 的時間消耗情況。V8 也提供了詳細的指南來介紹如何使用這個功能。
61072498-58a1c31358eb2_articlex

WebPageTest

1225927222-58a1c31701293_articlexWebPageTest 中 Processing Breakdown 頁面在我們啟用 Chrome > Capture Dev Tools Timeline 時會自動記錄 V8 編譯、EvaluateScript 以及 FunctionCall 的時間。我們同樣可以通過指明disabled-by-default-v8.runtime_stats的方式來啟用 Runtime Call Stats。
3359794933-58a1c3147c377_articlex

更多使用說明參考我的gist

User Timing

我們還可以使用 Nolan Lawson 推薦的User Timing API來評估語法解析的時間。不過這種方式可能會受 V8 預解析過程的影響,我們可以借鑑 Nolan 在 optimize-js 評測中的方式,在指令碼的尾部新增隨機字串來解決這個問題。我基於 Google Analytics 使用相似的方式來評估真實使用者與裝置訪問網站時候的解析時間:
1403440557-58a1c313f15be_articlex

DeviceTiming

Etsy 的 DeviceTiming 工具能夠模擬某些受限環境來評估頁面的語法解析與執行時間。其將本地指令碼包裹在了某個儀表工具程式碼內從而使我們的頁面能夠模擬從不同的裝置中訪問。可以閱讀 Daniel Espeset 的Benchmarking JS Parsing and Execution on Mobile Devices 一文來了解更詳細的使用方式。
794656070-58a1c31468aee_articlex

我們可以做些什麼以降低 JavaScript 的解析時間?

  • 減少 JavaScript 包體體積。我們在上文中也提及,更小的包體往往意味著更少的解析工作量,也就能降低瀏覽器在解析與編譯階段的時間消耗。
  • 使用程式碼分割工具來按需傳遞程式碼與懶載入剩餘模組。這可能是最佳的方式了,類似於PRPL這樣的模式鼓勵基於路由的分組,目前被 Flipkart, Housing.com 與 Twitter 廣泛使用。
  • Script streaming: 過去 V8 鼓勵開發者使用async/defer來基於script streaming實現 10-20% 的效能提升。這個技術會允許 HTML 解析器將相應的指令碼載入任務分配給專門的 script streaming 執行緒,從而避免阻塞文件解析。V8 推薦儘早載入較大的模組,畢竟我們只有一個 streamer 執行緒。
  • 評估我們依賴的解析消耗。我們應該儘可能地選擇具有相同功能但是載入地更快的依賴,譬如使用 Preact 或者 Inferno 來代替 React,二者相較於 React 體積更小具有更少的語法解析與編譯時間。Paul Lewis 在最近的一篇文章中也討論了框架啟動的代價,與 Sebastian Markbage 的說法不謀而合:最好地評測某個框架啟動消耗的方式就是先渲染一個介面,然後刪除,最後進行重新渲染。第一次渲染的過程會包含了分析與編譯,通過對比就能發現該框架的啟動消耗。

如果你的 JavaScript 框架支援 AOT(ahead-of-time)編譯模式,那麼能夠有效地減少解析與編譯的時間。Angular 應用就受益於這種模式:
2308613206-58a1c3158b4c6_articlex

現代瀏覽器是如何提高解析與編譯速度的?

不用灰心,你並不是唯一糾結於如何提升啟動時間的人,我們 V8 團隊也一直在努力。我們發現之前的某個評測工具 Octane 是個不錯的對於真實場景的模擬,它在微型框架與冷啟動方面很符合真實的使用者習慣。而基於這些工具,V8 團隊在過去的工作中也實現了大約 25% 的啟動效能提升:
3266050737-58a1c31598702_articlex

本部分我們就會對過去幾年中我們使用的提升語法解析與編譯時間的技巧進行闡述。

程式碼快取

1006411302-58a1c31497414_articlex

Chrome 42 開始引入了所謂的程式碼快取的概念,為我們提供了一種存放編譯後的程式碼副本的機制,從而當用戶二次訪問該頁面時可以避免指令碼抓取、解析與編譯這些步驟。除以之外,我們還發現在重複訪問的時候這種機制還能避免 40% 左右的編譯時間,這裡我會深入介紹一些內容:

  • 程式碼快取會對於那些在 72 小時之內重複執行的指令碼起作用。
  • 對於 Service Worker 中的指令碼,程式碼快取同樣對 72 小時之內的指令碼起作用。
  • 對於利用 Service Worker 快取在 Cache Storage 中的指令碼,程式碼快取能在指令碼首次執行的時候起作用。

總而言之,對於主動快取的 JavaScript 程式碼,最多在第三次呼叫的時候其能夠跳過語法分析與編譯的步驟。我們可以通過chrome://flags/#v8-cache-strategies-for-cache-storage來檢視其中的差異,也可以設定 js-flags=profile-deserialization執行 Chrome 來檢視程式碼是否載入自程式碼快取。不過需要注意的是,程式碼快取機制僅會快取那些經過編譯的程式碼,主要是指那些頂層的往往用於設定全域性變數的程式碼。而對於類似於函式定義這樣懶編譯的程式碼並不會被快取,不過 IIFE 同樣被包含在了 V8 中,因此這些函式也是可以被快取的。

Script Streaming

Script Streaming允許在後臺執行緒中對非同步指令碼執行解析操作,可以對於頁面載入時間有大概 10% 的提升。上文也提到過,這個機制同樣會對同步指令碼起作用。
2716066697-58a1c315e1063_articlex這個特性倒是第一次提及,因此 V8 會允許所有的指令碼,即使阻塞型的<script src=''>指令碼也可以由後臺執行緒進行解析。不過缺陷就是目前僅有一個 streaming 後臺執行緒存在,因此我們建議首先解析大的、關鍵性的指令碼。在實踐中,我們建議將<script defer>新增到<head>塊內,這樣瀏覽器引擎就能夠儘早地發現需要解析的指令碼,然後將其分配給後臺執行緒進行處理。我們也可以檢視 DevTools Timeline 來確定指令碼是否被後臺解析,特別是當你存在某個關鍵性指令碼需要解析的時候,更需要確定該指令碼是由 streaming 執行緒解析的。
739720084-58a1c3160549a_articlex

語法解析 & 編譯優化

我們同樣致力於打造更輕量級、更快的解析器,目前 V8 主執行緒中最大的瓶頸在於所謂的非線性解析消耗。譬如我們有如下的程式碼片:

JavaScript
1 (function(global,module){})(this,functionmodule(){my functions})

V8 並不知道我們編譯主指令碼的時候是否需要module這個模組,因此我們會暫時放棄編譯它。而當我們打算編譯module時,我們需要重分析所有的內部函式。這也就是所謂的 V8 解析時間非線性的原因,任何一個處於 N 層深度的函式都有可能被重新分析 N 次。V8 已經能夠在首次編譯的時候蒐集所有內部函式的資訊,因此在未來的編譯過程中 V8 會忽略所有的內部函式。對於上面這種module形式的函式會是很大的效能提升,建議閱讀The V8 Parser(s) — Design, Challenges, and Parsing JavaScript Better來獲取更多內容。V8 同樣在尋找合適的分流機制以保證啟動時能在後臺執行緒中執行 JavaScript 編譯過程。

預編譯 JavaScript?

每隔幾年就有人提出引擎應該提供一些處理預編譯指令碼的機制,換言之,開發者可以使用構建工具或者其他服務端工具將指令碼轉化為位元組碼,然後瀏覽器直接執行這些位元組碼即可。從我個人觀點來看,直接傳送位元組碼意味著更大的包體,勢必會增加載入時間;並且我們需要去對程式碼進行簽名以保證能夠安全執行。目前我們對於 V8 的定位是儘可能地避免上文所說的內部重分析以提高啟動時間,而預編譯則會帶來額外的風險。不過我們歡迎大家一起來討論這個問題,雖然 V8 目前專注於提升編譯效率以及推廣利用 Service Worker 快取指令碼程式碼來提升啟動效率。我們在 BlinkOn7 上與 Facebook 以及 Akamai 也討論過預編譯相關內容

Optimize JS 優化

類似於 V8 這樣的 JavaScript 引擎在進行完整的解析之前會對指令碼中的大部分函式進行預解析,這主要是考慮到大部分頁面中包含的 JavaScript 函式並不會立刻被執行。
2022817464-58a1c315d032d_articlex

預編譯能夠通過只處理那些瀏覽器執行所需要的最小函式集合來提升啟動時間,不過這種機制在 IIFE 面前卻反而降低了效率。儘管引擎希望避免對這些函式進行預處理,但是遠不如optimize-js這樣的庫有作用。optimize-js 會在引擎之前對於指令碼進行處理,對於那些立即執行的函式插入圓括號從而保證更快速地執行。這種預處理對於 Browserify, Webpack 生成包體這樣包含了大量即刻執行的小模組起到了非常不錯的優化效果。儘管這種小技巧並非 V8 所希望使用的,但是在當前階段不得不引入相應的優化機制。

總結

啟動階段的效能至關重要,緩慢的解析、編譯與執行時間可能成為你網頁效能的瓶頸所在。我們應該評估頁面在這個階段的時間佔比並且選擇合適的方式來優化。我們也會繼續致力於提升 V8 的啟動效能,盡我所能!

延伸閱讀

相關推薦

JavaScript 啟動效能瓶頸分析解決方案

在 Web 開發中,隨著需求的增加與程式碼庫的擴張,我們最終釋出的 Web 頁面也逐漸膨脹。不過這種膨脹遠不止意味著佔據更多的傳輸頻寬,其還意味著使用者瀏覽網頁時可能更差勁的效能體驗。瀏覽器在下載完某個頁面依賴的指令碼之後,其還需要經過語法分析、解釋與執行這些步驟。而本文則會

Java內部類持有外部類的引用詳細分析解決方案

調用 lai urn star keyword inner android get sta 在Java中內部類的定義與使用一般為成員內部類與匿名內部類,他們的對象都會隱式持有外部類對象的引用,影響外部類對象的回收。 GC只會回收沒有被引用或者根集不可到達的對象(取決於GC算

客戶端網路切換導致應用退回登陸前介面 的故障分析解決方案

故障現象: 使用者使用手機銀行客戶端登入,客戶端處於登入狀態,由WiFi網路切換為手機4G網路,導致手機銀行直接退回到登入前狀態,伺服器日誌顯示該使用者在登入期間出現兩個不同地點的IP。 故障分析: 網路架構如圖所示,當省內某使用者使用聯通WiFi登入手機銀行後,F5將請求轉發到

spark資料傾斜分析解決方案

Spark資料傾斜(資料分佈不均勻) 資料傾斜發生時的現象: 絕大多數task(任務)執行得都非常快,但個別task執行極慢。 OOM(記憶體溢位),這種情況比較少見。 資料傾斜發生的原理 資料傾斜的原理很簡單:在進行shuffle的時候,必須將各個節點上相同的k

AVR燒錯熔絲到恢復的一次經驗----詳細分析解決方案

AVR燒錯熔絲到恢復的一次經驗----詳細分析與解決方案---winsu(ant,ant的筆記的blog)環境目標器件:MEGA64L燒錄軟體:PonyProg2000 (Version  2.06c Beta  Jul 27 2003)燒錄硬體:按

NUMA導致的MySQL伺服器SWAP問題分析解決方案

【SWAP產生原理】 先從swap產生的原理來分析,由於linux記憶體管理比較複雜,下面以問答的方式列了一些重要的點,方便大家理解:  1、swap是如何產生的 swap指的是一個交換分割槽或檔案,主要是在記憶體使用存在壓力時,觸發記憶體回收,這時可能會將部分記憶體的資料交換到swap空間。  2、

高併發場景下的快取+資料庫雙寫不一致問題分析解決方案

1、最初級的快取不一致問題以及解決方案問題:先修改資料庫,再刪除快取,如果刪除快取失敗了,那麼會導致資料庫中是新資料,快取中是舊資料,資料出現不一致。解決思路:先刪除快取,再修改資料庫,如果刪除快取成功了,如果修改資料庫失敗了,那麼資料庫中是舊資料,快取中是空的,那麼資料不會

高併發場景下的快取 資料庫雙寫不一致問題分析解決方案設計

馬上開始去開發業務系統 從哪一步開始做,從比較簡單的那一塊開始做,實時性要求比較高的那塊資料的快取去做 實時性比較高的資料快取,選擇的就是庫存的服務 庫存可能會修改,每次修改都要去更新這個快取資料; 每次庫存的資料,在快取中一旦過期,或者是被清理掉了,前端的ngin

Nginx出現Access Denied的原理分析解決方案

如果你發現Nginx伺服器出現Access Denied我覺得90%的可能性是Nginx配置檔案配置的有些小毛病,網上有些解決方案是修改php-fpm的配置檔案中的security.limit_extensions,在這個引數中增加訪問的副檔名,例如css、js等檔案出現Access Den

哲學家就餐問題的分析解決方案

1.程序互斥與同步,死鎖基本知識 在多道程式環境下,程序有非同步和同步兩種併發執行方式。非同步執行是指執行中的各程序在作業系統的排程下以不可預知的速度向前推進。非同步執行的程序大多沒有時序要求,不存在“執行結果與語句的特定執行順序有關”的條件競爭。然而存在一類

Android 介面滑動卡頓分析解決方案

導致Android介面滑動卡頓主要有兩個原因: 1.UI執行緒(main)有耗時操作 2.檢視渲染時間過長,導致卡頓 目前只講第1點,第二點相對比較複雜待以後慢慢研究。 眾所周知,介面的流暢度主要依賴FPS這個值,這個值是通過(1s/渲染1幀所花費的時間)計算所得,FPS值越大視訊越流暢,所以就需要渲染1幀

Handler記憶體洩露分析解決方案

一、記憶體洩露分析 內部類會有一個指向外部類的引用。 垃圾回收機制中約定,當記憶體中的一個物件的引用計數為0時,將會被回收。 Handler 作為 Android 上的非同步訊息處理機制(好吧,我大多用來進行 worker thread 與 UI

25-02、高併發場景下的快取+資料庫雙寫不一致問題分析解決方案設計

馬上開始去開發業務系統, 從哪一步開始做,從比較簡單的那一塊開始做,實時性要求比較高的那塊資料的快取去做, 實時性比較高的資料快取,選擇的就是庫存的服務, 庫存可能會修改,每次修改都要去更新這個快取資料; 每次庫存的資料,在快取中一旦過期,或者是被清理掉了,前端的nginx服務都會發送請

Android效能全面分析優化方案研究

效能優化是一個持續的過程,要多種手段,一點一點優化,一般是優化影響比較大頭的,再逐步優化小頭的,

77.下拉重新整理MJRefresh和UITableView的section headerView衝突的原因分析解決方案

首先修改MJRefreshHeader.h 中的這個  目的是當HeadView已經處於當前螢幕頂端 時不要執行動畫  直接設定偏移量為64 // 恢復inset和offset if (self.

Android 系統性能優化(30)---Android效能全面分析優化方案研究

5.1、渲染問題先來看看造成應用UI卡頓的常見原因都有哪些?1、人為在UI執行緒中做輕微耗時操作,導致UI執行緒卡頓;2、佈局Layout過於複雜,無法在16ms內完成渲染;3、同一時間動畫執行的次數過多,導致CPU或GPU負載過重;4、View過度繪製,導致某些畫素在同一幀時間內被繪製多次,從而使CPU或G

高併發場景下快取+資料庫雙寫不一致問題分析解決方案設計

能堅持別人不能堅持的,才能擁有別人不能擁有的。   文章首發於左上角公眾號,同步到部落格園會延遲一到兩天。 關注程式設計大道公眾號,讓我們一同堅持心中所想,一起成長!! Redis是企業級系統高併發、高可用架構中非常重要的一個環節。Redis主要解決了關係型資料庫併發量低的問題,有助於緩

啟動VIP報CRS-1028/CRS-0223致使VIP狀態為UNKNOWN故障分析解決

ssi host article 3.6 handle 性能優化 roc ng- 應用程序 CRS版本號為10.2.0.4 1、VIP State為UNKNOWN [[email protected]/* */ ~]# crs_stat -t

應用記憶體洩露起因解決方案分析

java gc機制 java記憶體管理與c/c++不同,java使用garbage collection機制,由虛擬機器管理記憶體。在大部分虛擬機器(包括android的ART)中,都採用了“可達性”分析演算法來進行記憶體管理。 原理是:選取某幾個root節

redis效能分析監控方案

1、redis slowlog分析2、SCAN,SSCAN,HSCAN和ZSCAN命令的使用方法3、檢查redis是否受到系統使用swap的影響4、使用redis watchdog定位延時5、關於redis的延時監控框架 redis官網資料參見這裡:https://red