JavaScript – 事件循環(Event Loop)與非同步程式設計
主題:async / await
簡介
在現代的前端與 Node.js 應用程式中,非同步 已成為不可或缺的基礎概念。無論是向伺服器請求資料、讀寫檔案,或是計時器、事件處理,都必須透過非同步方式避免 UI 卡死或伺服器阻塞。
ECMAScript 2017(ES8)正式加入了 async / await 語法,讓原本需要大量 Promise 鏈或回呼函式(callback)的程式碼,變得 更直觀、易讀且易於維護。本單元將從事件循環的運作原理切入,說明 async / await 背後的機制,並提供實務範例、常見陷阱與最佳實踐,協助你在真實專案中安全、有效地使用這套語法。
核心概念
1. 事件循環與任務隊列
JavaScript 是單執行緒語言,但透過 事件循環(Event Loop),它可以同時處理多個非同步任務。簡單來說:
- 呼叫堆疊(Call Stack):同步程式碼依序執行,執行完畢後彈出堆疊。
- 任務隊列(Task Queue):
setTimeout、click之類的事件會被放入此隊列,待 Call Stack 為空時由事件循環取出執行。 - 微任務(Micro‑task):
Promise的.then/.catch、queueMicrotask等會在 同一輪事件循環 的最後、下一輪任務隊列之前執行,優先級高於普通任務。
重點:
async函式在執行時會自動回傳一個 已解決(resolved)或已拒絕(rejected)的Promise,而await會暫停當前的 async 函式,把控制權交還給事件循環,待Promise完成後再回到函式繼續執行。
2. async 與 await 的語法
| 語法 | 說明 |
|---|---|
async function foo() { … } |
宣告一個 async 函式,此函式會自動回傳 Promise。 |
await expression |
必須寫在 async 函式內,暫停執行直到 expression(必須是 Promise)解決或拒絕。若 expression 不是 Promise,會被 立即包裝成已解決的 Promise。 |
注意:
await只會暫停所在的 async 函式,不會阻塞整個執行緒,其他同步程式仍會繼續執行。
3. 基本範例:從回呼到 Promise 再到 async/await
// 1️⃣ 傳統回呼(callback)寫法
function getDataCb(url, cb) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = () => cb(null, xhr.responseText);
xhr.onerror = () => cb(new Error('Network error'));
xhr.send();
}
// 2️⃣ Promise 版
function getDataPromise(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error('Network error'));
xhr.send();
});
}
// 3️⃣ async / await 版(最直觀)
async function getData(url) {
const response = await fetch(url); // fetch 內建回傳 Promise
if (!response.ok) throw new Error('Bad response');
const text = await response.text(); // 再次 await 取得內容
return text; // 仍回傳 Promise
}
// 使用方式(同樣支援 Promise)
getData('https://example.com')
.then(data => console.log(data))
.catch(err => console.error(err));
小結:從回呼到 Promise 再到
async/await,程式碼的層次結構變得更像同步流程,易於除錯與維護。
4. 多個 await 的執行順序
async function sequential() {
console.time('seq');
const a = await fetch('/api/a'); // 第一步:等待完成
const b = await fetch('/api/b'); // 第二步:等 a 完成後才開始
const c = await fetch('/api/c'); // 第三步:等 b 完成後才開始
console.timeEnd('seq');
return [a, b, c];
}
在上述程式碼中,每一次 await 都會等前一個 Promise 完成,因此總耗時大約是三個請求的總和。
5. 並行(Parallel)執行多個非同步作業
若不需要前後相依,可使用 Promise.all 搭配 await 讓請求同時發送:
async function parallel() {
console.time('par');
const [a, b, c] = await Promise.all([
fetch('/api/a'),
fetch('/api/b'),
fetch('/api/c')
]);
console.timeEnd('par'); // 時間約等於最慢的一個請求
return [a, b, c];
}
技巧:把
await放在Promise.all之外,而不是分別寫await fetch(...),才能真正達到 並行 效果。
6. 錯誤處理:try / catch 與 .catch 的差異
async function fetchWithTry(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP error');
const data = await res.json();
return data; // 成功回傳
} catch (err) {
console.error('Fetch failed:', err);
// 可自行拋出或回傳預設值
throw err; // 讓呼叫端再次捕捉
}
}
// 呼叫端可以使用 .catch 或 try/catch
fetchWithTry('/api/data')
.then(d => console.log(d))
.catch(e => console.warn('Handled at top:', e));
使用 try / catch 可以在 同一層級 捕捉多個 await 的錯誤,讓程式碼更簡潔;而直接在 Promise 鏈 上使用 .catch 則適合只想捕捉最外層錯誤的情況。
7. await 與非 Promise 值
async function demo() {
const num = await 42; // 立即轉成已解決的 Promise
console.log(num); // 42
}
demo();
即使傳入的是普通值,await 仍會將其包裝成 Promise.resolve(value),因此不會拋出錯誤,也不會造成額外的非同步延遲。
8. 取消(Cancellation)與 Timeout
async/await 本身不支援取消,但可結合 AbortController 或自行包裝 Promise.race 來實作:
async function fetchWithTimeout(url, ms) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
return await response.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error('Request timed out');
}
throw err;
}
}
常見陷阱與最佳實踐
| 陷阱 | 說明 | 最佳做法 |
|---|---|---|
忘記 await |
忽略 await 會讓 Promise 直接回傳,導致後續程式碼在未完成的狀態下執行。 |
務必檢查 每個需要結果的非同步呼叫是否已加上 await,或使用 Promise.all 一次處理多筆。 |
在迴圈內使用 await 逐一執行 |
會導致 序列化 執行,效能低下。 | 使用 Promise.all 或 for…of 搭配 await Promise.all(promises) 讓迭代 並行。 |
| 未捕捉錯誤 | await 若拋出錯誤,若未包在 try/catch 中,會導致未處理的 Promise Rejection。 |
在每個 async 函式最外層統一捕捉,或在呼叫端使用 .catch。 |
在同步迴圈外部使用 await |
例如 array.map(item => await foo(item)) 會產生語法錯誤。 |
先產生 Promise 陣列:array.map(item => foo(item)),再 await Promise.all([...])。 |
過度使用 async |
把每個小函式都標記為 async,會產生不必要的 Promise 包裝,影響效能與可讀性。 |
只在需要返回 Promise或**使用 await**的函式上加 async。 |
在 finally 中使用 await |
finally 仍屬於同步區塊,await 會被忽略或產生意外行為。 |
若需在 finally 內執行非同步清理,改寫為 try { … } catch { … } finally { await cleanUp(); },或把清理抽成另一個 async 函式在外層 await。 |
建議的程式碼風格
- 統一使用
async/await,盡量避免混用回呼與 Promise。 - 命名慣例:以
fetchXxx、loadXxx類似動詞開頭,表明其返回Promise。 - 保持最小的作用域:只在需要的區塊內使用
await,減少不必要的阻塞。 - 避免深層嵌套:使用
await可以把原本的.then鏈平鋪成直線,若仍有多層嵌套,考慮抽成子函式。 - 使用 Lint 規則(如
eslint-plugin-promise、eslint-plugin-no-async-promise-executor)自動偵測常見錯誤。
實際應用場景
| 場景 | 為何使用 async/await |
|---|---|
| API 串接(例如取得使用者資料、發送表單) | 讓多個請求以 並行 或 序列 的方式寫出,程式碼如同同步流程,易於除錯。 |
| 伺服器端資料庫查詢(Node.js + MongoDB / PostgreSQL) | await db.collection.findOne() 能直接取得結果,避免回呼地獄,且能在 try/catch 中捕捉 DB 錯誤。 |
檔案 I/O(Node.js fs.promises) |
讀寫檔案時使用 await fs.readFile(path),讓程式在等待磁碟 I/O 時不阻塞事件循環。 |
| 串流處理(Web Streams、Node.js Stream) | 透過 for await (const chunk of stream) 直接遍歷非同步資料流,語法直觀。 |
| 測試框架(Jest、Mocha) | 測試非同步函式時直接寫 await,測試結構更簡潔,且能正確捕捉錯誤。 |
| 限速(Rate Limiting)或重試機制 | 結合 await delay(ms) 與 try/catch,實作簡潔的重試迴圈。 |
範例:Node.js 中的批次匯入
const fs = require('fs').promises;
const db = require('./db'); // 假設是一個 Promise 介面的 ORM
async function importCsv(filePath) {
const data = await fs.readFile(filePath, 'utf8');
const rows = data.split('\n').filter(Boolean);
// 逐行插入,使用 transaction 以確保原子性
const client = await db.connect();
try {
await client.query('BEGIN');
const promises = rows.map(row => {
const [name, email] = row.split(',');
return client.query(
'INSERT INTO users(name, email) VALUES($1, $2)',
[name.trim(), email.trim()]
);
});
// 並行寫入
await Promise.all(promises);
await client.query('COMMIT');
console.log('匯入完成');
} catch (err) {
await client.query('ROLLBACK');
console.error('匯入失敗,已回滾', err);
throw err;
} finally {
client.release();
}
}
importCsv('./data/users.csv')
.catch(e => console.error('整體錯誤:', e));
此範例展示了 async/await 結合 transaction、錯誤回滾 與 並行寫入 的完整流程,程式碼結構清晰且易於維護。
總結
async/await是 Promise 的語法糖,讓非同步流程看起來像同步程式,降低認知負擔。- 它的運作依賴 事件循環 與 微任務隊列,
await會暫停所在的async函式,並把控制權交還給事件循環,待Promise解決後再恢復執行。 - 正確使用
await(避免在迴圈內序列化、適時使用Promise.all)可大幅提升效能;錯誤處理 建議以try / catch包圍需要捕捉的區塊。 - 常見陷阱包括忘記
await、在迴圈內錯誤使用await、未捕捉異常等,遵守最佳實踐與 Lint 規則能有效避免。 - 在實務上,從前端 API 呼叫、Node.js 資料庫操作、檔案 I/O 到測試與批次任務,
async/await都是提升程式可讀性與可靠性的關鍵工具。
掌握了這套語法,你就能在 事件循環 的框架下,寫出既 高效 又 易於維護 的非同步程式碼。祝你在 JavaScript 的非同步世界裡玩得開心、寫得順手!