JavaScript 效能最佳化

考慮您在網站上如何使用 JavaScript 並思考如何減輕它可能造成的任何效能問題非常重要。雖然影像和影片佔普通網站下載位元組的 70% 以上,但從位元組數來看,JavaScript 對效能負面影響的潛力更大——它會嚴重影響下載時間、渲染效能以及 CPU 和電池使用情況。本文介紹了最佳化 JavaScript 以增強網站效能的技巧和方法。

先決條件 已安裝基本軟體,以及對客戶端 Web 技術的基本瞭解。
目標 瞭解 JavaScript 對網頁效能的影響以及如何減輕或解決相關問題。

是否最佳化

在開始最佳化程式碼之前,您應該回答的第一個問題是“我需要最佳化什麼?”。下面討論的一些技巧和方法是良好的實踐,幾乎可以使任何 Web 專案受益,而另一些技巧和方法僅在特定情況下才需要。試圖在所有地方應用所有這些技巧可能是不必要的,並且可能是浪費時間。您應該弄清楚每個專案中實際上需要哪些效能最佳化。

為此,您需要衡量網站的效能。如前一個連結所示,有多種衡量效能的方法,其中一些方法涉及複雜的效能 API。但是,最好的入門方法是學習如何使用內建瀏覽器網路效能工具,檢視網頁載入的哪些部分花費了很長時間,需要最佳化。

最佳化 JavaScript 下載

您可以使用的最具效能且最不阻塞的 JavaScript 是您根本不使用的 JavaScript。您應該儘可能少地使用 JavaScript。以下是需要牢記的一些技巧

  • 您並不總是需要框架:您可能熟悉使用JavaScript 框架。如果您有經驗並有信心使用此框架,並且喜歡它提供的所有工具,那麼它可能是您構建大多數專案的首選工具。但是,框架是 JavaScript 密集型的。如果您正在建立幾乎靜態的體驗,並且 JavaScript 需求很少,那麼您可能不需要該框架。您可能可以使用幾行標準 JavaScript 來實現您需要的功能。
  • 考慮更簡單的解決方案:您可能有一個炫酷、有趣的解決方案要實施,但請考慮您的使用者是否會欣賞它。他們是否更喜歡更簡單的方案?
  • 刪除未使用的程式碼:這聽起來可能很明顯,但令人驚訝的是,許多開發人員忘記清理開發過程中新增的未使用的功能。您需要仔細謹慎地新增和刪除程式碼。所有指令碼都會被解析,無論它是否被使用;因此,加快下載速度的快速方法是擺脫任何未使用的功能。還要考慮,您通常只會使用框架中可用功能的一小部分。是否可以建立僅包含您所需部分的框架自定義構建?
  • 考慮內建的瀏覽器功能:您可能可以使用瀏覽器已經具有的功能,而不是透過 JavaScript 建立自己的功能。例如

您還應該將 JavaScript 拆分為多個檔案,分別代表關鍵部分和非關鍵部分。JavaScript 模組 允許您比僅使用單獨的外部 JavaScript 檔案更有效地做到這一點。

然後,您可以最佳化這些較小的檔案。縮小 會減少檔案中的字元數,從而減少 JavaScript 的位元組數或權重。Gzipping 會進一步壓縮檔案,即使您沒有縮小程式碼,也應該使用它。Brotli 與 Gzip 類似,但通常壓縮效能優於 Gzip。

您可以手動拆分和最佳化程式碼,但通常像Webpack 這樣的模組打包器會做得更好。

處理解析和執行

在檢視本節中包含的技巧之前,重要的是要談論 JavaScript 在瀏覽器頁面渲染過程中的位置。當載入網頁時

  1. HTML 通常首先解析,按其在頁面上的出現順序解析。
  2. 每當遇到 CSS 時,都會解析它以瞭解需要應用於頁面的樣式。在此期間,開始獲取連結的資產,例如影像和網路字型。
  3. 每當遇到 JavaScript 時,瀏覽器都會解析、評估它並在頁面上執行它。
  4. 稍後,瀏覽器會根據應用於它的 CSS,確定每個 HTML 元素的樣式應如何。
  5. 然後,樣式化的結果會繪製到螢幕上。

注意:這只是發生情況的非常簡化的描述,但它確實讓您有所瞭解。

這裡關鍵的一步是步驟 3。預設情況下,JavaScript 解析和執行是渲染阻塞的。這意味著瀏覽器會阻塞對 JavaScript 遇到的 JavaScript 後面出現的任何 HTML 的解析,直到指令碼被處理完畢。因此,樣式和繪製也會被阻塞。這意味著您不僅需要仔細考慮要下載的內容,還需要考慮何時以及如何執行該程式碼。

接下來的幾節提供了最佳化 JavaScript 解析和執行的有用技術。

儘快載入關鍵資產

如果指令碼確實很重要,並且您擔心它由於載入速度不夠快而影響效能,您可以將它載入到文件的<head>

html
<head>
  ...
  <script src="main.js"></script>
  ...
</head>

這可以正常工作,但會阻塞渲染。更好的策略是使用rel="preload" 為關鍵 JavaScript 建立預載入器

html
<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,不會阻塞渲染。然後,您可以在頁面的任何位置使用它

html
<!-- Include this wherever makes sense -->
<script src="important-js.js"></script>

或者在您的指令碼中,如果是 JavaScript 模組

js
import { function } from "important-module.js";

注意:預載入不保證指令碼在您包含它時會被載入,但它確實意味著它會更快地開始下載。即使渲染阻塞時間沒有完全消除,也會縮短。

延遲非關鍵 JavaScript 的執行

另一方面,您應該儘量推遲解析和執行非關鍵 JavaScript,直到需要時再執行。提前載入所有內容會不必要地阻塞渲染。

首先,您可以將async 屬性新增到<script> 元素中

html
<head>
  ...
  <script async src="main.js"></script>
  ...
</head>

這會導致指令碼與 DOM 解析並行獲取,因此它將在同一時間準備好,不會阻塞渲染。

注意:還有一個屬性defer,它會導致指令碼在文件解析後執行,但在觸發DOMContentLoaded 事件之前執行。這與async 具有類似的效果。

您也可以在需要時才載入 JavaScript,直到事件發生。這可以透過 DOM 指令碼完成,例如

js
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);

可以使用import() 函式動態載入 JavaScript 模組

js
import("./modules/myModule.js").then((module) => {
  // Do something with the module
});

分解長時間任務

當瀏覽器執行您的 JavaScript 時,它會將指令碼組織成按順序執行的任務,例如發出獲取請求,透過事件處理程式驅動使用者互動和輸入,執行 JavaScript 驅動的動畫等等。

大多數操作都在主執行緒上進行,除了在Web Workers 中執行的 JavaScript 之外。主執行緒一次只能執行一項任務。

當一項任務的執行時間超過 50 毫秒時,它就會被分類為一項長時間任務。如果使用者嘗試在長時間任務執行時與頁面互動或請求重要的 UI 更新,他們的體驗將會受到影響。預期的響應或視覺更新將被延遲,導致 UI 顯得遲緩或無響應。

為了緩解這個問題,您需要將長時間任務分解成更小的任務。這會讓瀏覽器有更多機會執行重要的使用者互動處理或 UI 渲染更新——瀏覽器有可能在每個較小的任務之間執行這些操作,而不僅僅是在長時間任務之前或之後。在您的 JavaScript 中,您可能會透過將程式碼分解成單獨的函式來做到這一點。這對於其他幾個原因也說得通,例如更易於維護、除錯和編寫測試。

例如

js
function main() {
  a();
  b();
  c();
  d();
  e();
}

但是,這種結構對主執行緒阻塞沒有幫助。由於所有五個函式都在一個主函式中執行,瀏覽器會將它們全部作為一個長時間任務執行。

為了處理這種情況,我們傾向於定期執行一個“yield”函式,以讓程式碼讓步給主執行緒。這意味著我們的程式碼被拆分成多個任務,在執行這些任務之間,瀏覽器有機會處理高優先順序任務,例如更新 UI。此函式的常見模式使用setTimeout() 將執行推遲到另一個任務中

js
function yield() {
  return new Promise((resolve) => {
    setTimeout(resolve, 0);
  });
}

這可以用於像這樣在任務執行器模式中使用,以便在每個任務執行後讓步給主執行緒

js
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 yield();
  }
}

為了進一步改進這一點,我們可以使用navigator.scheduling.isInputPending() 僅在使用者嘗試與頁面互動時執行yield() 函式

js
async function main() {
  // Create an array of functions to run
  const tasks = [a, b, c, d, e];

  while (tasks.length > 0) {
    // Yield to a pending user input
    if (navigator.scheduling.isInputPending()) {
      await yield();
    } else {
      // Shift the first task off the tasks array
      const task = tasks.shift();

      // Run the task
      task();
    }
  }
}

這使您能夠在使用者積極與頁面互動時避免阻塞主執行緒,從而提供更流暢的使用者體驗。但是,透過僅在必要時讓步,我們可以在沒有使用者輸入需要處理時繼續運行當前任務。這還可以避免任務排在佇列的後面,位於在當前任務之後安排的其他非必需的瀏覽器啟動任務的後面。

處理 JavaScript 動畫

動畫可以提高感知效能,使介面感覺更靈敏,並讓使用者感覺在等待頁面載入時正在取得進展(例如載入動畫)。但是,更大的動畫和更多的動畫自然需要更多的處理能力來處理,這會降低效能。

最明顯的動畫建議是使用更少的動畫——減少任何非必要的動畫,或者考慮讓使用者設定一個偏好設定,讓他們可以選擇關閉動畫,例如,如果他們使用的是低功耗裝置或電池電量有限的移動裝置。

對於基本的 DOM 動畫,建議您儘可能使用 CSS 動畫,而不是 JavaScript 動畫(Web 動畫 API 提供了一種使用 JavaScript 直接掛鉤到 CSS 動畫的方法)。使用瀏覽器直接執行 DOM 動畫,而不是使用 JavaScript 操作內聯樣式,速度更快,效率更高。另請參見 CSS 效能最佳化 > 處理動畫

對於無法在 JavaScript 中處理的動畫,例如,動畫 HTML <canvas>,建議您使用 Window.requestAnimationFrame(),而不是舊選項,例如 setInterval()requestAnimationFrame() 方法專門設計用於高效且一致地處理動畫幀,以提供流暢的使用者體驗。基本模式如下所示

js
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 動畫的良好介紹,以及在 物件構建實踐 中找到更深入的示例。您還可以在 Canvas 教程 中找到完整的 canvas 教程集。

最佳化事件效能

對於瀏覽器而言,跟蹤和處理事件可能很昂貴,尤其是在您持續執行事件時。例如,您可能正在使用 mousemove 事件來跟蹤滑鼠的位置,以檢查它是否仍在頁面的某個區域內

js
function handleMouseMove() {
  // Do stuff while mouse pointer is inside elem
}

elem.addEventListener("mousemove", handleMouseMove);

您可能在頁面中執行 <canvas> 遊戲。當滑鼠在 canvas 內時,您需要不斷檢查滑鼠移動和游標位置,並更新遊戲狀態——包括分數、時間、所有精靈的位置、碰撞檢測資訊等。遊戲結束後,您將不再需要執行所有操作,事實上,繼續監聽該事件將是一種浪費處理能力。

因此,最好刪除不再需要的事件監聽器。這可以使用 removeEventListener() 完成

js
elem.removeEventListener("mousemove", handleMouseMove);

另一個技巧是在任何可能的情況下使用事件委託。當您有一些程式碼需要響應使用者與大量子元素中的任何一個互動時,您可以在其父元素上設定事件監聽器。在任何子元素上觸發的事件都會冒泡到其父元素,因此您無需在每個子元素上單獨設定事件監聽器。更少的事件監聽器意味著更好的效能。

有關更多詳細資訊和有用示例,請參閱 事件委託

編寫更高效程式碼的技巧

有幾個通用的最佳實踐可以讓您的程式碼執行效率更高。

  • 減少 DOM 操作:訪問和更新 DOM 在計算上很昂貴,因此您應該儘量減少 JavaScript 執行的操作量,尤其是在執行持續的 DOM 動畫時(請參見上面的 處理 JavaScript 動畫)。
  • 批次 DOM 更改:對於基本的 DOM 更改,您應該將它們批處理到一起執行的組中,而不是在發生時立即觸發每個單獨的更改。這可以減少瀏覽器實際執行的工作量,還可以提高感知效能。一次性完成多個更新,而不是不斷進行小的更新,可以讓 UI 看起來更流暢。這裡一個有用的技巧是——當您有一大塊 HTML 要新增到頁面時,請先構建整個片段(通常在 DocumentFragment 中),然後將其全部附加到 DOM 中,而不是單獨附加每個專案。
  • 簡化您的 HTML:DOM 樹越簡單,使用 JavaScript 訪問和操作它就越快。仔細考慮 UI 的需求,並刪除不必要的冗餘內容。
  • 減少迴圈程式碼的數量:迴圈很昂貴,因此儘可能減少程式碼中的迴圈使用量。在迴圈不可避免的情況下
    • 避免在不需要的情況下執行整個迴圈,使用 breakcontinue 語句,具體取決於情況。例如,如果您正在陣列中搜索特定名稱,則應在找到名稱後退出迴圈;沒有必要執行進一步的迴圈迭代
      js
      function processGroup(array) {
        const toFind = "Bob";
        for (let i = 0; i < array.length - 1; i++) {
          if (array[i] === toFind) {
            processMatchingArray(array);
            break;
          }
        }
      }
      
    • 將僅需執行一次的操作放到迴圈之外。這聽起來可能有些明顯,但很容易被忽視。以下程式碼片段獲取包含要以某種方式處理的資料的 JSON 物件。在這種情況下,fetch() 操作在迴圈的每次迭代中執行,這是一種浪費計算能力。獲取操作不依賴於 i,可以移動到迴圈之外,這樣就只執行一次。
      js
      async 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 中執行該計算,等待結果,並在結果準備好後將其傳送回主執行緒。
    • 使用 WebGPUWebGPU 是一種瀏覽器 API,允許 Web 開發人員使用底層系統的 GPU(圖形處理單元)來執行高效能計算和繪製可以在瀏覽器中渲染的複雜影像。它相當複雜,但可以提供比 web worker 更好的效能優勢。

另請參閱