2019年JavaScript性能優化解析

在日前的PerfMatters 2019大會上,Addy Osmani發表了《JavaScript性能開銷》的演講,本文整理內容如下。

原演講視頻連接:https://youtu.be/X9eRLElSW1c

過去幾年來,瀏覽器解析和編譯腳本的速度已經有了顯著提升,這也改變了JavaScript的性能開銷結構。到了2019年,處理腳本的主要性能開銷體現在了腳本下載和CPU執行時間上。

當瀏覽器的主線程忙於執行JavaScript腳本時可能會拖累用戶交互操作,因此加快腳本執行速度並消除網絡瓶頸能明顯改善用戶體驗。

實用的高層級指南

對Web開發者來說上述事實意味着什麼?首先,解析和編譯工作不像以前那麼慢了。現在開發者做優化時,針對JavaScript包需要關注三大重點:

減少下載時間

  • 控制JavaScript包的大小,面向移動設備時尤其要注意。較小的包可提升下載速度、降低內存使用率並減少CPU開銷。

  • 不要只做一個大包;如果你的包大小超過50-100kB,就把它拆分成幾個小包。(通過HTTP/2多路複用可以同時傳輸多個請求和響應消息,從而減少額外請求的開銷。)

  • 在移動設備上儘量縮減包的大小,這主要是考慮到網絡帶寬,同時也有助於降低內存使用率。

縮短執行時間

  • 儘量避免持續佔用主線程、影響頁面響應速度的長任務。現在腳本下載後的執行時間是主要的性能開銷之一。

避免使用大型內聯腳本

因爲它們仍需在主線程上解析和編譯。可以參考一條經驗法則:如果腳本超過1kb就不用內聯(這也是因爲超過1kB時針對外部腳本的代碼緩存就會啓動了)。

爲什麼要關注下載和執行時間?

爲什麼我們應該關注下載和執行時間的優化工作?因爲在低端網絡中下載時間是影響很大的指標。儘管全球範圍4G(甚至5G)網絡正在普及,但很多人的有效連接類型依舊存在很多起伏;很多時候我們出門在外會感到網速下滑到3G(甚至更糟)的水平上。

JavaScript執行時間在低端手機上也有很大的影響。不同手機的CPU、GPU和散熱限制差異巨大,所以低端和高端手機之間有着顯著的性能差距,嚴重影響JS這種CPU密集任務的性能表現。

數據顯示,在Chrome之類的瀏覽器中加載頁面時,JS的執行時間可以佔到加載總耗時的最多30%。下圖是一臺高端桌面PC從具有典型負載的網站(Reddit.com)中加載頁面的性能分析:

在移動端,典型的中端手機(Moto G4)執行Reddit的JS腳本耗時足足是高端手機(Pixel 3)的3-4倍之久,而低端手機(售價低於100美元的阿爾卡特1x)的耗時更是有6倍之久:

注意:Reddit的桌面和移動端版本不一樣,所以兩個平臺的性能表現無法直接比較。

如果你要着手優化JS腳本的執行時間,請留意可能長時間獨佔UI線程的長任務。就算頁面看起來已經準備就緒了,這些長任務也可能拖累關鍵任務的執行。你可以把這些長任務拆分開來,並安排好各個小任務的加載優先級,這樣就能加快頁面響應並降低輸入延遲。

V8引擎的解析/編譯改進

相比Chrome 60版本,現在V8引擎的JS解析速度提高了兩倍。Chrome還做了一些優化工作讓解析和編譯工作並行化,現在這部分性能開銷已經不再是影響體驗的關鍵因素了。

V8將解析和編譯任務轉到了worker線程上,將主線程上的解析和編譯工作量平均減少了40%(Facebook上爲46%,Pinterest爲62%),最高達到81%(YouTube) 。這是在已有的改進工作基礎上得到的性能提升數字。

還可以對比不同版本V8引擎的性能表現。可以看到Chrome 61解析完Facebook的JS腳本時,Chrome 75已經解析完Facebook和6個Twitter的JS腳本了。

下面來深入瞭解一下這些優化的細節。簡而言之,現在腳本資源可以在worker線程上流式解析和編譯,這意味着:

  • V8可以在不阻塞主線程的情況下解析並編譯JavaScript。

  • 當整個HTML解析器遇到<script>標記後就開始流式處理。遇到阻塞解析器的腳本時HTML解析器暫停,遇到異步腳本時繼續。

  • 實際使用中,大多數網絡條件下V8的腳本解析速度都比下載更快,所以腳本下載完畢後幾毫秒之內V8也完成了解析和編譯工作。

具體來說,較老版本的Chrome會在腳本下載完畢之後纔會開始解析,這種方法很簡單,但並沒有充分利用CPU能力。從41到68版,Chrome會在下載開始時立即在單獨的線程上解析異步和延遲腳本。

到了Chrome 71,我們改成了基於任務的設置方案,讓調度程序同時解析多個異步/延遲腳本。於是主線程解析時間縮短了約20%,在真實網站上測得的TTI/FID總體上提高了約2%。

在Chrome 72中,我們開始使用流式傳輸處理主要的解析任務:現在常規的同步腳本(內聯腳本除外)也會流式處理。當主線程需要基於任務的解析時,我們也不再取消這些解析操作了,從而減少了不必要的重複勞動。

舊版Chrome支持流式解析和編譯,其中來自網絡的腳本源數據必須在轉發到流傳輸器之前進入Chrome的主線程。

結果經常出現的一種情況是,雖然數據已經從網絡傳輸過來了,但是主線程忙於其他任務(如HTML解析、佈局或JavaScript執行等),來不及處理這些數據,所以數據還沒有轉發到流任務上,流解析器只能乾等。

現在我們正嘗試在預加載時開始解析,以前主線程反彈會阻礙這種操作。
https://youtu.be/D1UJgiG4_NI
Leszek Swirski在BlinkOn 10上的演講介紹了相關細節。

DevTools中的改進

此外DevTools中也存在一個問題,它在呈現整個解析任務時會表明自己正在佔用CPU(完全阻塞),但不管解析器是否需要數據(數據需要通過主線程)都會阻塞。當我們從單個流線程轉向多個流傳輸任務時這個問題變得非常明顯。下圖是Chrome 69中的情況。

DevTools呈現解析任務時表明自己正在佔用CPU(完全阻塞)

如圖,“解析腳本”任務需要1.08秒時間。但是解析JavaScript其實沒那麼慢纔對!大部分時間都是在乾等數據通過主線程而已。

Chrome 76顯示的內容就不一樣了:

在Chrome 76中,解析工作被分解爲多個較小的流任務

一般來說,DevTools性能窗格非常適合從宏觀層面分析你的頁面。如果你需要了解更具體的V8性能指標(如JavaScript解析和編譯時間),我們建議使用Chrome跟蹤和運行時調用統計(RCS,https://v8.dev/docs/rcs)。在RCS結果中,Parse-Background和Compile-Background會告訴你在主線程之外解析和編譯JavaScript所花費的時間,而Parse和Compile是針對主線程的指標。

這些改進對現實應用有多大影響?

下面來看一些真實網站的示例以及腳本流的效果。

Reddit.com有幾個超過100kB的JS包,它們包裝在外部函數中,爲主線程帶來了大量懶編譯操作。如上圖所示,主線程耗時會嚴重影響交互體驗。Reddit的大部分時間都花在了主線程上,而worker/後臺線程的使用率很低。

想要做優化的話,他們可以將一些大包拆分成一些不用包裝的小包(比如每個包50KB),這樣每個包可以分別流解析和編譯,並在載入期間減少主線程的解析和編譯時間。

然後是Facebook.com。Facebook使用了292個請求,加載了大約6MB的壓縮JS腳本,其中一些是異步的,一些是預加載的,還有一些是低優先級的。他們的許多腳本都不大,粒度也很小,所以能並行流解析和編譯,改善Background/Worker線程上的整體並行化表現。

但要注意的是,像Facebook或Gmail這樣的老牌應用在桌面端使用這麼多腳本還比較合理,但你的網站可能並不是這種情況。不管怎樣還是要儘量簡化JS包,沒什麼必要的就不要加載了。

雖然大多數JavaScript解析和編譯工作都可以在後臺線程上流式處理,但有些工作還是要跑在主線程上。主線程繁忙時頁面就無法響應用戶輸入了。請密切關注下載和執行代碼的操作對用戶體驗的影響。

注意:目前,並非所有JavaScript引擎和瀏覽器都實現了腳本流這個加載優化方案。但我們仍然相信本文能幫助大家提升整體的應用體驗。

解析JSON的開銷

JSON語法比JavaScript簡單很多,所以前者的解析效率也要高得多。基於這一點,web應用可以提供大型的類似JSON的對象字面量(諸如內聯Redux存儲),取代將數據內聯爲JS對象字面量的做法來提升加載速度,如下所示:

const data = { foo: 42, bar: 1337 }; // 🐌

……它可以用JSON字符串形式表示,然後在運行時進行JSON解析:

const data = JSON.parse('{"foo":42,"bar":1337}'); // 🚀

只要JSON字符串僅被評估一次,那麼相比JavaScript對象字面量,JSON.parse方法就要快得多,冷加載時尤其明顯。

將普通對象字面量用於大量數據時還會帶來一種風險:它們可以被解析兩次!

  1. 字面量預解析時是第一次。

  2. 字面量被懶解析時是第二次。

第一次解析是必須的,可以將對象字面量放在頂層或PIFE中來避免第二次解析。

重複訪問時的解析/編譯情況

V8的(字節)代碼緩存優化可以改善重複訪問時的體驗。首次請求腳本時,Chrome會下載腳本並將其提供給V8編譯,同時將文件存儲在瀏覽器的磁盤緩存中;當第二次請求JS文件時,Chrome從瀏覽器緩存中獲取該文件,並再次將其提供給V8編譯。但這次編譯的代碼被序列化,並作爲元數據附加到緩存的腳本文件中。

V8中的代碼緩存工作原理示意圖

第三次請求腳本時,Chrome從緩存中獲取腳本文件和文件的元數據,並將兩者都交給V8引擎。V8會反序列化元數據來跳過編譯步驟。如果前兩次訪問間隔小於72小時,代碼緩存就會啓動。如果使用服務worker緩存腳本,Chrome也會主動啓用代碼緩存。詳細信息可以參閱web開發者的代碼緩存指南

總結

到了2019年,加載腳本的主要瓶頸在於下載和執行腳本的時間開銷。你可以爲頁面的頂層內容安排一個較小的同步(內聯)腳本包,其餘內容則使用一個或多個延遲腳本。可以把較大的包拆分成許多小包來按需加載。這樣一來就能充分利用V8的並行化能力。

在移動設備上,爲了減少網絡、內存和CPU需求,你需要儘量減少腳本的數量。此外還應仔細調整緩存策略,讓解析和編譯任務儘量在主線程外執行。

參考資料

https://v8.dev/blog/scanner
https://v8.dev/blog/preparser

英文原文:https://v8.dev/blog/cost-of-javascript-2019

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章