本文 AI 產出,尚未審核

JavaScript – 效能與最佳化(Performance & Optimization)

主題:非同步效能


簡介

在前端開發中,非同步程式碼是提升使用者體驗的關鍵。無論是向伺服器請求資料、讀取大型檔案,或是執行計算密集的演算法,都會透過 Promiseasync/awaitWeb Worker 等機制交給瀏覽器的事件迴圈(event loop)來處理。若非同步流程寫得不當,雖然表面上看起來是「不會阻塞 UI」,但實際上仍可能造成 記憶體洩漏、過度的 I/O 呼叫、或是 CPU 飽和,最終導致頁面卡頓、電池耗電加速,甚至瀏覽器崩潰。

本篇文章將從 概念、實作範例、常見陷阱與最佳實踐 三個層面,說明如何在 JavaScript 中掌握非同步效能,讓你的應用程式在大量並發請求或重度計算時仍能保持流暢。


核心概念

1. 事件迴圈與任務隊列

JavaScript 執行環境只有一條執行緒,所有非同步工作都是透過 事件迴圈(event loop)與 任務隊列(task queue)協調。

  • 宏任務(macrotask)setTimeoutsetIntervalI/O(fetch、XMLHttpRequest)等。
  • 微任務(microtask)Promise.thenPromise.catchqueueMicrotask

重點:微任務會在同一輪事件迴圈結束前全部執行完畢,若大量微任務堆疊,會阻塞後續的宏任務(例如 UI repaint),造成畫面卡頓。

2. Promise 串接 vs. 並行(Parallel)

串接(串列)

// 每一次請求都等前一次完成
function fetchSequential(urls) {
  let result = [];
  return urls.reduce((p, url) => {
    return p.then(() => fetch(url))
            .then(res => res.json())
            .then(data => result.push(data));
  }, Promise.resolve()).then(() => result);
}

此寫法簡單易讀,但若 urls 數量多,總執行時間會是 N × 單次請求時間

並行(Parallel)

// 同時發起所有請求,等全部完成
async function fetchParallel(urls) {
  const promises = urls.map(url => fetch(url).then(r => r.json()));
  return Promise.all(promises);   // 只要有一個失敗就會 reject
}

使用 Promise.all 可以把多筆 I/O 合併,同時利用瀏覽器的連線上限(通常 6~8 個)取得最大吞吐量。

3. async/awaitPromise.all 的最佳組合

async/await 讓非同步程式碼看起來像同步程式,但若直接在迴圈中 await,仍會變成串列

async function fetchWithAwait(urls) {
  const results = [];
  for (const url of urls) {
    const resp = await fetch(url);   // <-- 每次都等前一次
    results.push(await resp.json());
  }
  return results;
}

正確做法:先建立所有 Promise,再一次 await Promise.all

async function fetchOptimized(urls) {
  const promises = urls.map(url => fetch(url).then(r => r.json()));
  return await Promise.all(promises);
}

4. 使用 Web Worker 分擔 CPU 密集工作

瀏覽器的主執行緒負責 UI 渲染與事件處理,若在主執行緒執行大量計算,會直接卡住畫面。Web Worker 允許把計算搬到 背景執行緒,與 UI 完全隔離。

worker.js

// 監聽主執行緒傳來的訊息
self.addEventListener('message', e => {
  const { data } = e;               // 例如要計算的陣列
  const result = heavyComputation(data);
  // 計算完回傳結果
  self.postMessage(result);
});

function heavyComputation(arr) {
  // 假設是大規模的階乘或矩陣運算
  let sum = 0;
  for (let i = 0; i < arr.length; i++) {
    sum += arr[i] * Math.sqrt(arr[i]); // 只是一個示例
  }
  return sum;
}

main.js

const worker = new Worker('worker.js');

worker.postMessage(largeArray);          // 傳遞資料給 worker

worker.addEventListener('message', e => {
  console.log('計算結果:', e.data);     // 取得結果後更新 UI
});

提醒:Worker 與主執行緒之間只能傳遞 可序列化(structured clone) 的資料,避免傳遞過大的物件導致記憶體拷貝成本過高。

5. 防抖(Debounce)與節流(Throttle)控制頻繁觸發的非同步事件

在捲動、輸入框自動完成、視窗大小改變等情境下,若每一次事件都直接發送請求,會產生 大量無效的 HTTP 呼叫,浪費頻寬與 CPU。

Debounce(防抖)

function debounce(fn, wait) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

// 使用範例:搜尋框輸入時才發送請求
const search = debounce(async query => {
  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  const data = await res.json();
  renderResults(data);
}, 300);

Throttle(節流)

function throttle(fn, limit) {
  let lastCall = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastCall >= limit) {
      lastCall = now;
      fn.apply(this, args);
    }
  };
}

// 使用範例:捲動時每 200ms 只觸發一次
window.addEventListener('scroll', throttle(() => {
  console.log('檢查是否需要載入更多內容');
}, 200));

6. 利用 requestIdleCallback 延遲次要任務

requestIdleCallback 允許開發者把 非即時可容忍延遲 的工作(例如預先載入資料或緩存)交給瀏覽器在「空閒時間」執行,減少對主要渲染流程的衝擊。

function preloadImages(urls) {
  requestIdleCallback(deadline => {
    while (deadline.timeRemaining() > 0 && urls.length) {
      const img = new Image();
      img.src = urls.shift();   // 取出一張圖載入
    }
  });
}

注意requestIdleCallback 並非所有瀏覽器都支援,若需要相容性,可自行實作 fallback(例如 setTimeout)。


常見陷阱與最佳實踐

陷阱 可能的影響 解決方案
過度使用微任務(大量 Promise.resolve().then 事件迴圈被微任務佔滿,UI repaint 被延遲 控制微任務數量,必要時改用 setTimeoutrequestIdleCallback
忽略錯誤處理(未加 .catchtry/catch Promise 被拒絕時產生未捕獲的例外,導致程式碼中斷 為每個非同步呼叫加入錯誤處理,或使用 Promise.allSettled
一次性大量傳遞大物件至 Worker 產生大量記憶體拷貝,導致 GC 壓力 只傳遞必要的資料,或使用 Transferable(如 ArrayBuffer)避免拷貝
在迴圈內直接 await 變成串列執行,效能大幅下降 先建立 Promise 陣列,再一次 await Promise.all
忘記取消不再需要的請求(例如離開頁面或輸入框被清空) 無效的網路流量、潛在的 race condition 使用 AbortController 取消 fetch,或在 debounce 中清除 pending 的 timer
使用 setInterval 觸發密集的 API 每個間隔都會排入宏任務,若 API 執行時間超過間隔會產生疊加 改用 setTimeout 形成「自我調整」的迴圈,或使用 requestAnimationFrame 配合 UI 更新

最佳實踐要點

  1. 優先使用 Promise.all / Promise.allSettled 來達成真正的並行。
  2. 將 CPU 密集工作交給 Web Worker,保持主執行緒的流暢度。
  3. 針對高頻事件實施 Debounce / Throttle,減少不必要的 I/O。
  4. 使用 AbortController 取消過時的非同步操作,避免資源浪費。
  5. 在可能的情況下利用 requestIdleCallback 把次要任務排到空閒時間。
  6. 監控微任務與宏任務的比例,避免微任務「飢餓」主執行緒的渲染。

實際應用場景

場景 典型問題 建議的效能優化手法
即時搜尋(Autocomplete) 使用者輸入每個字元都發送 API 請求,頻寬浪費且伺服器壓力大 使用 Debounce(300ms)+ AbortController 取消前一次請求
圖片無限捲動(Infinite Scroll) 捲動時不斷觸發 fetch,若一次發送太多請求會導致 UI 卡頓 Throttle 滾動事件 + Promise.all 同時載入多張圖片 + requestIdleCallback 預載下一批
大型資料分析(Data Visualization) 前端需要在瀏覽器內部執行大量統計運算,導致 UI 卡死 把計算搬到 Web Worker,完成後使用 postMessage 更新圖表
多語系字串載入 首次載入時同時請求 20+ 語系 JSON,會產生大量 HTTP 連線 依照使用者語系 懶載(lazy load)或一次 Batch 請求(Promise.all
背景同步(Background Sync) 使用 Service Worker 於離線後同步大量資料,若一次全部上傳會耗盡帶寬 使用 分塊上傳(Chunked upload)+ requestIdleCallback 控制上傳節奏

總結

非同步程式碼是 JavaScript 開發不可或缺的一環,但效能往往不在「是否使用 async」上,而在 如何協調事件迴圈、任務排程與資源使用。本文重點回顧如下:

  1. 了解微任務 vs. 宏任務,避免微任務堆積造成 UI 延遲。
  2. 使用 Promise.all 取代迴圈中的 await,讓 I/O 真正並行。
  3. CPU 密集工作交給 Web Worker,保持主執行緒的渲染流暢。
  4. Debounce / Throttle 是控制高頻非同步事件的第一道防線。
  5. AbortControllerrequestIdleCallbackTransferable 等 API 為資源回收與排程提供更細緻的控制。

掌握這些概念與技巧,你就能在 大量資料請求、即時互動以及計算密集型應用 中,寫出既 易讀高效 的 JavaScript 程式碼。祝開發順利,程式執行如虎添翼 🚀。