指令碼評估和長時間任務

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

在改善 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。

這一點的重要性在於,假設您使用資訊整合工具來管理實際工作環境的指令碼,並將網頁需要的所有資訊彙整成單一指令碼,如果是您的網站,您應該會調度單一工作來評估該指令碼。這是否不好?不一定,除非該指令碼很大

如要中斷指令碼評估工作,請避免載入大量的 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 模組時,編譯步驟會中斷。在管理長時間工作方面,這確實是個明確的勝利;不過,產生的模組評估工作仍會導致您必須承擔一些不可避免的成本。雖然無論使用何種瀏覽器,ES 模組仍應盡量減少 JavaScript 的發布方式,但有以下優點:

  • 所有模組程式碼都會自動以嚴格模式執行,讓 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() 呼叫在所有主要瀏覽器引擎中的行為都類似:產生的指令碼評估工作會與動態匯入的模組數量相同。

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

網路工作站是特殊的 JavaScript 使用案例。網路背景工作會在主執行緒上註冊,而背景工作內的程式碼會在其自身執行緒上執行。這對註冊網路工作站的程式碼在主執行緒上執行時非常實用,但網路工作站內的程式碼並非如此。這可減少主執行緒壅塞,並有助於讓主執行緒更能回應使用者互動。

除了減少主執行緒的工作量,網路工作者本身也可以在支援模組工作者的瀏覽器中,透過 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 資源提示來規避巢狀模組呼叫,藉此避免網路要求鏈。

結論

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

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

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

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