JavaScript – 效能與最佳化(Performance & Optimization)
主題:非同步效能
簡介
在前端開發中,非同步程式碼是提升使用者體驗的關鍵。無論是向伺服器請求資料、讀取大型檔案,或是執行計算密集的演算法,都會透過 Promise、async/await、Web Worker 等機制交給瀏覽器的事件迴圈(event loop)來處理。若非同步流程寫得不當,雖然表面上看起來是「不會阻塞 UI」,但實際上仍可能造成 記憶體洩漏、過度的 I/O 呼叫、或是 CPU 飽和,最終導致頁面卡頓、電池耗電加速,甚至瀏覽器崩潰。
本篇文章將從 概念、實作範例、常見陷阱與最佳實踐 三個層面,說明如何在 JavaScript 中掌握非同步效能,讓你的應用程式在大量並發請求或重度計算時仍能保持流暢。
核心概念
1. 事件迴圈與任務隊列
JavaScript 執行環境只有一條執行緒,所有非同步工作都是透過 事件迴圈(event loop)與 任務隊列(task queue)協調。
- 宏任務(macrotask):
setTimeout、setInterval、I/O(fetch、XMLHttpRequest)等。 - 微任務(microtask):
Promise.then、Promise.catch、queueMicrotask。
重點:微任務會在同一輪事件迴圈結束前全部執行完畢,若大量微任務堆疊,會阻塞後續的宏任務(例如 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/await 與 Promise.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 被延遲 | 控制微任務數量,必要時改用 setTimeout 或 requestIdleCallback |
忽略錯誤處理(未加 .catch 或 try/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 更新 |
最佳實踐要點
- 優先使用
Promise.all/Promise.allSettled來達成真正的並行。 - 將 CPU 密集工作交給 Web Worker,保持主執行緒的流暢度。
- 針對高頻事件實施 Debounce / Throttle,減少不必要的 I/O。
- 使用
AbortController取消過時的非同步操作,避免資源浪費。 - 在可能的情況下利用
requestIdleCallback把次要任務排到空閒時間。 - 監控微任務與宏任務的比例,避免微任務「飢餓」主執行緒的渲染。
實際應用場景
| 場景 | 典型問題 | 建議的效能優化手法 |
|---|---|---|
| 即時搜尋(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」上,而在 如何協調事件迴圈、任務排程與資源使用。本文重點回顧如下:
- 了解微任務 vs. 宏任務,避免微任務堆積造成 UI 延遲。
- 使用
Promise.all取代迴圈中的await,讓 I/O 真正並行。 - CPU 密集工作交給 Web Worker,保持主執行緒的渲染流暢。
- Debounce / Throttle 是控制高頻非同步事件的第一道防線。
- AbortController、requestIdleCallback、Transferable 等 API 為資源回收與排程提供更細緻的控制。
掌握這些概念與技巧,你就能在 大量資料請求、即時互動以及計算密集型應用 中,寫出既 易讀 又 高效 的 JavaScript 程式碼。祝開發順利,程式執行如虎添翼 🚀。