指令碼評估和長時間任務

載入指令碼時,瀏覽器需要花費時間評估指令碼,然後才會執行,這可能會導致工作時間過長。瞭解指令碼評估的運作方式,以及如何避免在頁面載入期間造成長時間的工作。

在改善 Interaction to Next Paint (INP) 方面,您會看到的大部分建議都是針對互動本身進行最佳化。舉例來說,最佳化長時間工作指南會討論使用 setTimeout 等技術的訣竅。這些技巧非常實用,因為它們可避免長時間執行的工作,讓主執行緒有更多時間執行其他活動,而非等待單一長時間執行的工作。

不過,如果是因為載入指令碼而產生的長時間工作,該怎麼辦呢?這些工作可能會干擾使用者互動,並影響網頁在載入期間的 INP。本指南將探討瀏覽器如何處理由指令碼評估作業啟動的作業,並說明如何分割指令碼評估作業,讓主要執行緒在網頁載入期間更能回應使用者輸入內容。

如果您分析的應用程式會傳送大量 JavaScript,您可能會看到長時間的工作,而導致問題的元兇會標示為「Evaluate Script」

在 Chrome 開發人員工具的效能分析器中,以視覺化方式呈現的腳本評估工作。工作會在啟動期間造成長時間的任務,導致主執行緒無法回應使用者互動。
如 Chrome 開發人員工具的效能分析器所示,這項工作是指指令碼的評估作業。在這種情況下,工作會導致長時間執行的任務,阻斷主執行緒執行其他工作,包括促進使用者互動的任務。

在瀏覽器中執行 JavaScript 時,指令碼評估是必要的部分,因為 JavaScript 會在執行前即時編譯。評估指令碼時,系統會先剖析指令碼,檢查是否有錯誤。如果剖析器未發現錯誤,則會將指令碼編譯成機器碼,並繼續執行。

雖然必要,但指令碼評估可能會出現問題,因為使用者可能會在網頁初始轉譯後不久嘗試與網頁互動。不過,即使網頁已算繪,也不代表網頁已完成載入。由於網頁忙於評估指令碼,因此載入期間發生的互動可能會延遲。雖然無法保證互動行為會在這個時間點發生 (因為負責處理互動行為的指令碼可能尚未載入),但互動行為可能會依賴「已」就緒的 JavaScript,或是完全不依賴 JavaScript。

指令碼與評估指令碼的工作之間的關係

負責評估指令碼的工作啟動方式,取決於您載入的指令碼是使用一般 <script> 元素載入,還是使用 type=module 載入的模組。由於瀏覽器處理方式可能有所不同,因此我們會說明主要瀏覽器引擎如何處理指令碼評估作業,以及各瀏覽器的評估行為差異。

使用 <script> 元素載入的腳本

指派給評估指令碼的工作數量通常與網頁上的 <script> 元素數量有直接關係。每個 <script> 元素都會啟動工作,以便評估要求的腳本,以便進行剖析、編譯及執行。這適用於以 Chromium 為基礎的瀏覽器、Safari 和 Firefox。

這一點的重要性在於,假設您使用 Bundler 管理正式版指令碼,並將網頁執行所需的所有內容整合到單一指令碼中。如果您的網站屬於這種情況,系統會派送單一工作來評估該指令碼。這是否有什麼問題?不一定,除非該指令碼「非常大」

您可以避免載入大量 JavaScript,並使用額外的 <script> 元素載入更多個別的較小指令碼,藉此分割指令碼評估作業。

雖然您應該盡量在頁面載入期間載入盡可能少的 JavaScript,但分割指令碼可確保您有更多不會阻斷主執行緒的較小工作,而非單一可能阻斷主執行緒的大型工作,或至少比您一開始時的情況減少。

涉及指令碼評估的多項工作,如 Chrome 開發人員工具的效能分析器中顯示的結果。因為載入的是多個較小的指令碼,而不是較少的大型指令碼,因此工作不太可能變成長時間的工作,讓主要執行緒更快回應使用者輸入內容。
由於網頁 HTML 中存在多個 <script> 元素,因此系統會產生多個工作來評估指令碼。這比將一個大型指令碼組合傳送給使用者更佳,因為後者更有可能阻斷主執行緒。

您可以將分割指令碼評估工作視為類似於在互動期間執行的事件回呼期間產生中斷。不過,在執行指令碼評估時,產生機制會將您載入的 JavaScript 拆分為多個較小的指令碼,而不是較少數較大的指令碼,因為較大的指令碼更有可能阻斷主執行緒。

使用 <script> 元素和 type=module 屬性載入的指令碼

您現在可以在瀏覽器中使用 <script> 元素上的 type=module 屬性,原生載入 ES 模組。這種指令碼載入方法可帶來一些開發人員體驗優勢,例如不必轉換正式版使用的程式碼,尤其是與匯入對應表搭配使用時。不過,以這種方式載入指令碼會根據瀏覽器的不同而排定工作。

以 Chromium 為基礎的瀏覽器

在 Chrome 等瀏覽器 (或衍生自 Chrome 的瀏覽器) 中,使用 type=module 屬性載入 ES 模組時,會產生不同類型的作業,這與未使用 type=module 時通常會產生的作業不同。舉例來說,每個模組指令碼的任務都會執行,其中涉及標示為「Compile module」的活動。

在 Chrome 開發人員工具中以視覺化方式呈現的多個工作中的模組編譯作業。
以 Chromium 為基礎的瀏覽器中的模組載入行為。每個模組指令碼都會產生「Compile module」呼叫,在評估前編譯內容。

模組編譯完成後,後續在模組中執行的任何程式碼都會啟動標示為「Evaluate module」的活動。

在 Chrome 開發人員工具的效能面板中,以視覺化方式呈現模組的即時評估結果。
當模組中的程式碼執行時,該模組會立即進行評估。

至少在 Chrome 和相關瀏覽器中,這會導致使用 ES 模組時,編譯步驟會中斷。在管理長時間工作方面,這確實是個明確的勝利;不過,產生的模組評估工作仍會導致您必須承擔一些不可避免的成本。雖然您應盡量減少 JavaScript 的出貨量,但無論瀏覽器為何,使用 ES 模組仍有以下優點:

  • 所有模組程式碼都會自動以嚴格模式執行,讓 JavaScript 引擎進行潛在最佳化,而這在非嚴格模式的情況下無法執行。
  • 使用 type=module 載入的指令碼,預設會視為延遲。您可以在使用 type=module 載入的指令碼中使用 async 屬性,變更這項行為。

Safari 和 Firefox

在 Safari 和 Firefox 中載入模組時,每個模組都會在個別工作中評估。也就是說,您理論上可以將單一頂層模組載入至其他模組,而該模組只包含 static import 陳述式,每個載入的模組都會產生個別的網路要求和工作,用於評估模組。

使用動態 import() 載入的指令碼

動態 import() 是另一種載入指令碼的方法。與必須位於 ES 模組頂端的靜態 import 陳述式不同,動態 import() 呼叫可出現在指令碼的任何位置,以便按需載入 JavaScript 區塊。這種技巧稱為「程式碼分割」

動態 import() 在改善 INP 方面有兩項優點:

  1. 延後載入的模組會減少當時載入的 JavaScript 數量,進而減少啟動期間的主執行緒爭用情形。這麼做可釋放主執行緒,讓執行緒更能回應使用者互動。
  2. 當動態 import() 呼叫發生時,每個呼叫都會將各個模組的編譯和評估作業有效地分開至各自的工作。當然,如果動態 import() 載入非常大的模組,就會啟動相當大的指令碼評估工作,如果互動發生的時間與動態 import() 呼叫同時發生,就可能會影響主執行緒回應使用者輸入內容的能力。因此,請盡可能減少 JavaScript 的載入量。

動態 import() 呼叫在所有主要瀏覽器引擎中的行為皆類似:產生的指令碼評估工作會與動態匯入的模組數量相同。

在網路工作者中載入的腳本

Web worker 是一種特殊的 JavaScript 用途。網路背景工作會在主執行緒上註冊,背景工作內的程式碼會在其自身執行緒上執行。這點非常有用,因為註冊網路 worker 的程式碼會在主執行緒上執行,而網路 worker 中的程式碼則不會。這可減少主執行緒壅塞,並有助於讓主執行緒更能回應使用者互動。

除了減少主執行緒的工作量,網路工作者本身也可以在支援模組工作者的瀏覽器中,透過 importScripts 或靜態 import 陳述式,載入要在工作者內容中使用的外部指令碼。結果是,任何由網頁工作站要求的指令碼都會在主執行緒外評估。

取捨與考量

雖然將指令碼分割成較小的獨立檔案,有助於限制長時間執行的作業,而非載入較少且更大的檔案,但在決定如何分割指令碼時,請務必考量以下幾點。

壓縮效率

壓縮是分割指令碼的因素之一。當程式碼較小時,壓縮效率會稍微降低。較大的程式碼會從壓縮中獲得更多益處。雖然提高壓縮效率有助於盡可能縮短指令碼的載入時間,但您必須確保將指令碼分割成足夠小的片段,以便在啟動期間提供更好的互動性,這點需要取得平衡。

套件匯集器是管理網站所需指令碼輸出大小的絕佳工具:

  • 在 webpack 方面,其 SplitChunksPlugin 外掛程式可以提供協助。如要瞭解可設定的選項,以便管理素材資源大小,請參閱 SplitChunksPlugin 說明文件
  • 對於 Rollupesbuild 等其他組合器,您可以在程式碼中使用動態 import() 呼叫來管理指令碼檔案大小。這些 Bundler 和 webpack 會自動將動態匯入的素材資源分割成各自的檔案,避免初始 Bundle 大小過大。

快取撤銷

快取無效化對網頁在重複造訪時的載入速度有重大影響。如果您發布大型的單體式指令碼套件,在瀏覽器快取方面就會處於劣勢。這是因為當您更新第一方程式碼 (透過更新套件或發布錯誤修正程式) 時,整個套件都會失效,必須重新下載。

分割指令碼不僅可將指令碼評估工作分割成較小的任務,還能提高回訪者從瀏覽器快取中取得更多指令碼的可能性,而非從網路取得。這麼做可加快整體頁面載入速度。

巢狀模組和載入效能

如果您在實際工作環境中發布 ES 模組,並使用 type=module 屬性載入模組,請注意模組巢狀結構可能會影響啟動時間。模組巢狀結構是指 ES 模組靜態匯入另一個 ES 模組,而該模組又靜態匯入另一個 ES 模組的情況:

// a.js
import {b} from './b.js';

// b.js
import {c} from './c.js';

如果 ES 模組未綁定在一起,上述程式碼會產生網路要求鏈結:當 <script> 元素要求 a.js 時,系統會為 b.js 調度另一項網路要求,而這項要求會涉及 c.js另一項要求。避免這種情況的其中一種方法是使用 bundler,但請務必設定 bundler 以分割指令碼,以便分散指令碼評估工作。

如果您不想使用 bundler,另一種解決巢狀模組呼叫的方式,就是使用 modulepreload 資源提示,這樣系統會提前預先載入 ES 模組,以避免網路要求鏈結。

結論

在瀏覽器中最佳化指令碼評估作業無疑是一項棘手的任務。這取決於網站的需求和限制。不過,分割指令碼可將指令碼評估工作分散到許多較小的任務,因此可讓主執行緒更有效率地處理使用者互動,而不會阻斷主執行緒。

總結一下,以下是分割大型指令碼評估工作的一些方法:

  • 使用 <script> 元素載入指令碼時,如果沒有 type=module 屬性,請避免載入非常大的指令碼,因為這類指令碼會啟動耗用大量資源的指令碼評估工作,進而阻斷主執行緒。將指令碼分散到更多 <script> 元素,以分散工作量。
  • 使用 type=module 屬性在瀏覽器中原生載入 ES 模組,即可啟動個別工作,針對每個獨立的模組指令碼進行評估。
  • 使用動態 import() 呼叫,縮減初始套件的大小。這項做法也適用於套件組合器,因為套件組合器會將每個動態匯入的模組視為「分割點」,因此會為每個動態匯入的模組產生個別的腳本。
  • 請務必權衡壓縮效率和快取無效化等取捨。較大的指令碼可壓縮得更好,但在較少工作中執行的機率較高,且可能導致瀏覽器快取無效,進而降低整體快取效率。
  • 如果您原生使用 ES 模組,且未進行捆綁,請使用 modulepreload 資源提示,在啟動期間最佳化模組的載入作業。
  • 如往,請盡量減少 JavaScript 的發布量。

這確實是個平衡的做法,但透過動態 import() 分割指令碼並減少初始酬載,您就能提升啟動效能,並在關鍵的啟動期間更好地配合使用者互動。這樣一來,您在 INP 指標的得分應該會提高,進而提供更優質的使用者體驗。