JavaScript 效能最佳化
考慮您如何在網站上使用 JavaScript,並思考如何緩解可能由此造成的任何效能問題,這一點非常重要。雖然影像和影片佔平均網站下載位元組的 70% 以上,但就每個位元組而言,JavaScript 產生負面性能影響的潛力更大——它會顯著影響下載時間、渲染效能以及 CPU 和電池使用。本文介紹了最佳化 JavaScript 以提高網站效能的技巧和技術。
| 預備知識 | 已安裝基本軟體,並具備 客戶端 Web 技術 的基礎知識。 |
|---|---|
| 目標 | 瞭解 JavaScript 對 Web 效能的影響以及如何緩解或解決相關問題。 |
最佳化還是不最佳化
在開始最佳化程式碼之前,您應該回答的第一個問題是“我需要最佳化什麼?”。下面討論的一些技巧和技術是很好的實踐,幾乎所有 Web 專案都會受益,而有些只在特定情況下才需要。嘗試將所有這些技術應用到所有地方可能是不必要的,並且可能會浪費您的時間。您應該弄清楚每個專案實際需要哪些效能最佳化。
為此,您需要測量您網站的效能。如前面的連結所示,有幾種不同的方法可以測量效能,其中一些涉及複雜的效能 API。然而,最好的入門方法是學習如何使用內建瀏覽器網路和效能工具等工具,以檢視頁面載入的哪些部分耗時較長並需要最佳化。
最佳化 JavaScript 下載
您能使用的效能最高、阻塞最少的 JavaScript 是您根本不使用的 JavaScript。您應該儘可能少地使用 JavaScript。以下是一些需要牢記的技巧:
- 您並非總是需要框架:您可能熟悉使用 JavaScript 框架。如果您有使用此框架的經驗和信心,並且喜歡它提供的所有工具,那麼它可能是您構建大多數專案的首選工具。但是,框架是 JavaScript 密集型的。如果您正在建立具有很少 JavaScript 需求的相當靜態的體驗,您可能不需要該框架。您可能能夠使用幾行標準 JavaScript 實現您所需的功能。
- 考慮更簡單的解決方案:您可能有一個華麗有趣的解決方案要實現,但要考慮您的使用者是否會欣賞它。他們會喜歡更簡單的東西嗎?
- 刪除未使用的程式碼:這聽起來很明顯,但令人驚訝的是,有多少開發人員忘記清理在開發過程中新增的未使用功能。您需要謹慎而有意地新增和刪除內容。所有指令碼都會被解析,無論是否使用;因此,加快下載速度的一個快速方法是擺脫任何未使用的功能。還要考慮,通常您只會使用框架中可用功能的一小部分。是否可以建立只包含您需要部分的自定義框架構建?
- 考慮內建瀏覽器功能:您可能可以使用瀏覽器已有的功能,而不是透過 JavaScript 建立自己的功能。例如:
您還應該將 JavaScript 分成多個檔案,分別代表關鍵和非關鍵部分。JavaScript 模組 允許您比簡單地使用單獨的外部 JavaScript 檔案更有效地做到這一點。
然後您可以最佳化這些更小的檔案。最小化 可減少檔案中字元的數量,從而減少 JavaScript 的位元組數或大小。Gzipping 進一步壓縮檔案,即使您不最小化程式碼也應該使用它。Brotli 類似於 Gzip,但通常優於 Gzip 壓縮。
您可以手動分割和最佳化程式碼,但通常像 webpack 這樣的模組打包器會做得更好。
處理解析和執行
在檢視本節中的技巧之前,重要的是要討論瀏覽器頁面渲染過程中 JavaScript 的處理位置。當網頁載入時:
- HTML 通常首先解析,按照它在頁面上出現的順序。
- 每當遇到 CSS 時,它都會被解析以理解需要應用於頁面的樣式。在此期間,影像和網頁字型等連結資產開始被獲取。
- 每當遇到 JavaScript 時,瀏覽器會解析、評估並針對頁面執行它。
- 稍後,瀏覽器會根據應用於每個 HTML 元素的 CSS 來計算每個 HTML 元素的樣式。
- 然後將樣式化的結果繪製到螢幕上。
注意: 這只是對發生情況的非常簡化的描述,但它確實能讓您瞭解一些情況。
這裡的關鍵步驟是步驟 3。預設情況下,JavaScript 解析和執行是渲染阻塞的。這意味著瀏覽器會阻止解析 JavaScript 之後出現的任何 HTML,直到指令碼被處理。因此,樣式和繪製也會被阻塞。這意味著您不僅需要仔細考慮下載什麼,還需要考慮何時以及如何執行該程式碼。
接下來的幾節將提供最佳化 JavaScript 解析和執行的實用技術。
儘快載入關鍵資產
如果指令碼非常重要,並且您擔心它由於載入速度不夠快而影響效能,您可以將其載入到文件的 <head> 中:
<head>
...
<script src="main.js"></script>
...
</head>
這可以,但會阻塞渲染。更好的策略是使用 rel="preload" 為關鍵 JavaScript 建立預載入器:
<head>
...
<!-- Preload a JavaScript file -->
<link rel="preload" href="important-js.js" as="script" />
<!-- Preload a JavaScript module -->
<link rel="modulepreload" href="important-module.js" />
...
</head>
預載入 <link> 儘快獲取 JavaScript,而不會阻塞渲染。然後您可以在頁面中的任何地方使用它:
<!-- Include this wherever makes sense -->
<script src="important-js.js"></script>
或者在您的指令碼中,如果是 JavaScript 模組:
import { someFunction } from "important-module.js";
注意: 預載入並不能保證在您包含指令碼時它已經載入,但它確實意味著它會更快開始下載。渲染阻塞時間仍然會縮短,即使沒有完全消除。
延遲執行非關鍵 JavaScript
另一方面,您應該旨在將非關鍵 JavaScript 的解析和執行推遲到稍後需要時。預先載入所有內容會不必要地阻塞渲染。
首先,您可以將 async 屬性新增到您的 <script> 元素中:
<head>
...
<script async src="main.js"></script>
...
</head>
這會導致指令碼與 DOM 解析並行獲取,因此它會同時準備好,並且不會阻塞渲染。
注意: 還有另一個屬性,defer,它會導致指令碼在文件解析後但在觸發 DOMContentLoaded 事件之前執行。這與 async 具有類似的效果。
您也可以根本不載入 JavaScript,直到需要它時發生事件。這可以透過 DOM 指令碼來完成,例如:
const scriptElem = document.createElement("script");
scriptElem.src = "index.js";
scriptElem.addEventListener("load", () => {
// Run a function contained within index.js once it has definitely loaded
init();
});
document.head.append(scriptElem);
JavaScript 模組可以使用 import() 函式動態載入:
import("./modules/myModule.js").then((module) => {
// Do something with the module
});
分解長時間任務
當瀏覽器執行您的 JavaScript 時,它會將指令碼組織成按順序執行的任務,例如發出獲取請求、透過事件處理程式驅動使用者互動和輸入、執行 JavaScript 驅動的動畫等。
大部分操作都發生在主執行緒上,除了在 Web Worker 中執行的 JavaScript。主執行緒一次只能執行一個任務。
當單個任務執行時間超過 50 毫秒時,它被歸類為長時間任務。如果使用者在長時間任務執行時嘗試與頁面互動或請求重要的 UI 更新,他們的體驗將受到影響。預期的響應或視覺更新將被延遲,導致 UI 顯得遲鈍或無響應。
為了緩解這個問題,您需要將長時間任務分解為更小的任務。這為瀏覽器提供了更多機會來執行重要的使用者互動處理或 UI 渲染更新——瀏覽器可以在每個較小的任務之間執行它們,而不是隻在長時間任務之前或之後執行。在您的 JavaScript 中,您可以透過將程式碼分解為單獨的函式來實現這一點。這對於其他幾個原因也很有意義,例如更易於維護、除錯和編寫測試。
例如
function main() {
a();
b();
c();
d();
e();
}
然而,這種結構對主執行緒阻塞沒有幫助。由於所有五個函式都在一個主函式中執行,因此瀏覽器將它們全部作為單個長時間任務執行。
為了解決這個問題,我們傾向於定期執行一個“yield”函式,讓程式碼讓步給主執行緒。這意味著我們的程式碼被分成多個任務,在這些任務的執行之間,瀏覽器有機會處理高優先順序任務,例如更新 UI。此函式的一種常見模式使用 setTimeout() 將執行推遲到單獨的任務中:
function yieldFunc() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
這可以在任務執行器模式中使用,以便在每個任務執行後讓步給主執行緒,如下所示:
async function main() {
// Create an array of functions to run
const tasks = [a, b, c, d, e];
// Loop over the tasks
while (tasks.length > 0) {
// Shift the first task off the tasks array
const task = tasks.shift();
// Run the task
task();
// Yield to the main thread
await yieldFunc();
}
}
為了進一步改進這一點,我們可以在可用時使用 Scheduler.yield() 允許此程式碼在佇列中其他不太關鍵的任務之前繼續執行:
function yieldFunc() {
// Use scheduler.yield() if available
if ("scheduler" in window && "yield" in scheduler) {
return scheduler.yield();
}
// Fall back to setTimeout:
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
處理 JavaScript 動畫
動畫可以提高感知效能,使介面感覺更流暢,並讓使用者在等待頁面載入時感覺正在取得進展(例如載入旋轉器)。然而,更大的動畫和更多的動畫自然需要更多的處理能力來處理,這會降低效能。
最明顯的動畫建議是減少動畫的使用——取消任何非必要的動畫,或者考慮讓您的使用者設定一個偏好來關閉動畫,例如,如果他們使用的是低功耗裝置或電池電量有限的移動裝置。
對於必要的 DOM 動畫,建議儘可能使用 CSS 動畫,而不是 JavaScript 動畫(Web 動畫 API 提供了一種使用 JavaScript 直接掛鉤 CSS 動畫的方法)。使用瀏覽器直接執行 DOM 動畫而不是使用 JavaScript 操作內聯樣式要快得多且效率更高。另請參閱 CSS 效能最佳化 > 處理動畫。
對於無法在 JavaScript 中處理的動畫,例如,動畫 HTML <canvas>,建議使用 Window.requestAnimationFrame() 而不是舊選項,例如 Window.setInterval()。requestAnimationFrame() 方法專門設計用於高效且一致地處理動畫幀,以提供流暢的使用者體驗。基本模式如下所示:
function loop() {
// Clear the canvas before drawing the next frame of the animation
ctx.fillStyle = "rgb(0 0 0 / 25%)";
ctx.fillRect(0, 0, width, height);
// Draw objects on the canvas and update their positioning data
// ready for the next frame
for (const ball of balls) {
ball.draw();
ball.update();
}
// Call requestAnimationFrame to run the loop() function again
// at the right time to keep the animation smooth
requestAnimationFrame(loop);
}
// Call the loop() function once to set the animation running
loop();
您可以在 繪製圖形 > 動畫 中找到畫布動畫的精彩介紹,並在 物件構建實踐 中找到更深入的示例。您還可以在 Canvas 教程 中找到一套完整的畫布教程。
最佳化事件效能
事件對瀏覽器來說跟蹤和處理起來可能很昂貴,尤其是在您持續執行事件時。例如,您可能正在使用 mousemove 事件跟蹤滑鼠位置,以檢查它是否仍在頁面的某個區域內:
function handleMouseMove() {
// Do stuff while mouse pointer is inside elem
}
elem.addEventListener("mousemove", handleMouseMove);
您可能正在頁面中執行一個 <canvas> 遊戲。當滑鼠在畫布內時,您會希望不斷檢查滑鼠移動和游標位置並更新遊戲狀態——包括分數、時間、所有精靈的位置、碰撞檢測資訊等。遊戲結束後,您將不再需要做所有這些事情,事實上,繼續監聽該事件將浪費處理能力。
因此,刪除不再需要的事件監聽器是一個好主意。這可以透過使用 removeEventListener() 來完成:
elem.removeEventListener("mousemove", handleMouseMove);
另一個技巧是儘可能使用事件委託。當您有一些程式碼需要響應使用者與大量子元素中的任何一個進行互動時,您可以在它們的父元素上設定一個事件監聽器。在任何子元素上觸發的事件都會冒泡到它們的父元素,因此您無需單獨為每個子元素設定事件監聽器。需要跟蹤的事件監聽器越少意味著更好的效能。
有關更多詳細資訊和實用示例,請參閱 事件委託。
編寫更高效程式碼的技巧
有幾個通用的最佳實踐可以讓您的程式碼執行得更有效率。
-
減少 DOM 操作:訪問和更新 DOM 的計算成本很高,因此您應該儘量減少 JavaScript 執行的操作量,尤其是在執行持續的 DOM 動畫時(參見上面的處理 JavaScript 動畫)。
-
批次處理 DOM 更改:對於必要的 DOM 更改,您應該將它們分批處理,而不是在每次單獨的更改發生時就立即觸發。這可以減少瀏覽器實際執行的工作量,同時也可以提高感知效能。一次性完成幾次更新而不是持續進行小更新,可以使 UI 看起來更流暢。這裡有一個有用的提示——當您有一大塊 HTML 要新增到頁面時,首先構建整個片段(通常在
DocumentFragment中),然後一次性將其全部附加到 DOM 中,而不是單獨附加每個專案。 -
簡化您的 HTML:您的 DOM 樹越簡單,它被 JavaScript 訪問和操作的速度就越快。仔細考慮您的 UI 需要什麼,並刪除不必要的冗餘。
-
減少迴圈程式碼量:迴圈開銷很大,因此儘可能減少程式碼中迴圈的使用量。在迴圈不可避免的情況下:
-
在不必要時避免執行完整的迴圈,根據需要使用
break或continue語句。例如,如果您正在陣列中搜索特定名稱,一旦找到名稱,您就應該跳出迴圈;沒有必要執行進一步的迴圈迭代:jsfunction processGroup(array) { const toFind = "Bob"; for (let i = 0; i < array.length - 1; i++) { if (array[i] === toFind) { processMatchingArray(array); break; } } } -
執行只需要一次的工作,放在迴圈之外。這聽起來有點明顯,但很容易被忽視。看下面的程式碼片段,它獲取一個包含資料需要以某種方式處理的 JSON 物件。在這種情況下,
fetch()操作在迴圈的每次迭代中完成,這浪費了計算能力。不依賴於i的獲取可以移到迴圈之外,這樣只需執行一次。jsasync function returnResults(number) { for (let i = 0; i < number; i++) { const response = await fetch(`/results?number=${number}`); const results = await response.json(); processResult(results[i]); } }
-
-
在主執行緒之外執行計算:前面我們討論了 JavaScript 通常如何在主執行緒上執行任務,以及長時間操作如何阻塞主執行緒,可能導致糟糕的 UI 效能。我們還展示瞭如何將長時間任務分解為更小的任務,從而緩解這個問題。解決此類問題的另一種方法是將任務完全移出主執行緒。有幾種方法可以實現這一點:
- 使用非同步程式碼:非同步 JavaScript 基本上是不會阻塞主執行緒的 JavaScript。非同步 API 傾向於處理從網路獲取資源、訪問本地檔案系統上的檔案或開啟使用者網路攝像頭流等操作。由於這些操作可能需要很長時間,因此在等待它們完成時阻塞主執行緒會很糟糕。相反,瀏覽器執行這些函式,保持主執行緒執行後續程式碼,並且這些函式將在未來的某個時刻可用時返回結果。現代非同步 API 是基於
Promise的,這是一個為處理非同步操作而設計的 JavaScript 語言特性。如果您有需要非同步執行的功能,可以編寫自己的基於 Promise 的函式。 - 在 Web Worker 中執行計算:Web Worker 是一種機制,允許您開啟一個單獨的執行緒來執行一段 JavaScript,這樣它就不會阻塞主執行緒。Worker 有一些主要的限制,最大的限制是您不能在 Worker 中進行任何 DOM 指令碼操作。您可以做大多數其他事情,Worker 可以向主執行緒傳送和接收訊息。Worker 的主要用例是如果您有大量計算要做,並且您不希望它阻塞主執行緒。在 Worker 中進行計算,等待結果,並在準備好時將其傳送回主執行緒。
- 使用 WebGPU:WebGPU 是一種瀏覽器 API,允許 Web 開發人員使用底層系統的 GPU(圖形處理單元)來執行高效能計算並繪製可在瀏覽器中渲染的複雜影像。它相當複雜,但可以提供比 Web Worker 更好的效能優勢。
- 使用非同步程式碼:非同步 JavaScript 基本上是不會阻塞主執行緒的 JavaScript。非同步 API 傾向於處理從網路獲取資源、訪問本地檔案系統上的檔案或開啟使用者網路攝像頭流等操作。由於這些操作可能需要很長時間,因此在等待它們完成時阻塞主執行緒會很糟糕。相反,瀏覽器執行這些函式,保持主執行緒執行後續程式碼,並且這些函式將在未來的某個時刻可用時返回結果。現代非同步 API 是基於