在 Web 開發(fā)中唐全,隨著需求的增加與代碼庫的擴張释簿,我們最終發(fā)布的 Web 頁面也逐漸膨脹帖烘。不過這種膨脹遠不止意味著占據(jù)更多的傳輸帶寬鸟辅,其還意味著用戶瀏覽網(wǎng)頁時可能更差勁的性能體驗。瀏覽器在下載完某個頁面依賴的腳本之后淘衙,還需要經(jīng)過語法分析传藏、解釋與運行這些步驟。而本文則會深入分析瀏覽器對于 JavaScript 的這些處理流程彤守,挖掘出那些影響你應(yīng)用啟動時間的罪魁禍首毯侦,并且根據(jù)我個人的經(jīng)驗提出相對應(yīng)的解決方案【叩妫回顧過去侈离,我們還沒有專門地考慮過如何去優(yōu)化 JavaScript 解析/編譯這些步驟;我們預(yù)想中的是解析器在發(fā)現(xiàn)<script>標(biāo)簽后會瞬時完成解析操作筝蚕,不過這很明顯是癡人說夢卦碾。下圖是對于 V8 引擎工作原理的概述:
下面我們深入其中的關(guān)鍵步驟進行分析铺坞。
到底是什么拖慢了我們應(yīng)用的啟動時間?
在啟動階段洲胖,語法分析济榨、編譯與腳本執(zhí)行占據(jù)了 JavaScript 引擎運行的絕大部分時間。換言之绿映,這些過程造成的延遲會真實地反應(yīng)到用戶可交互時延上擒滑;譬如用戶已經(jīng)看到了某個按鈕,但是要好幾秒之后才能真正地去點擊操作叉弦,這一點會大大影響用戶體驗丐一。下圖是我們使用 Chrome Canary 內(nèi)置的 V8 RunTime Call Stats 對于某個網(wǎng)站的分析結(jié)果:
需要注意的是桌面瀏覽器中語法解析與編譯占用的時間還是蠻長的,而在移動端中占用的時間則更長淹冰。實際上库车,對于 Facebook, Wikipedia, Reddit 這些大型網(wǎng)站中語法解析與編譯所占的時間也不容忽視,下圖中的粉色區(qū)域表示花費在 V8 與 Blink's C++ 中的時間樱拴,而橙色和黃色分別表示語法解析與編譯的時間占比:
Facebook 的 Sebastian Markbage 與 Google 的 Rob Wormald 也都在 Twitter 發(fā)文表示過 JavaScript 的語法解析時間過長已經(jīng)成為了不可忽視的問題柠衍,后者還表示這也是 Angular 啟動時主要的消耗之一。
隨著移動端浪潮的涌來疹鳄,我們不得不面對一個殘酷的事實:移動端對于相同包體的解析與編譯過程要花費相當(dāng)于桌面瀏覽器2~5倍的時間拧略。當(dāng)然,對于高配的 iPhone 或者 Pixel 這樣的手機相較于 Moto G4 這樣的中配手機表現(xiàn)會好很多瘪弓;這一點提醒我們在測試的時候不能僅用身邊那些高配的手機,而應(yīng)該中高低配兼顧:
上圖是部分桌面瀏覽器與移動端瀏覽器對于 1MB 的 JavaScript 包體進行解析的時間對比禽最,顯而易見的可以發(fā)現(xiàn)不同配置的移動端手機之間的巨大差異腺怯。當(dāng)我們應(yīng)用包體已經(jīng)非常巨大的時候,使用一些現(xiàn)代的打包技巧川无,譬如代碼分割呛占,TreeShaking,Service Workder 緩存懦趋,等等會對啟動時間有很大的影響晾虑。另一個角度來看,即使是小模塊仅叫,你代碼寫的很糟或者使用了很糟的依賴庫都會導(dǎo)致你的主線程花費大量的時間在編譯或者冗余的函數(shù)調(diào)用中帜篇。我們必須要清醒地認識到全面評測以挖掘出真正性能瓶頸的重要性。
JavaScript 語法解析與編譯是否成為了大部分網(wǎng)站的瓶頸诫咱?
我曾不止一次聽到有人說笙隙,我又不是 Facebook,你說的 JavaScript 語法解析與編譯到底會對其他網(wǎng)站造成什么樣的影響呢坎缭?對于這個問題我也很好奇竟痰,于是我花費了兩個月的時間對于超過 6000 個網(wǎng)站進行分析签钩;這些網(wǎng)站囊括了 React,Angular坏快,Ember铅檩,Vue 這些流行的框架或者庫。大部分的測試是基于 WebPageTest 進行的莽鸿,因此你可以很方便地重現(xiàn)這些測試結(jié)果昧旨。光纖接入的桌面瀏覽器大概需要 8 秒的時間才能允許用戶交互,而 3G 環(huán)境下的 Moto G4 大概需要 16 秒 才能允許用戶交互富拗。
大部分應(yīng)用在桌面瀏覽器中會耗費約 4 秒的時間進行 JavaScript 啟動階段(語法解析臼予、編譯、執(zhí)行):
而在移動端瀏覽器中啃沪,大概要花費額外 36% 的時間來進行語法解析:
另外粘拾,統(tǒng)計顯示并不是所有的網(wǎng)站都甩給用戶一個龐大的 JS 包體,用戶下載的經(jīng)過 Gzip 壓縮的平均包體大小是 410KB创千,這一點與 HTTPArchive 之前發(fā)布的 420KB 的數(shù)據(jù)基本一致缰雇。不過最差勁的網(wǎng)站則是直接甩了 10MB 的腳本給用戶,簡直可怕追驴。
通過上面的統(tǒng)計我們可以發(fā)現(xiàn)械哟,包體體積固然重要,但是其并非唯一因素殿雪,語法解析與編譯的耗時也不一定隨著包體體積的增長而線性增長暇咆。總體而言小的 JavaScript 包體是會加載地更快(忽略瀏覽器丙曙、設(shè)備與網(wǎng)絡(luò)連接的差異)爸业,但是同樣 200KB 的大小,不同開發(fā)者的包體在語法解析亏镰、編譯上的時間卻是天差地別扯旷,不可同日而語。
現(xiàn)代 JavaScript 語法解析 & 編譯性能評測
Chrome DevTools
打開 Timeline( Performance panel ) > Bottom-Up/Call Tree/Event Log 就會顯示出當(dāng)前網(wǎng)站在語法解析/編譯上的時間占比索抓。如果你希望得到更完整的信息钧忽,那么可以打開 V8 的 Runtime Call Stats。在 Canary 中逼肯,其位于 Timeline 的 Experims > V8 Runtime Call Stats 下耸黑。
Chrome Tracing
打開 about:tracing 頁面,Chrome 提供的底層的追蹤工具允許我們使用disabled-by-default-v8.runtime_stats
來深度了解 V8 的時間消耗情況汉矿。V8 也提供了詳細的指南來介紹如何使用這個功能崎坊。
WebPageTest
WebPageTest 中 Processing Breakdown 頁面在我們啟用 Chrome > Capture Dev Tools Timeline 時會自動記錄 V8 編譯、EvaluateScript 以及 FunctionCall 的時間洲拇。我們同樣可以通過指明disabled-by-default-v8.runtime_stats的方式來啟用 Runtime Call Stats奈揍。
User Timing
我們還可以使用 Nolan Lawson 推薦的User Timing API來評估語法解析的時間曲尸。不過這種方式可能會受 V8 預(yù)解析過程的影響,我們可以借鑒 Nolan 在 optimize-js 評測中的方式男翰,在腳本的尾部添加隨機字符串來解決這個問題另患。我基于 Google Analytics 使用相似的方式來評估真實用戶與設(shè)備訪問網(wǎng)站時候的解析時間:
DeviceTiming
Etsy 的 DeviceTiming 工具能夠模擬某些受限環(huán)境來評估頁面的語法解析與執(zhí)行時間。其將本地腳本包裹在了某個儀表工具代碼內(nèi)從而使我們的頁面能夠模擬從不同的設(shè)備中訪問蛾绎±セ可以閱讀 Daniel Espeset 的Benchmarking JS Parsing and Execution on Mobile Devices 一文來了解更詳細的使用方式。
我們可以做些什么以降低 JavaScript 的解析時間租冠?
1.減少 JavaScript 包體體積鹏倘。我們在上文中也提及,更小的包體往往意味著更少的解析工作量顽爹,也就能降低瀏覽器在解析與編譯階段的時間消耗纤泵。
2.使用代碼分割工具來按需傳遞代碼與懶加載剩余模塊。這可能是最佳的方式了镜粤,類似于PRPL這樣的模式鼓勵基于路由的分組捏题,目前被 Flipkart, Housing.com 與 Twitter 廣泛使用。
3.Script streaming: 過去 V8 鼓勵開發(fā)者使用async/defer
來基于script streaming實現(xiàn) 10%-20% 的性能提升肉渴。這個技術(shù)會允許 HTML 解析器將相應(yīng)的腳本加載任務(wù)分配給專門的 script streaming 線程公荧,從而避免阻塞文檔解析。V8 推薦盡早加載較大的模塊同规,畢竟我們只有一個 streamer 線程循狰。
4.評估我們依賴的解析消耗。我們應(yīng)該盡可能地選擇具有相同功能但是加載地更快的依賴券勺,譬如使用 Preact 或者 Inferno 來代替 React晤揣,二者相較于 React 體積更小具有更少的語法解析與編譯時間。Paul Lewis 在最近的一篇文章中也討論了框架啟動的代價朱灿,與 Sebastian Markbage 的說法不謀而合:最好地評測某個框架啟動消耗的方式就是先渲染一個界面,然后刪除钠四,最后進行重新渲染盗扒。第一次渲染的過程會包含了分析與編譯,通過對比就能發(fā)現(xiàn)該框架的啟動消耗缀去。
如果你的 JavaScript 框架支持 AOT(ahead-of-time)編譯模式侣灶,那么能夠有效地減少解析與編譯的時間。Angular 應(yīng)用就受益于這種模式:
現(xiàn)代瀏覽器是如何提高解析與編譯速度的缕碎?
不用灰心褥影,你并不是唯一糾結(jié)于如何提升啟動時間的人,我們 V8 團隊也一直在努力咏雌。我們發(fā)現(xiàn)之前的某個評測工具 Octane 是個不錯的對于真實場景的模擬凡怎,它在微型框架與冷啟動方面很符合真實的用戶習(xí)慣校焦。而基于這些工具,V8 團隊在過去的工作中也實現(xiàn)了大約 25% 的啟動性能提升:
本部分我們就會對過去幾年中我們使用的提升語法解析與編譯時間的技巧進行闡述统倒。
代碼緩存
Chrome 42 開始引入了所謂的代碼緩存的概念寨典,為我們提供了一種存放編譯后的代碼副本的機制,從而當(dāng)用戶二次訪問該頁面時可以避免腳本抓取房匆、解析與編譯這些步驟耸成。除以之外,我們還發(fā)現(xiàn)在重復(fù)訪問的時候這種機制還能避免 40% 左右的編譯時間浴鸿,這里我會深入介紹一些內(nèi)容:
1.代碼緩存會對于那些在 72 小時之內(nèi)重復(fù)執(zhí)行的腳本起作用井氢。
2.對于 Service Worker 中的腳本,代碼緩存同樣對 72 小時之內(nèi)的腳本起作用岳链。
3.對于利用 Service Worker 緩存在 Cache Storage 中的腳本花竞,代碼緩存能在腳本首次執(zhí)行的時候起作用。
總而言之宠页,對于主動緩存的 JavaScript 代碼左胞,最多在第三次調(diào)用的時候其能夠跳過語法分析與編譯的步驟。我們可以通過chrome://flags/#v8-cache-strategies-for-cache-storage來查看其中的差異举户,也可以設(shè)置?js-flags=profile-deserialization運行 Chrome 來查看代碼是否加載自代碼緩存烤宙。不過需要注意的是,代碼緩存機制僅會緩存那些經(jīng)過編譯的代碼俭嘁,主要是指那些頂層的往往用于設(shè)置全局變量的代碼躺枕。而對于類似于函數(shù)定義這樣懶編譯的代碼并不會被緩存,不過 IIFE 同樣被包含在了 V8 中供填,因此這些函數(shù)也是可以被緩存的拐云。
Script Streaming
Script Streaming允許在后臺線程中對異步腳本執(zhí)行解析操作,可以對于頁面加載時間有大概 10% 的提升近她。上文也提到過叉瘩,這個機制同樣會對同步腳本起作用。
這個特性倒是第一次提及粘捎,因此 V8 會允許所有的腳本薇缅,即使阻塞型的<script src=''>腳本也可以由后臺線程進行解析。不過缺陷就是目前僅有一個 streaming 后臺線程存在攒磨,因此我們建議首先解析大的泳桦、關(guān)鍵性的腳本。在實踐中娩缰,我們建議將<script defer>添加到<head>塊內(nèi)灸撰,這樣瀏覽器引擎就能夠盡早地發(fā)現(xiàn)需要解析的腳本,然后將其分配給后臺線程進行處理。我們也可以查看 DevTools Timeline 來確定腳本是否被后臺解析浮毯,特別是當(dāng)你存在某個關(guān)鍵性腳本需要解析的時候完疫,更需要確定該腳本是由 streaming 線程解析的。
語法解析 & 編譯優(yōu)化
我們同樣致力于打造更輕量級亲轨、更快的解析器趋惨,目前 V8 主線程中最大的瓶頸在于所謂的非線性解析消耗。譬如我們有如下的代碼片:
(function (global, module) { … })(this, function module() { my functions })
V8 并不知道我們編譯主腳本的時候是否需要module
這個模塊惦蚊,因此我們會暫時放棄編譯它器虾。而當(dāng)我們打算編譯module
時,我們需要重分析所有的內(nèi)部函數(shù)蹦锋。這也就是所謂的 V8 解析時間非線性的原因兆沙,任何一個處于 N 層深度的函數(shù)都有可能被重新分析 N 次。V8 已經(jīng)能夠在首次編譯的時候搜集所有內(nèi)部函數(shù)的信息莉掂,因此在未來的編譯過程中 V8 會忽略所有的內(nèi)部函數(shù)葛圃。對于上面這種module
形式的函數(shù)會是很大的性能提升,建議閱讀The V8 Parser(s)?—?Design, Challenges, and Parsing JavaScript Better來獲取更多內(nèi)容憎妙。V8 同樣在尋找合適的分流機制以保證啟動時能在后臺線程中執(zhí)行 JavaScript 編譯過程库正。
預(yù)編譯 JavaScript?
每隔幾年就有人提出引擎應(yīng)該提供一些處理預(yù)編譯腳本的機制厘唾,換言之褥符,開發(fā)者可以使用構(gòu)建工具或者其他服務(wù)端工具將腳本轉(zhuǎn)化為字節(jié)碼,然后瀏覽器直接運行這些字節(jié)碼即可抚垃。從我個人觀點來看喷楣,直接傳送字節(jié)碼意味著更大的包體,勢必會增加加載時間鹤树;并且我們需要去對代碼進行簽名以保證能夠安全運行铣焊。目前我們對于 V8 的定位是盡可能地避免上文所說的內(nèi)部重分析以提高啟動時間,而預(yù)編譯則會帶來額外的風(fēng)險罕伯。不過我們歡迎大家一起來討論這個問題曲伊,雖然 V8 目前專注于提升編譯效率以及推廣利用 Service Worker 緩存腳本代碼來提升啟動效率。我們在 BlinkOn7 上與 Facebook 以及 Akamai 也討論過預(yù)編譯相關(guān)內(nèi)容追他。
Optimize JS 優(yōu)化
類似于 V8 這樣的 JavaScript 引擎在進行完整的解析之前會對腳本中的大部分函數(shù)進行預(yù)解析熊昌,這主要是考慮到大部分頁面中包含的 JavaScript 函數(shù)并不會立刻被執(zhí)行。
預(yù)編譯能夠通過只處理那些瀏覽器運行所需要的最小函數(shù)集合來提升啟動時間湿酸,不過這種機制在 IIFE 面前卻反而降低了效率。盡管引擎希望避免對這些函數(shù)進行預(yù)處理灭美,但是遠不如optimize-js這樣的庫有作用推溃。optimize-js 會在引擎之前對于腳本進行處理,對于那些立即執(zhí)行的函數(shù)插入圓括號從而保證更快速地執(zhí)行届腐。這種預(yù)處理對于 Browserify, Webpack 生成包體這樣包含了大量即刻執(zhí)行的小模塊起到了非常不錯的優(yōu)化效果铁坎。盡管這種小技巧并非 V8 所希望使用的蜂奸,但是在當(dāng)前階段不得不引入相應(yīng)的優(yōu)化機制。
總結(jié)
啟動階段的性能至關(guān)重要硬萍,緩慢的解析扩所、編譯與執(zhí)行時間可能成為你網(wǎng)頁性能的瓶頸所在。我們應(yīng)該評估頁面在這個階段的時間占比并且選擇合適的方式來優(yōu)化朴乖。我們也會繼續(xù)致力于提升 V8 的啟動性能祖屏,盡我所能!
延伸閱讀
Planning for Performance
Solving the Web Performance Crisis by Nolan Lawson
JS Parse and Execution Time
Measuring Javascript Parse and Load
Unpacking the Black Box: Benchmarking JS Parsing and Execution on Mobile Devices (slides)
When everything’s important, nothing is!
The truth about traditional JavaScript benchmarks
Do Browsers Parse JavaScript On Every Page Load
查看英文原文:JavaScript Start-up Performance
infoQ中文出處:JavaScript 啟動性能瓶頸分析與解決方案
前端·哈達
好好學(xué)習(xí)买羞,天天向上