JavaScript 的時間消耗

發表於2017-12-04

隨著我們的網站越來越依賴 JavaScript, 我們有時會(無意)用一些不易追蹤的方式來傳輸一些(耗時的)東西. 在這篇文章中, 我會介紹一些能讓你的網站在移動裝置上快速載入且可互動的方式.

摘要: 更少的程式碼 = 更少的解析/編譯(時間) + 更少的傳輸(時間) + 更少的解壓(時間)

網路

大多數開發者考慮 JavaScript 的時間消耗時, 都會首先考慮到 JavaScript 的下載和執行消耗. 指令碼傳輸的位元組越多, 花費的時間越長, 使用者連線的就越慢.

network

即使在網路發達的國家, 這也是需要面對的一個問題, 因為使用者有效的網路連線型別不一定就是 3G、4G 或者 Wifi. 你可以連線咖啡店的 Wifi, 也可能連線上一個 2G 網路的蜂窩熱點.

因而, 開發者需要想辦法減少 JavaScript 在網路上的傳輸時間. 我這提供一些參考的方式:

  • 通過程式碼分割(Code Splitting), 只傳輸使用者需要的程式碼.
  • 減少程式碼體積(對於 ES5 可以使用 Uglify; 對於 ES2015, 可以使用 babel-minifyuglify-es)
  • 壓縮程式碼(可以使用 Brotli ~ q11, Zopfli 或 gzip). Brotli 在壓縮比上優於 gzip. 這種方式幫助 CertSimple 網站把指令碼體積減少了 17%, 並幫助 LinkedIn 節省了 4% 的指令碼載入時間.
  • 移除無用的程式碼. 可以通過 DevTools 檢視程式碼覆蓋率情況. 對於程式碼分離, 可以瞭解 tree-shakingClosure Compiler等高階優化方式. 對於公共庫則可以使用一些程式碼優化外掛, 如針對 lodash 的程式碼優化外掛 lodash-babel-plugin, 可用於像 Moment.js 一類庫的優化外掛 ContextReplacementPlugin. 此外, 使用 babel-preset-env & browserlist 可以避免編譯現代瀏覽器已經支援的功能. 部分更高階的開發者可能會細心分析 Webpack bundles 來幫助確定不必要的依賴.
  • 通過快取來優化網路傳輸. 通過 max-ageEtag 等方式來快取指令碼, 減少位元組的傳輸. Service Worker 快取技術能使你的應用具備網路彈性, 並且能使用像 V8 code cache 一樣的特性. 同時, 也可以瞭解下通過 檔案雜湊名 實現長久快取.

cache

解析/編譯

指令碼下載之後, JavaScript 最消耗時間的地方就是 JS 引擎對程式碼的解析/編譯. 在 Chrome DevTools 的效能皮膚中, JS 的解析和編譯是 Scripting time 中的黃色部分.

parse

從 Bottom-Up/Call Tree 可以看到更精確的解析/編譯時間.

parse2

但是, 為什麼會這樣呢?

parse3

花費很長時間去解析/編譯程式碼會嚴重延遲使用者在網站上的可互動時間. 傳輸的指令碼越多, 在網站可互動之前, 就會花費更多的時間去解析/編譯程式碼.

parse4

和指令碼相比, 瀏覽器也會花費很多時間來處理同等大小的圖片(圖片仍需要被解碼). 但是在大多數移動裝置上, JS 更有可能對頁面的互動性產生負面影響.

parse5

當我們談論指令碼的解析和編譯很慢時, 上下文是很重要的–我們說的是普通的手機裝置. 普通使用者的手機是配置低配的 CPU 和 GPU, 可能由於手機記憶體的限制, 也沒有 L2/L3 級快取設定.

JavaScript 效能 一文中, 我注意到在低配手機和高配手機上解析約 1M 被解壓後的指令碼檔案所用的時間是不同的. 對於市面上解析最快的手機和普通手機之間, 大約有 2~5x 的時間差異.

phones

那麼不同配置的手機訪問 CNN.com 又會是怎麼樣的呢?

與普通手機(Moto G4) 需要花費約 13s 來解析/編譯 CNN 網站的 JS 相比, 高配 iPhone 8 僅需要約 4s 時間.這可以顯著地影響使用者與該站點完全互動的速度.

phones2

這突出了測試普通手機裝置(如 Moto G4)的重要性而不僅僅是你口袋裡的手機裝置. 然而, 上下文關係也很重要: 優化網站使用者的硬體裝置和網路環境.

phones3

深入分析真實使用者訪問你的網站所使用的移動裝置型別, 這樣才可能明白他們真實的 CPU/GPU 等硬體約束.

另一方面, 也需要反思我們是否真的傳輸了太多的指令碼?

通過 HTTP Archive 分析約前 500K 網站在移動裝置上傳輸的指令碼大小, 可以發現 50% 的網站需要佔據 14s, 使用者才可以與網站互動, 但是這些網站僅用 4s 時間來解析和編譯 JS.

phones4

在獲取和處理 JS 以及其他資源所需的時間中, 使用者需要在頁面可互動之前等待一段時間, 這一點也不奇怪, 但我們可以在這裡做得更好.

移除頁面上的非關鍵指令碼不僅能減少傳輸時間, 也能減少 CPU 的解析/編譯時間和潛在的記憶體開銷, 這可提高頁面可互動的速度.

執行時間

不僅指令碼的解析和編譯需要時間, 指令碼的執行也需要時間. 長時間的執行時間也會延遲使用者與站點的互動速度.

execute

如果指令碼的執行時間超過 50ms, 那麼可互動時間的延遲將是指令碼下載、編譯和執行指令碼所花費時間的總和. — Alex Russell

為減少指令碼的執行時間, 可以將指令碼分成小塊來執行, 以避免鎖住主執行緒. 可以考慮是否能減少指令碼在執行過程中需要完成的工作量, 如果工作量很多, 就將指令碼分成小塊來分解工作量, 以提高頁面可互動的速度.

降低 JavaScript 交付成本的模式

當你嘗試著降低 JavaScript 的解析/編譯和網路傳輸時間時, 也可以試試基於路由的程式碼分割或 PRPL 模式來降低 JavaScript 的交付成本.

PRPL 是一種通過程式碼分割和快取來優化頁面互動的模式:

PRPL

通過 V8’s Runtime Call Stats, 我們可以分析一些受歡迎移動站以及 PWA 應用的載入時間. 從下圖可以看出, 指令碼解析所需要的時間(橙色部分)是頁面載入中最耗時的一部分:

Call Stats

其它消耗

除上述方式外, JavaScript 還能通過如下方式影響頁面效能:

  • 記憶體. 由於 GC(garbage collection), 頁面可能會頻繁的出現閃現或者卡頓. 當瀏覽器回收記憶體時, JS 的執行會被暫停, 所以 JS 被暫停執行的頻率和瀏覽器回收記憶體的頻率是正相關的, 因此需要避免記憶體洩漏和頻繁的記憶體回收導致的 JS 執行暫停, 保持頁面的流暢度.
  • 在執行期間, 長時間的指令碼執行會阻塞主執行緒而導致頁面沒有響應. 將指令碼的工作量分成多個小塊來執行(使用 requestAnimationFrame()requestIdleCallback() 進行任務排程)可以最小化響應性問題.

Progressive Bootstrapping(逐步引導)

因為優化互動性的成本比較高, 許多網站會考慮去優化內容的可見性. 當 JavaScript Bundles 很大時, 為了減少白屏時間(First paint time), 一些開發者會採用服務端渲染的方式, 當 JS 處理完成之後再將其 “升級” 為事件處理.

但這種方式也是有時間消耗的: 1) 通常會傳送一個很大的 HTML 檔案作為響應, 2) 在 JavaScript 完成處理之前, 頁面可能只有一部分是可互動的.

因而逐步引導可能是一個更好的方式. 瀏覽請請求一個最小化的功能頁面(僅由當前路由需要的 HTML/JS/CSS 組成), 當有更多資源請求時, 應用可以進行資源懶載入, 然後逐步解鎖更多功能.

pwa

Loading code proportionate to what’s in view is the holy grail. PRPL and Progressive Bootstrapping are patterns that can help accomplish this.

參考

相關文章