在 Web 開發中,隨著需求的增加與程式碼庫的擴張,我們最終發布的 Web 頁面也逐漸膨脹。不過這種膨脹遠不止意味著佔據更多的傳輸頻寬,其也意味著用戶瀏覽網頁時可能更差勁的效能體驗。瀏覽器在下載完某個頁面所依賴的腳本之後,其還需要經過語法分析、解釋與執行這些步驟。而本文則會深入分析瀏覽器對於 JavaScript 的這些處理流程,挖掘出那些影響你應用程式啟動時間的罪魁禍首,並且根據我個人的經驗提出相對應的解決方案。回顧過去,我們還沒有專門考慮過如何去優化JavaScript 解析/編譯這些步驟;我們預想中的是解析器在發現<script>
標籤後會瞬時完成解析操作,不過這很明顯是癡人說夢。下圖是 V8 引擎工作原理的概述:
下面我們深入其中的關鍵步驟進行分析。
在啟動階段,語法分析,編譯與腳本執行佔據了 JavaScript 引擎運行的絕大部分時間。換言之,這些過程造成的延遲會真實地反應到用戶可交互時延上;譬如用戶已經看到了某個按鈕,但是要好幾秒之後才能真正地去點擊操作,這一點會大大影響用戶體驗。
上圖是我們使用Chrome Canary 內建的V8 RunTime Call Stats 對於某個網站的分析結果;需要注意的是桌面瀏覽器中語法解析與編譯佔用的時間還是蠻長的,而在行動端佔用的時間則更長。實際上,對於Facebook, Wikipedia, Reddit 這些大型網站中語法解析與編譯所佔的時間也不容忽視:
上圖中的粉紅色區域表示花費在V8與Blink's C++ 中的時間,而橘色和黃色分別表示語法解析與編譯的時間佔比。 Facebook 的 Sebastian Markbage 與 Google 的 Rob Wormald 也都在 Twitter 發文表示過 JavaScript 的語法解析時間過長已經成為了不可忽視的問題,後者也表示這也是 Angular 啟動時主要的消耗之一。
隨著行動端浪潮的湧來,我們不得不面對一個殘酷的事實:行動端對於相同包體的解析與編譯過程要花費相當於桌面瀏覽器2~5倍的時間。當然,對於高配的iPhone 或Pixel 這樣的手機相較於Moto G4 這樣的中配手機表現會好很多;這一點提醒我們在測試的時候不能只用身邊那些高配的手機,而應該中高低配兼顧:
上圖是部分桌面瀏覽器與行動裝置瀏覽器對於1MB 的JavaScript 包體進行解析的時間對比,顯而易見的可以發現不同配置的行動裝置手機之間的巨大差異。當我們應用包體已經非常巨大的時候,使用一些現代的打包技巧,譬如程式碼分割,TreeShaking,Service Workder 快取等等會對啟動時間有很大的影響。另一個角度來看,即使是小模組,你程式碼寫的很糟糕或使用了很糟糕的依賴函式庫都會導致你的主執行緒花費大量的時間在編譯或冗餘的函數呼叫中。我們必須要清醒地認識到全面評測以挖掘出真正效能瓶頸的重要性。
我曾經不只一次聽到有人說,我又不是 Facebook,你說的 JavaScript 語法解析與編譯到
底會對其他網站造成什麼樣的影響呢?對於這個問題我也很好奇,於是我花了兩個月的時間對於超過 6000 個網站進行分析;這些網站囊括了 React,Angular,Ember,Vue 這些流行的框架或者庫。大部分的測試是基於 WebPageTest 進行的,因此你可以很方便地重現這些測試結果。 光纖接入的桌面瀏覽器大概需要 8 秒的時間才能允許用戶交互,而 3G 環境下的 Moto G4 大概需要 16 秒 才能允許用戶交互。
大部分應用程式在桌面瀏覽器中會耗費約4 秒的時間進行JavaScript 啟動階段(語法解析、編譯、執行):
而在行動裝置瀏覽器中,大概要花額外36% 的時間來進行語法解析:
另外,統計顯示並不是所有的網站都甩給用戶一個龐大的JS 包體,用戶下載的經過Gzip 壓縮的平均包體大小是410KB,這點與HTTPArchive 之前發布的420KB 的數據基本一致。不過最糟糕的網站則是直接甩了 10MB 的腳本給用戶,簡直可怕。
透過上面的統計我們可以發現,包體體積固然重要,但並非唯一因素,語法解析與編譯的耗時也不一定隨著包體體積的成長而線性成長。整體而言小的JavaScript 包體是會載入地更快(忽略瀏覽器、裝置與網路連線的差異),但是同樣200KB 的大小,不同開發者的包體在語法解析、編譯上的時間卻是天差地別,不可同日而語。
開啟Timeline( Performance panel ) > Bottom-Up/Call Tree/Event Log 就會顯示目前網站在語法解析/編譯上的時間佔比。如果你希望得到更完整的訊息,那麼可以打開 V8 的 Runtime Call Stats。在 Canary 中,其位於 Timeline 的 Experims > V8 Runtime Call Stats 下。
開啟about:tracing 頁面,Chrome 提供的底層的追蹤工具允許我們使用disabled-by-default-v8. runtime_stats
來深入了解V8 的時間消耗。 V8 也提供了詳細的指南來介紹如何使用這個功能。
WebPageTest 中Processing Breakdown 頁面在我們啟用Chrome > Capture Dev Tools Timeline 時會自動記錄V8 編譯、EvaluateScript 以及FunctionCall 的時間。我們同樣可以透過指明disabled-by-default-v8.runtime_stats
的方式來啟用 Runtime Call Stats。
更多使用說明參考我的gist。
我們也可以使用 Nolan Lawson 推薦的User Timing API來評估語法解析的時間。不過這種方式可能會受 V8 預解析過程的影響,我們可以藉鏡 Nolan 在 optimize-js 評測中的方式,在腳本的尾部添加隨機字串來解決這個問題。我基於Google Analytics 使用相似的方式來評估真實使用者與裝置造訪網站時候的解析時間:
Etsy 的 DeviceTiming 工具能夠模擬某有些受限環境來評估頁面的語法解析與執行時間。其將本地腳本包裹在了某個儀表工具代碼內從而使我們的頁面能夠模擬從不同的設備中訪問。可以閱讀 Daniel Espeset 的Benchmarking JS Parsing and Execution on Mobile Devices 一文來了解更詳細的使用方式。
減少 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 應用程式就受益於此模式:
不用灰心,你並不是唯一糾結於如何提升啟動時間的人,我們 V8 團隊也一直在努力。我們發現之前的某個評測工具 Octane 是個不錯的對於真實場景的模擬,它在微型框架與冷啟動方面很符合真實的用戶習慣。而基於這些工具,V8 團隊在過去的工作中也實現了大約25% 的啟動性能提升:
本部分我們就會對過去幾年中我們使用的提升語法解析與編譯時間的技巧進行闡述。
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允許在後台執行緒中對非同步腳本執行解析操作,可以對於頁面載入時間有大約 10% 的提升。上文也提到過,這個機制同樣會對同步腳本運作。
這個特性倒是第一次提及,因此V8 會允許所有的腳本,即使阻塞型的<script src=''>
腳本也可以由後台執行緒進行解析。不過缺陷就是目前只有一個 streaming 後台執行緒存在,因此我們建議先解析大的、關鍵性的腳本。在實踐中,我們建議將<script defer>
加入<head>
區塊內,這樣瀏覽器引擎就能夠儘早發現需要解析的腳本,然後將其分配給後台執行緒進行處理。我們也可以查看 DevTools Timeline 來確定腳本是否被後台解析,特別是當你存在某個關鍵性腳本需要解析的時候,更需要確定該腳本是由 streaming 線程解析的。
我們同樣致力於打造更輕量級、更快的解析器,目前V8 主執行緒中最大的瓶頸在於所謂的非線性解析消耗。譬如我們有如下的程式碼片:
(function (global, module) { … })(this, function module() { my functions })
V8 並不知道我們編譯主腳本的時候是否需要module
這個模組,因此我們會暫時放棄編譯它。而當我們打算編譯module
時,我們需要重新分析所有的內部函數。這也就是所謂的 V8 解析時間非線性的原因,任何一個處於 N 層深度的函數都有可能被重新分析 N 次。 V8 已經能夠在首次編譯的時候蒐集所有內部函數的信息,因此在未來的編譯過程中 V8 會忽略所有的內部函數。對於上面這種module
形式的函數會是很大的效能提升,建議閱讀The V8 Parser(s) — Design, Challenges, and Parsing JavaScript Better來取得更多內容。 V8 同樣在尋找適當的分流機制以確保啟動時能在後台執行緒執行 JavaScript 編譯過程。
每隔幾年就有人提出引擎應該提供一些處理預編譯腳本的機制,換言之,開發者可以使用建置工具或其他服務端工具將腳本轉換為字節碼,然後瀏覽器直接執行這些字節碼即可。從我個人觀點來看,直接傳送字節碼意味著更大的包體,勢必會增加載入時間;並且我們需要去對程式碼進行簽名以確保能夠安全運行。目前我們對於 V8 的定位是盡可能避免上文所說的內部重分析以提高啟動時間,而預編譯則會帶來額外的風險。不過我們歡迎大家一起來討論這個問題,雖然 V8 目前專注於提升編譯效率以及推廣利用 Service Worker 快取腳本程式碼來提升啟動效率。我們在 BlinkOn7 上與 Facebook 以及 Akamai 也討論過預編譯相關內容。
類似於V8 這樣的JavaScript 引擎在進行完整的解析之前會對腳本中的大部分函數進行預解析,這主要是考慮到大部分頁面中包含的JavaScript 函數並不會立刻被執行。
預編譯能夠透過只處理那些瀏覽器運行所需的最小函數集合來提升啟動時間,不過這種機制在 IIFE 面前卻反而降低了效率。儘管引擎希望避免對這些函數進行預處理,但遠不如optimize-js這樣的函式庫有作用。 optimize-js 會在引擎之前對於腳本進行處理,對於那些立即執行的函數插入圓括號從而保證更快速地執行。這種預處理對於 Browserify, Webpack 生成包體這樣包含了大量即時執行的小模組起到了非常不錯的優化效果。儘管這種小技巧並非 V8 所希望使用的,但是在當前階段不得不引入相應的優化機制。
啟動階段的效能至關重要,緩慢的解析、編譯與執行時間可能成為你網頁效能的瓶頸所在。我們應該評估頁面在這個階段的時間佔比並且選擇合適的方式來優化。我們也會繼續致力於提升 V8 的啟動效能,盡我所能!
以上就是JavaScript 啟動效能瓶頸分析與解決方案 的內容,更多相關內容請關注PHP中文網(m.sbmmt.com)!
#