載入大型 JavaScript 資源會大幅影響網頁速度。將 JavaScript 分割成較小的區塊,並只下載網頁在啟動期間運作所需的內容,可大幅提升網頁的載入回應速度,進而改善網頁的下次顯示互動 (INP)。
頁面下載、剖析及編譯大型 JavaScript 檔案時,可能會有一段時間沒有回應。網頁元素會顯示出來,因為這些元素是網頁初始 HTML 的一部分,並由 CSS 設定樣式。不過,由於這些互動元素所需的 JavaScript,以及網頁載入的其他指令碼,可能會剖析及執行 JavaScript,才能正常運作。因此使用者可能會覺得互動延遲了許多,甚至完全無法互動。
這是因為系統會在主執行緒上剖析及編譯 JavaScript,因此主執行緒遭到封鎖。如果這個程序耗時過長,互動式網頁元素可能無法及時回應使用者輸入的內容。如要解決這個問題,其中一種方法是只載入網頁運作所需的 JavaScript,並透過程式碼分割技術,延後載入其他 JavaScript。本單元著重介紹後者。
透過程式碼分割,減少啟動期間的 JavaScript 剖析和執行作業
如果 JavaScript 執行時間超過 2 秒,Lighthouse 就會發出警告,如果超過 3.5 秒,就會失敗。在網頁生命週期的任何時間點,過多的 JavaScript 剖析和執行作業都可能造成問題,因為如果使用者與網頁互動的時間,恰好與負責處理及執行 JavaScript 的主要執行緒工作重疊,就可能導致互動的輸入延遲增加。
此外,在網頁初始載入期間,過多的 JavaScript 執行和剖析作業特別容易造成問題,因為使用者很可能在這個網頁生命週期階段與網頁互動。事實上,總阻塞時間 (TBT) (載入回應指標) 與 INP 高度相關,這表示使用者在網頁初次載入期間,很可能會嘗試互動。
Lighthouse 稽核會回報網頁要求的每個 JavaScript 檔案執行時間,有助於找出適合進行程式碼分割的指令碼。接著,您可以使用 Chrome 開發人員工具中的涵蓋率工具,找出網頁載入期間未使用的 JavaScript 部分。
程式碼分割是實用的技巧,可減少網頁的初始 JavaScript 酬載。可將 JavaScript 套件分割為兩部分:
- 網頁載入時需要 JavaScript,因此無法在其他時間載入。
- 其餘 JavaScript 可在稍後載入,通常是在使用者與網頁上的特定互動式元素互動時載入。
您可以使用動態 import()
語法進行程式碼分割。與啟動期間要求特定 JavaScript 資源的 <script>
元素不同,這項語法會在網頁生命週期的稍後階段,要求 JavaScript 資源。
document.querySelectorAll('#myForm input').addEventListener('blur', async () => {
// Get the form validation named export from the module through destructuring:
const { validateForm } = await import('/validate-form.mjs');
// Validate the form:
validateForm();
}, { once: true });
在上述 JavaScript 片段中,只有在使用者模糊表單的任何 <input>
欄位時,系統才會下載、剖析及執行 validate-form.mjs
模組。在這種情況下,負責驅動表單驗證邏輯的 JavaScript 資源只會在最有可能實際使用時,才會與網頁互動。
您可以設定 webpack、Parcel、Rollup 和 esbuild 等 JavaScript 整合工具,在來源程式碼中遇到動態 import()
呼叫時,將 JavaScript 整合包分割成較小的區塊。大多數工具都會自動執行這項作業,但特別是 esbuild,需要您選擇啟用這項最佳化功能。
程式碼分割的實用附註
程式碼分割是減少網頁初始載入期間主執行緒爭用的有效方法,但如果您決定稽核 JavaScript 原始碼,找出程式碼分割的機會,請務必留意以下幾點。
盡可能使用打包工具
開發人員通常會在開發過程中,使用 JavaScript 模組。這項功能可大幅提升開發人員體驗,並改善程式碼的可讀性和可維護性。不過,將 JavaScript 模組運送至實際工作環境時,可能會導致一些效能不佳的特徵。
最重要的是,您應該使用 Bundler 處理及最佳化原始碼,包括您打算進行程式碼分割的模組。封裝工具不僅能有效率地將最佳化措施套用至 JavaScript 原始碼,還能有效率地平衡效能考量,例如封裝大小與壓縮比。壓縮效果會隨著套件大小而提升,但打包工具也會盡量確保套件不會過大,以免因指令碼評估而導致工作時間過長。
此外,打包工具還能避免透過網路傳送大量未打包的模組。使用 JavaScript 模組的架構往往具有龐大複雜的模組樹狀結構。如果模組樹狀結構未經過套件化,每個模組都會代表一個獨立的 HTTP 要求,而如果沒有將模組套件化,網頁應用程式的互動性可能會延遲。雖然可以使用 <link rel="modulepreload">
資源提示盡早載入大型模組樹狀結構,但從載入效能的角度來看,JavaScript 套件仍是較好的選擇。
避免不慎停用串流編譯
Chromium 的 V8 JavaScript 引擎提供多種最佳化功能,確保正式版 JavaScript 程式碼盡可能有效率地載入。其中一項最佳化措施稱為「串流編譯」,與串流至瀏覽器的 HTML 增量剖析類似,會編譯從網路傳輸的 JavaScript 區塊。
您可以透過幾種方式,確保 Chromium 會為您的網頁應用程式進行串流編譯:
- 轉換正式版程式碼,避免使用 JavaScript 模組。封裝工具可以根據編譯目標轉換 JavaScript 原始碼,而目標通常是特定環境。V8 會對未使用模組的任何 JavaScript 程式碼套用串流編譯,您可以設定 Bundler,將 JavaScript 模組程式碼轉換為不使用 JavaScript 模組及其功能的語法。
- 如要將 JavaScript 模組運送至正式版,請使用
.mjs
擴充功能。無論正式版 JavaScript 是否使用模組,使用模組的 JavaScript 與未使用模組的 JavaScript 都沒有特殊的內容類型。就 V8 而言,您使用.js
擴充功能在正式環境中發布 JavaScript 模組時,實際上會選擇不進行串流編譯。如果您使用 JavaScript 模組的.mjs
擴充功能,V8 可確保模組式 JavaScript 程式碼的串流編譯不會中斷。
請勿因為這些考量因素而放棄使用程式碼分割功能。程式碼分割是減少使用者初始 JavaScript 酬載的有效方法,但只要使用打包工具,並瞭解如何保留 V8 的串流編譯行為,就能確保 JavaScript 生產環境程式碼盡可能快速地提供給使用者。
動態匯入示範
webpack
webpack 隨附名為 SplitChunksPlugin
的外掛程式,可讓您設定 Bundler 分割 JavaScript 檔案的方式。webpack 會辨識動態 import()
和靜態 import
陳述式。如要修改 SplitChunksPlugin
的行為,請在其設定中指定 chunks
選項:
chunks: async
是預設值,是指動態import()
呼叫。chunks: initial
是指靜態import
呼叫。chunks: all
涵蓋動態import()
和靜態匯入,讓您在async
和initial
匯入之間共用區塊。
根據預設,每當 webpack 遇到動態 import()
陳述式,就會為該模組建立獨立區塊:
/* main.js */
// An application-specific chunk required during the initial page load:
import myFunction from './my-function.js';
myFunction('Hello world!');
// If a specific condition is met, a separate chunk is downloaded on demand,
// rather than being bundled with the initial chunk:
if (condition) {
// Assumes top-level await is available. More info:
// https://v8.dev/features/top-level-await
await import('/form-validation.js');
}
上述程式碼片段的預設 webpack 設定會產生兩個獨立區塊:
- webpack 會將
main.js
區塊分類為initial
區塊,其中包含main.js
和./my-function.js
模組。 async
區塊,其中只包含form-validation.js
(如果已設定,資源名稱中會包含檔案雜湊)。只有在condition
為真值時,才會下載這個區塊。
這項設定可讓您延遲載入 form-validation.js
區塊,直到實際需要時再載入。這樣做可以減少初始網頁載入期間的指令碼評估時間,進而提升載入回應速度。當符合指定條件時,系統會下載並評估 form-validation.js
區塊的指令碼,並下載動態匯入的模組。舉例來說,只有特定瀏覽器需要下載 Polyfill,或者如先前的範例所示,使用者互動需要匯入的模組。
另一方面,將 SplitChunksPlugin
設定變更為指定 chunks: initial
,可確保程式碼只會分割成初始區塊。這些是靜態匯入的區塊,或列在 webpack 的 entry
屬性中。以上述範例來看,產生的區塊會是單一指令碼檔案中的 form-validation.js
和 main.js
組合,可能導致初始網頁載入效能變差。
您也可以設定 SplitChunksPlugin
的選項,將較大的指令碼分成多個較小的指令碼,例如使用 maxSize
選項,指示 webpack 將超過 maxSize
指定大小的區塊分割成個別檔案。將大型指令碼檔案分割為較小的檔案可以提升載入回應速度,因為在某些情況下,耗用大量 CPU 的指令碼評估工作會分割為較小的任務,因此較不可能長時間封鎖主要執行緒。
此外,產生較大的 JavaScript 檔案也表示指令碼更有可能發生快取失效問題。舉例來說,如果您同時運送架構和第一方應用程式碼,但只有架構更新,則整個套件可能會失效,但套件資源中的其他項目不會失效。
另一方面,指令碼檔案越小,回訪者就越有可能從快取擷取資源,進而加快網頁載入速度。不過,壓縮對較小的檔案效益不大,而且如果瀏覽器快取未預先載入,可能會增加網頁載入時的網路往返時間。請務必在快取效率、壓縮效果和指令碼評估時間之間取得平衡。
webpack 示範
webpack SplitChunksPlugin
demo。
學以致用
執行程式碼分割時,會使用哪種 import
陳述式?
import()
。import
。
哪種import
陳述式必須位於 JavaScript 模組頂端,且不得位於其他位置?
import()
。import
。
在 webpack 中使用 SplitChunksPlugin
時,async
和 initial
區塊有何不同?
async
區塊是使用動態 import()
載入,initial
區塊則是使用靜態 import
載入。
async
區塊是使用靜態 import
載入,initial
區塊則是使用動態 import()
載入。
下一個主題:延遲載入圖片和 <iframe>
元素
雖然 JavaScript 往往是相當昂貴的資源類型,但您可延遲載入的資源類型不只 JavaScript。圖片和 <iframe>
元素本身可能就是耗費資源的項目。與 JavaScript 類似,您可以延遲載入圖片和 <iframe>
元素,這會在課程的下一個單元中說明。