JavaScript – 事件循環(Event Loop)與非同步
主題:非同步錯誤處理
簡介
在 JavaScript 中,非同步程式碼是日常開發不可或缺的一部份──從網路請求、檔案讀寫到計時器、事件監聽,都會透過非同步機制執行。非同步程式碼的好處是能夠避免阻塞主執行緒,讓 UI 保持流暢;然而,錯誤的處理方式若不恰當,則會導致錯誤被悄悄吞掉、程式失去可預測性,甚至讓整個應用崩潰。
本篇文章將深入探討 非同步錯誤處理 的核心概念、常見寫法與陷阱,並提供實務範例與最佳實踐,幫助你在撰寫 Promise、async/await、EventEmitter 等非同步程式時,能夠正確捕獲與回報錯誤,提升程式的韌性與可維護性。
核心概念
1. 為什麼非同步錯誤與同步錯誤不同?
- 同步錯誤(例如
throw new Error())會即時拋出,若不被try…catch包住,就會直接中斷程式執行。 - 非同步錯誤 發生在未來的某個時間點,已經脫離了當下的執行上下文。若使用傳統的
try…catch包住非同步呼叫,它不會捕獲到錯誤,因為錯誤發生時程式已經離開了那個try區塊。
重點:非同步錯誤必須透過 Promise 的 reject、async/await 的 try…catch,或是 事件監聽 的方式來捕獲。
2. Promise 的錯誤傳遞機制
reject:在 Promise 內部使用reject(error)或直接拋出錯誤,都會讓 Promise 進入 rejected 狀態。- 鏈式傳遞:如果在
.then()中拋出錯誤,錯誤會自動傳遞到下一個.catch(),形成錯誤「向下傳遞」的管道。
範例 1:基本的 Promise 錯誤捕獲
function fetchData(url) {
return new Promise((resolve, reject) => {
// 模擬非同步請求
setTimeout(() => {
if (!url.startsWith('https://')) {
// 錯誤情況 → 使用 reject
reject(new Error('URL 必須以 https:// 開頭'));
return;
}
// 成功情況
resolve({ data: `Data from ${url}` });
}, 1000);
});
}
// 使用 Promise
fetchData('http://example.com')
.then(res => console.log(res))
.catch(err => console.error('發生錯誤:', err.message));
說明:
reject會把錯誤傳遞到.catch(),即使錯誤發生在setTimeout的回呼裡,也能被正確捕獲。
3. async/await 與 try…catch
async 函式會自動把回傳值包成 Promise,await 會暫停執行直到 Promise 完成。若 await 的 Promise 被 reject,會拋出例外,此時就可以使用 try…catch 捕獲。
範例 2:async/await 錯誤處理
async function getUser(id) {
try {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
// 手動拋出錯誤,讓 catch 捕獲
throw new Error(`HTTP 錯誤!狀態碼 ${response.status}`);
}
const data = await response.json();
return data;
} catch (err) {
// 集中處理所有非同步錯誤
console.error('取得使用者失敗:', err.message);
// 可以選擇重新拋出或回傳預設值
throw err; // 讓呼叫端再決定如何處理
}
}
// 呼叫端
getUser(123)
.then(user => console.log('使用者資料:', user))
.catch(err => console.warn('最外層錯誤處理:', err.message));
重點:
try…catch必須包在await的上下文裡;若把await放在try之外,錯誤仍會向上拋出,無法被捕獲。
4. 多層 Promise 鏈的錯誤傳遞
在長鏈的 Promise 中,只需要在最後加上一個 .catch() 即可捕獲前面所有階段的錯誤。但如果在中間想要自行處理錯誤並繼續傳遞,需要在 .catch() 內 重新拋出 或 回傳一個 rejected Promise。
範例 3:中間層捕獲後再拋出
fetch('https://api.example.com/posts')
.then(res => {
if (!res.ok) throw new Error('取得文章失敗');
return res.json();
})
.then(posts => {
// 假設只取第一篇文章
return posts[0];
})
.catch(err => {
console.warn('第一層錯誤處理:', err.message);
// 重新拋出,讓後面的 catch 繼續偵測
throw err;
})
.then(firstPost => {
// 這裡只會在前面沒有錯誤時執行
console.log('第一篇文章:', firstPost);
})
.catch(err => {
console.error('最終錯誤處理:', err.message);
});
技巧:如果在中間層已經把錯誤「消化」了(例如回傳一個預設值),則後面的
.catch()就不會被觸發。根據需求決定是否要「吞掉」錯誤或「向上傳遞」。
5. EventEmitter 與非同步錯誤
Node.js 中常使用 EventEmitter 來實作事件驅動的非同步流程。錯誤事件('error')是保留事件,若沒有監聽器,Node 會自動拋出例外並導致程式終止。
範例 4:正確監聽 error 事件
const { EventEmitter } = require('events');
class MyWorker extends EventEmitter {
start() {
// 模擬非同步工作
setTimeout(() => {
// 發生錯誤,觸發 error 事件
this.emit('error', new Error('工作失敗!'));
}, 500);
}
}
const worker = new MyWorker();
// 必須註冊 error 監聽器,否則程式會直接 crash
worker.on('error', err => {
console.error('捕獲到工作錯誤:', err.message);
});
worker.start();
提醒:在自訂的 EventEmitter 中,永遠不要忘記 為可能的錯誤事件加上監聽器,否則錯誤會變成未捕獲例外。
6. 全域未捕獲錯誤處理
即使在每個非同步操作都做好了錯誤捕獲,仍有可能因為程式碼疏失或第三方庫的 bug,產生 未捕獲的 Promise 拒絕。在瀏覽器與 Node.js 中,都提供了全域的錯誤監聽機制:
| 環境 | 監聽方式 |
|---|---|
| 瀏覽器 | window.addEventListener('unhandledrejection', handler) |
| Node.js | process.on('unhandledRejection', handler) |
範例 5:全域未捕獲錯誤監聽(Node)
process.on('unhandledRejection', (reason, promise) => {
console.error('未捕獲的 Promise 拒絕:', reason);
// 這裡可以記錄日誌、上報監控或安全關閉服務
});
最佳實踐:在正式環境中,不要直接
process.exit(),而是先清理資源、寫入錯誤日誌,確保系統能平滑重啟。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
1. try…catch 包住非同步函式 |
try { asyncFn(); } catch {} 永遠不會捕獲錯誤。 |
把 await 放在 try 裡,或在 Promise 後接 .catch()。 |
2. 忘記在 Promise 鏈最後加 .catch() |
錯誤會變成未捕獲的 rejected Promise。 | 確保每條鏈都有最終的錯誤處理,或使用全域 unhandledRejection。 |
3. 在 catch 裡直接回傳錯誤物件 |
會把錯誤當作成功結果傳遞,導致後續程式誤判。 | 若想終止流程,重新拋出錯誤或回傳 Promise.reject(err)。 |
4. 多次 await 串行執行 |
造成不必要的延遲,且錯誤會在第一個失敗時就中斷。 | 需要同時執行時,使用 Promise.allSettled 或 Promise.all,配合個別錯誤捕獲。 |
5. 忽略 EventEmitter 的 error 事件 |
無監聽時會導致程式直接崩潰。 | 始終為自訂 EventEmitter 加上 error 監聽器,或使用 once('error')。 |
主要最佳實踐
- 統一錯誤格式:自訂錯誤類別(
class AppError extends Error)並在全域或服務層統一處理,方便日誌與前端回傳。 - 使用
async/await搭配try…catch:可讀性佳,且錯誤捕獲範圍清晰。 - 在 API 層返回統一的 Promise:避免同時混用回呼與 Promise,減少錯誤來源。
- 利用
Promise.allSettled:在需要多個非同步任務都執行完畢後才處理結果時,避免因單一失敗導致整體失敗。 - 全域錯誤監控:在開發與正式環境都設定
unhandledrejection/unhandledException,確保即使漏掉個別錯誤也能被捕獲。
實際應用場景
1. 前端表單提交與多 API 並行
async function submitForm(data) {
const [profileRes, settingsRes] = await Promise.allSettled([
fetch('/api/profile', { method: 'POST', body: JSON.stringify(data.profile) }),
fetch('/api/settings', { method: 'POST', body: JSON.stringify(data.settings) })
]);
const errors = [];
if (profileRes.status === 'rejected') errors.push('個人資料更新失敗');
if (settingsRes.status === 'rejected') errors.push('設定更新失敗');
if (errors.length) {
// 統一顯示錯誤訊息
throw new Error(errors.join(';'));
}
return { profile: await profileRes.value.json(), settings: await settingsRes.value.json() };
}
// 使用
submitForm(formData)
.then(result => console.log('全部成功:', result))
.catch(err => alert(`提交失敗:${err.message}`));
說明:使用 Promise.allSettled 能同時取得成功與失敗的結果,避免單一失敗擋住其他請求,最後集中處理錯誤。
2. Node.js 服務的批次工作(Queue)
const queue = require('bull'); // 高階佇列套件
const jobQueue = new queue('email', { redis: { port: 6379, host: '127.0.0.1' } });
jobQueue.process(async job => {
try {
await sendMail(job.data);
return Promise.resolve(); // 成功
} catch (err) {
// 將錯誤寫入日誌,讓 Bull 重新嘗試
console.error('寄送郵件失敗:', err);
throw err; // 讓 Bull 標記為失敗
}
});
// 全域監聽未捕獲錯誤,避免服務直接崩潰
process.on('unhandledRejection', (reason) => {
console.error('未捕獲的錯誤:', reason);
});
說明:在佇列工作中,必須把錯誤拋出,讓佇列系統(例如 Bull)能自動重試或移到失敗隊列。
3. 前端全域錯誤上報
window.addEventListener('unhandledrejection', event => {
const errorInfo = {
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack,
url: location.href,
timestamp: Date.now()
};
// 發送至監控平台
navigator.sendBeacon('/api/report', JSON.stringify(errorInfo));
});
說明:即使每個 API 呼叫都有 .catch(),仍有可能遺漏某些錯誤。全域監聽可作為最後一道防線,確保所有未捕獲的錯誤都能被上報。
總結
非同步錯誤處理是 JavaScript 開發者必備的核心技能。透過 Promise 的 reject、async/await 的 try…catch、EventEmitter 的 error 事件,以及 全域未捕獲錯誤監聽,我們可以在任何非同步情境下完整捕獲、傳遞與回報錯誤。
在實務開發中,遵守以下要點能大幅提升程式的可靠性:
- 永遠在非同步呼叫後接
.catch()(或使用try…catch包住await)。 - 錯誤不要在中間層「吞掉」,除非確定可以安全恢復,否則要重新拋出。
- 統一錯誤類別與回傳格式,方便日誌與前端顯示。
- 利用
Promise.allSettled、Promise.race等工具,根據需求選擇適當的併發策略。 - 設定全域錯誤監聽,作為最後的安全網,避免因疏漏導致服務崩潰。
掌握這些概念與實踐技巧,你將能寫出 既具可讀性又具韌性的非同步程式,在面對複雜的網路請求、資料處理或事件驅動系統時,仍能保持程式的穩定與可維護性。祝你在 JavaScript 的非同步世界裡,玩得開心、寫得安心!