本文 AI 產出,尚未審核

JavaScript – 事件循環(Event Loop)與非同步

主題:非同步錯誤處理


簡介

在 JavaScript 中,非同步程式碼是日常開發不可或缺的一部份──從網路請求、檔案讀寫到計時器、事件監聽,都會透過非同步機制執行。非同步程式碼的好處是能夠避免阻塞主執行緒,讓 UI 保持流暢;然而,錯誤的處理方式若不恰當,則會導致錯誤被悄悄吞掉、程式失去可預測性,甚至讓整個應用崩潰。

本篇文章將深入探討 非同步錯誤處理 的核心概念、常見寫法與陷阱,並提供實務範例與最佳實踐,幫助你在撰寫 Promise、async/await、EventEmitter 等非同步程式時,能夠正確捕獲與回報錯誤,提升程式的韌性與可維護性。


核心概念

1. 為什麼非同步錯誤與同步錯誤不同?

  • 同步錯誤(例如 throw new Error())會即時拋出,若不被 try…catch 包住,就會直接中斷程式執行。
  • 非同步錯誤 發生在未來的某個時間點,已經脫離了當下的執行上下文。若使用傳統的 try…catch 包住非同步呼叫,它不會捕獲到錯誤,因為錯誤發生時程式已經離開了那個 try 區塊。

重點:非同步錯誤必須透過 Promise 的 rejectasync/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.allSettledPromise.all,配合個別錯誤捕獲。
5. 忽略 EventEmitter 的 error 事件 無監聽時會導致程式直接崩潰。 始終為自訂 EventEmitter 加上 error 監聽器,或使用 once('error')

主要最佳實踐

  1. 統一錯誤格式:自訂錯誤類別(class AppError extends Error)並在全域或服務層統一處理,方便日誌與前端回傳。
  2. 使用 async/await 搭配 try…catch:可讀性佳,且錯誤捕獲範圍清晰。
  3. 在 API 層返回統一的 Promise:避免同時混用回呼與 Promise,減少錯誤來源。
  4. 利用 Promise.allSettled:在需要多個非同步任務都執行完畢後才處理結果時,避免因單一失敗導致整體失敗。
  5. 全域錯誤監控:在開發與正式環境都設定 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 事件,以及 全域未捕獲錯誤監聽,我們可以在任何非同步情境下完整捕獲、傳遞與回報錯誤。

在實務開發中,遵守以下要點能大幅提升程式的可靠性:

  1. 永遠在非同步呼叫後接 .catch()(或使用 try…catch 包住 await)。
  2. 錯誤不要在中間層「吞掉」,除非確定可以安全恢復,否則要重新拋出。
  3. 統一錯誤類別與回傳格式,方便日誌與前端顯示。
  4. 利用 Promise.allSettledPromise.race 等工具,根據需求選擇適當的併發策略。
  5. 設定全域錯誤監聽,作為最後的安全網,避免因疏漏導致服務崩潰。

掌握這些概念與實踐技巧,你將能寫出 既具可讀性又具韌性的非同步程式,在面對複雜的網路請求、資料處理或事件驅動系統時,仍能保持程式的穩定與可維護性。祝你在 JavaScript 的非同步世界裡,玩得開心、寫得安心!