建議閱讀本文前先讀完這篇文章:使用Script-Streaming提升頁面加載性能
原文作者:Addy Osmani (@addyosmani[1])
過去幾年中,JavaScript 性能[2]的大幅改進很大程度上依賴于瀏覽器解析和編譯 JavaScript 的速度。2019 年,處理 JavaScript 的主要性能損耗在于下載和 CPU 執行時間。
瀏覽器主線程忙于執行 JavaScript 時,用戶交互會被延遲,因此腳本執行時間和網絡上的瓶頸優化尤其重要。
可行的高級指南
這對于 web 開發者意味著什么?解析和編譯的性能損耗不再像從前我們認為的那樣慢。我們需要關注三點:
提升下載速度
?減小 JavaScript 包的體積,尤其是在移動設備上。更小的包可以提升下載速度,帶來更低的內存占用,并減少 CPU 性能損耗。?避免把代碼打包成一個大文件。如果一個包超過 50–100 kB,把它分割成多個更小的包。(由于 HTTP/2 的多路復用特性,多個請求和響應可以同時到達,從而減少額外請求的負載。)?由于移動設備上的網絡速度,你應該減少網絡傳輸,而且也需要維持更低的內存使用。
?提升執行速度
?避免使主線程忙碌的長任務[3],使頁面快點進行可交互態。腳本執行時間目前成為了一個主要的性能損耗。
?避免大型內聯腳本(因為它們也會在主線程中解析和編譯)一個不錯的規定是:如果腳本超過 1KB,就不要將其內聯(也因為外部腳本的字節碼緩存[4]要求最小為 1KB)。
為何優化下載和執行時間很重要?
為何優化下載和執行時間很重要?下載時間在低端網絡環境下很關鍵。盡管 4G(甚至 5G)在全球范圍快速發展,我們實際感受到的網絡速度[5]和宣傳并不一致,很多時候感覺就像 3G(甚至更差)。
JavaScript 執行時間在使用低端 CPU 的手機上很重要。由于 CPU、GPU 和散熱上的差異,不同手機上性能差異非常大。這會影響到 JavaScript 的性能,因為 JavaScript 的執行是 CPU 密集型任務。
實際上,像 Chrome 這樣的瀏覽器上的頁面加載總時間,有多達 30% 的時間花在 JavaScript 執行上。下面是一個很典型的網站(Reddit.com)在高端桌面設備上的頁面加載,
V8 中的 JavaScript 處理占用了頁面加載時間的 10-30%
移動設備上,中端機(Moto G4)的 JavaScript 執行時間是高端機(Pixel 3)的 3 到 4 倍,低端機(不到100 刀的 Alcatel 1X)上有超過 6 倍的性能差異:
Reddit 在不同設備類型上(低端、中端和高端)的 JavaScript 性能損耗
注意:Reddit 在桌面端和移動端的體驗完全不同,因此 MacBook Pro 上的結果并不能和其他設備上的結果直接做比較。
當你嘗試優化 JavaScript 執行時間,注意關注長任務[6],它可能長期獨占 UI 線程。這些任務會阻塞執行關鍵任務,即便頁面看起來已經加載完成。把長任務拆分成多個小任務。通過代碼分割和指定加載優先級,可以提升頁面可交互速度,并且有希望降低輸入延遲。
長任務獨占主線程,應該拆分它們
V8 在提升解析編譯速度上做了什么?
Chrome 60+ 上,V8 對于初始 JavaScript 的解析速度提升了 2 倍。與此同時, 由于 Chrome 上的其他并行優化,初始解析和編譯的性能損耗更少了。
V8 減少了主線程上的解析編譯任務,平均減少了 40%(比如 Facebook 上是 46%,Pinterest 上是 62%),最高減少了 81%(YouTube),這得益于將解析編譯任務搬到了 worker 線程上。這對于流式解析/編譯[7]是一個補充。
不同 V8 版本上的解析時間
下圖形象呈現了不同 Chrome V8 版本上 CPU 解析時間。Chrome 61 解析 Facebook 的 JS 所花的時間里,Chrome 75 可以解析 Facebook 和6次 Twitter。
我們來研究下這些釋放出來的改變。簡言之,流式解析和 worker 線程編譯腳本,這意味著:
?V8 可以解析編譯 JavaScript 時不阻塞主線程。?流式解析始于整個 HTML 解析器遇到 <script>
標簽。對于阻塞解析的 JS,HTML 解析器會暫停,而異步 JS 會繼續執行。?大多數真實世界的網絡連接速度下,V8 解析比下載快,所以 V8 在 JS 下載完后很快就完成了解析編譯。
稍微解釋下...很老的 Chrome 上會在全部下載完 JS 后才開始解析,這很直接但并沒有完全利用好 CPU。Chrome 41 和 68 之間的版本上,Chrome 在下載一開始就在一個獨立線程上解析 async 和 defer 的 JavaScript。
頁面上的 JavaScript 代碼被分割成多個塊。V8 只會對超過 30KB 的代碼塊進行流式解析。
Chrome 71 上,我們開始做一個基于任務的調整,調度器可以一次解析多個 async/defer 腳本。這一改變使得主線程解析時間減少了 20%,在真實網站上,帶來超過 2% 的 TTI/FID 提升。
譯者注:FID(First Input Delay),第一輸入延遲(FID)測量用戶首次與您的站點交互時的時間(即,當他們單擊鏈接,點擊按鈕或使用自定義的JavaScript驅動控件時)到瀏覽器實際能夠的時間回應這種互動。交互時間(TTI)是衡量應用加載所需時間并能夠快速響應用戶交互的指標。
Chrome 72 上,我們轉向使用流式解析作為主要解析方式:現在一般異步的腳本都以這種方式解析(內聯腳本除外)。我們也停止了廢除基于任務的解析,如果主線程需要的話,因為那樣只是在做不必要的重復工作。
早期版本的 Chrome[8] 支持流式解析和編譯,來自網絡的腳本源數據必須先到達 Chrome 的主線程,然后才會轉發給流處理器。
這常會造成流式解析器等待早已下載完成但還沒有被轉發到流任務的數據,因為它被主線程上的其他任務(比如 HTML 解析,布局或者 JavaScript 執行)所阻塞。
我們目前正在嘗試開始對 preload 的 JS 進行解析,而主線程彈跳會事先對此形成阻塞。
Leszek Swirski 的 BlinkOn 演示[9]呈現了更多細節。
DevTools 上如何查看這些改變?
除了上述之外,DevTools 有個問題[10], 它暗中使用了 CPU,這會影響到整個解析任務的呈現。然而,解析器解析數據時就會阻塞(它需要在主線程上運行)。自從我們從一個單一的流處理線程中移動到流任務中,這一點就變成更為明顯了。下面是你在 Chrome 69 中經常會看到的:
上圖中的“解析腳本”任務花了 1.08 秒。而解析 JavaScript 其實并不慢!多數時間里除了等待數據通過主線程之外什么都不做。
Chrome 76 的表現大不相同:
Chrome 76 上,解析腳本被拆分成多個更小的流式任務。
通常,DevTools 性能面板很適合用來查看頁面上發生的行為。對于更詳細的 V8 特定指標,比如 JavaScript 解析編譯時間,我們推薦使用帶有運行時調用統計(RCS)的 Chrome Tracing[11]。RCS 結果中,Parse-Background
和 Compile-Background
代表主線程以外的線程解析和編譯 JavaScript 花費的時間,然而 Parse
和 Compile
記錄了主線程的指標。
這些改變的真實影響?
來看一些真實網站的 JavaScript 流式解析的應用實例。
MacBook Pro 上主線程和 workder 線程解析編譯 Reddit 的 JS 所花的時間
Reddit.com 有多個 100 KB+ 的代碼包,這些包被包裝在引起主線程大量懶編譯[12]的外部函數中。在上圖中,由于主線程忙碌會延遲可交互時間,其運行時間至關重要。Reddit 花了多數時間在主線程上,Work/Background 線程的利用率很低。
這得益于將大包分割成多個小包(比如每個 50KB),以達到最大并行化,從而每個包都可以被獨立地流式解析編譯,減輕主線程在啟動階段的壓力。
Facebook 在 Macbook Pro 上的主線程和 worker 線程解析編譯時間對比
再來看看 Facebook.com。Facebook通過 292 個請求加載了 6MB 壓縮后的 JS,其中有些是異步的,有些是預加載的,還有些的加載優先級較低。它們很多 JavaScript 的粒度都非常小 - 這對 Background/Worker 線程上的整體并行化很有用,因為這些小的 JavaScript 可以同時被流式解析編譯。
注意,你可能不是 Facebook,很可能沒有一個類似 Facebook 或者 Gmail 這樣的長壽應用,在桌面端,它們放如此多的 JavaScript 是無可非議的。然而,一般來說,應該讓你的包的粒度較粗,并且按需加載。
盡管多數 JavaScript 解析編譯任務可以在 background 線程中以流的形式完成,但是某些任務仍然必須要在主線程中進行。當主線程忙碌時,頁面不能響應用戶輸入。注意關注下載執行代碼對你的用戶體驗造成的影響。
注意:當下,不是所有的 JavaScript 引擎和瀏覽器都實現了 script streaming 來優化加載。但我們相信大家為了優秀用戶體驗會加入這項優化的。
解析 JSON 的性能損耗
由于 JSON 語法比 JavaScript 語法簡單得多,解析 JSON 也會更快。這一點可以用于提升 web 應用的啟動性能,我們可以使用類似 JSON 的對象字面量配置(比如內聯 Redux store)。不要使用 JavaScript 對象字面量來內聯數據,比如這樣:
const data = { foo: 42, bar: 1337 }; // ??
它可以被表示成字符串化的 JSON 格式,運行時會變成解析后的 JSON:
const data = JSON.parse('{"foo":42,"bar":1337}'); // ??
只要 JSON 字符串只被執行一次,尤其是在冷啟動階段,JSON.parse
方法相比 JavaScript 對象字面量會快得多。在大于 10 KB 的對象上使用這個技巧的效果更佳 - 但在實際應用前,還是先要測試下真實效果。
在大型數據上使用普通對象字面量還有個風險:它們可能被解析兩次!
1.第一次發生于字面量預解析階段。2.第二次發生于字面量懶解析階段。
第一次解析無法避免。幸運地,第二次可以通過將對象字面量放在頂層來避免,或者放在 PIFE[13]。
關于重復訪問上的解析/編譯?
V8 的字節碼緩存優化大有幫助。當首次請求 JavaScript,Chrome 下載然后將其交給 V8 編譯。Chrome 也會將文件存進瀏覽器的磁盤緩存中。當 JS 文件再次請求,Chrome 從瀏覽器緩存中將其取出,并再次將其交給 V8 編譯。這個時候,編譯后代碼是序列化后的,會作為元數據被添加到緩存的腳本文件上。
V8 中的字節碼緩存工作示意圖
第三次,Chrome 將文件和文件元數據從緩存中取出,一起交給 V8 處理。V8 對元數據作反序列化,這樣可以跳過編譯。字節碼緩存會在 72 小時內的前兩次訪問生效。配合使用 serive worker 來緩存 JavaScript 代碼,Chrome 的字節碼緩存效果更佳。你可以在給開發者講的字節碼緩存[14]這篇文章中了解到更多細節。
結論
2019 年,下載和執行時間是加載 JavaScript 的主要瓶頸。首屏展示內容里使用異步的(內聯)JavaScript的小型包,頁面剩下部分使用延遲(deferred)加載的 JavaScript。分解大型包,實現代碼按需加載。這樣可以最大化 V8 的并行解析。
移動設備上,考慮到網絡、內存使用和低端 CPU 上的執行時間,你應該傳輸更少的 JavaScript。平衡可緩存性和延遲,實現在主線程之外解析編譯任務數量的最大化。
進一步閱讀
?Blazingly fast parsing, part 1: optimizing the scanner[15]?Blazingly fast parsing, part 2: lazy parsing[16]
References
[1]
@addyosmani: https://twitter.com/addyosmani
[2]
JavaScript 性能: https://medium.com/@addyosmani/the-cost-of-javascript-in-2018-7d8950fbb5d4
[3]
長任務: https://w3c.github.io/longtasks/
[4]
字節碼緩存: https://v8.dev/blog/code-caching-for-devs
[5]
實際感受到的網絡速度: https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/effectiveType
[6]
長任務: https://web.dev/long-tasks-devtools/
[7]
流式解析/編譯: https://blog.skrskrskrskr.com/2018/08/29/%E3%80%90%E8%AF%91%E3%80%91%E4%BD%BF%E7%94%A8Script-Streaming%E6%8F%90%E5%8D%87%E9%A1%B5%E9%9D%A2%E5%8A%A0%E8%BD%BD%E6%80%A7%E8%83%BD/
[8]
早期版本的 Chrome: https://v8.dev/blog/v8-release-75#script-streaming-directly-from-network
[9]
BlinkOn 演示: https://www.youtube.com/watch?v=D1UJgiG4_NI
[10]
DevTools 有個問題: https://bugs.chromium.org/p/chromium/issues/detail?id=939275
[11]
使用帶有運行時調用統計(RCS)的 Chrome Tracing: https://v8.dev/docs/rcs
[12]
懶編譯: https://v8.dev/blog/preparser
[13]
PIFE: https://v8.dev/blog/preparser#pife
[14]
給開發者講的字節碼緩存: https://v8.dev/blog/code-caching-for-devs
[15]
Blazingly fast parsing, part 1: optimizing the scanner: https://v8.dev/blog/scanner
[16]
Blazingly fast parsing, part 2: lazy parsing: https://v8.dev/blog/preparser