javaScript : 錯誤處理與例外處理

 


寫 JavaScript,最怕不是 bug,而是「錯了卻沒聲音」。頁面轉啊轉、API 回了 200 卻不是 JSON、Promise 悄悄拒絕、Node 服務看似存活其實已半死。

這篇把錯誤處理當正事來講:從 try…catch…finally 到 async/await,教你丟 Error(而不是字串)、怎麼在非同步情境接住錯、fetch 要怎麼檢查 res.ok 與 content-type、超時與 AbortController 怎麼加。

前端有 window.error、unhandledrejection、React 錯誤邊界;後端有 Express 統一錯誤中介層、uncaughtException 與優雅關閉。再談日誌、Source Map、重試與退避、使用者友善提示與避坑清單。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


為什麼要在意錯誤處理?

新手寫 JS 時,常把重點放在「功能能不能跑」,但在真實專案裡,「錯誤能不能被看見、被理解、被妥善處理」才是能不能上線、能不能維護的分水嶺。糟的錯誤處理會導致:

        問題被悶著不報(靜默失敗)

        使用者看到空白頁、轉圈圈、或鬼影錯誤

        開發者定位困難、無法回溯

        服務不穩、資源外洩、安全風險

好消息是:JS 的生態已經有一套完整的方法論,從 try…catch、Promise/async / await、Error 物件、到瀏覽器與 Node.js 的全域監聽、結合日誌與告警,都能一步一步補上。


基礎觀念速通:錯誤 vs 例外、同步 vs 非同步

錯誤(Error):

        程式在執行時遇到無法繼續的情況,如語法錯誤、型別不符、網路請求失敗。

例外(Exception):

        廣義上指「丟出(throw)」的一切可中斷流程的狀況;在 JS 裡通常以 Error(或繼承自它的類別)作為承載載體。

同步錯誤:當下就會丟出(如 JSON.parse("xxx"))。

非同步錯誤:出現在回呼或 Promise 內(如 fetch 請求、setTimeout 裡 throw)。

原則:丟的是 Error、接的是 catch、記的是 log、回的是對使用者友善的訊息。


同步錯誤處理:try…catch…finally

基本寫法

function parseUser(json) {
  try {
    const data = JSON.parse(json);
    if (!data.name) {
      // 建議「throw Error 物件」,並補上 cause(Node 16+ / 現代瀏覽器支援)
      throw new Error("Missing 'name' field", { cause: { json } });
    }
    return data;
  } catch (err) {
    // 千萬不要空 catch!
    console.error("[parseUser] parse failed:", err);
    // 規劃好要回傳什麼(null / 預設值 / 往上拋出)
    return null;
  } finally {
    // 不論成功失敗都會執行,可做資源釋放
  }
}


什麼時候用 finally?

        關閉連線、釋放鎖、清理暫存檔

        重置 loading 狀態、啟用 UI 元件

        記錄完成(成功或失敗)統計


非同步錯誤處理:Promise 與 async/await

Promise 的 .catch

fetch("/api/user")
  .then(res => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  })
  .then(user => console.log("user:", user))
  .catch(err => {
    console.error("request failed:", err);
    // 在這裡呈現 toast 或回傳預設值
  });


async/await 的 try…catch

async function loadUser() {
  try {
    const res = await fetch("/api/user");
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const user = await res.json();
    return user;
  } catch (err) {
    console.error("[loadUser] failed:", err);
    return null; // 或者 throw err 交給上層處理
  }
}


常見坑:忘記 await

// ❌ catch 不到因為 Promise 還沒 await
try {
  const user = loadUser(); // 這是 Promise,不是資料
} catch (e) {
  // 抓不到
}

// ✅
try {
  const user = await loadUser();
} catch (e) {
  // 才能抓到 loadUser 裡丟出的錯
}

丟什麼最安全?請「丟 Error,不丟字串」

// ❌
throw "something bad"; 

// ✅
throw new Error("something bad");

// ✅ 搭配 cause 保留上下文
throw new Error("DB insert failed", { cause: { sql, params } });

為什麼?因為 Error 內建 name、message、stack,易於記錄與追蹤;cause 則能讓你鏈結前序錯誤,保住真實根因。


錯誤類型與自訂錯誤

內建錯誤(常見)

        TypeError、ReferenceError、SyntaxError、RangeError

        AggregateError(一次包多個錯誤,用在 Promise.any 等情境)

自訂錯誤

class ValidationError extends Error {
  constructor(message, details) {
    super(message);
    this.name = "ValidationError";
    this.details = details;
  }
}

function validateUser(u) {
  if (!u.email) throw new ValidationError("Email required", { field: "email" });
}


這樣做的好處是:上層可以用 instanceof 精準分流(要回 400?重試?直接報警?)。


瀏覽器端的全域監聽(避免靜默失敗)

// 捕捉未處理的例外
window.addEventListener("error", (event) => {
  console.error("[window.error]", event.message, event.filename, event.lineno);
});

// 捕捉未處理的 Promise 拒絕
window.addEventListener("unhandledrejection", (event) => {
  console.error("[unhandledrejection]", event.reason);
});


搭配上送到你的日誌平台(例如以 fetch('/log', { body: … }))即可快速察覺前端的真實錯誤率。


Node.js 的全域保底與進程策略(謹慎使用)

// 1) 未捕捉例外
process.on("uncaughtException", (err) => {
  console.error("[uncaughtException]", err);
  // 建議:記錄後「優雅關閉」,避免不一致狀態
  process.exit(1);
});

// 2) 未處理的 Promise 拒絕
process.on("unhandledRejection", (reason, promise) => {
  console.error("[unhandledRejection]", reason);
  process.exit(1);
});


關鍵觀念:在伺服器程式裡,當出現未預期錯誤,進程可能已經進入不可信狀態(資源鎖死、資料部分寫入…)。偏保守的做法是記錄後重啟(交給 PM2、systemd、Kubernetes),同時確保請求能被健康地接手。


設計一套「能活在生產環境」的錯誤策略

1.    分層與分類

Domain/UseCase 層:用自訂錯誤做語意化(如 ValidationError、PermissionError、NotFoundError)。

Infra 層:DB、外部 API、檔案系統錯誤,附上 cause 與 metadata。

UI 層:把技術細節關起來,轉成可理解、可行動的訊息(重試、回上一頁、登入…)。


2.    訊息與代碼

對外回 安全、泛化 的訊息(避免洩漏堆疊、SQL)

對內保留 錯誤碼(APP_XXXX)與完整堆疊,利於查詢與統計


3.    日誌與追蹤

前端:console.error + 上送(含 URL、使用者 ID、版本、瀏覽器)

後端:集中式日誌(結構化 JSON),搭配追蹤 ID(X-Request-ID)

Source map:前端壓縮後要上傳 source map,堆疊才還原得回原始碼位置


非同步下的穩定性:重試、退避、超時、中止

超時+中止(AbortController)

async function fetchWithTimeout(url, ms = 8000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), ms);
  try {
    const res = await fetch(url, { signal: controller.signal });
    return res;
  } finally {
    clearTimeout(id);
  }
}


指數退避重試(簡版)

async function retry(fn, { retries = 3, base = 300 } = {}) {
  let lastErr;
  for (let i = 0; i <= retries; i++) {
    try { return await fn(); }
    catch (err) {
      lastErr = err;
      const delay = base * 2 ** i;
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw lastErr;
}

// 用法
const data = await retry(async () => {
  const res = await fetch("/api");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
});


搭配「只對暫時性錯誤重試」(如 429 / 503 / 網路錯)、「設定上限」,避免放大災情。


陣列與事件裡的非同步錯誤

map 裡的 async 要小心

// ❌ 這樣會得到 Promise 陣列,錯誤也不會在這裡被 try-catch 到
try {
  const results = [1,2,3].map(async x => await doAsync(x));
} catch { /* 不會進來 */ }

// ✅
const results = await Promise.all([1,2,3].map(x => doAsync(x)));


EventEmitter 的錯誤通道

const { EventEmitter } = require("events");
const bus = new EventEmitter();

bus.on("error", (err) => {
  console.error("[bus error]", err);
});

bus.emit("error", new Error("something bad"));


在 Node 生態中,許多套件用 error 事件來傳遞錯誤;沒監聽就會直接炸掉。


API 呼叫的正確打開方式:別被 200 騙了

async function safeFetchJson(url, opts) {
  const res = await fetch(url, opts);
  // 1) 先判斷 HTTP 狀態
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  // 2) 再判斷回傳格式
  const ct = res.headers.get("content-type") || "";
  if (!ct.includes("application/json")) {
    throw new Error(`Unexpected content type: ${ct}`);
  }
  // 3) 才 parse
  return res.json();
}


Express(Node)錯誤中介層實戰

建立統一錯誤處理器

// errorMiddleware.js
module.exports = function errorMiddleware(err, req, res, next) {
  console.error("[reqId=%s] %s", req.id, err.stack || err);

  const status =
    err.name === "ValidationError" ? 400 :
    err.name === "PermissionError" ? 403 :
    err.name === "NotFoundError"   ? 404 : 500;

  res.status(status).json({
    error: {
      code: err.code || "APP_UNEXPECTED",
      message: status === 500 ? "Something went wrong" : err.message,
    }
  });
};


在路由裡丟出錯誤

const express = require("express");
const app = express();
const errorMiddleware = require("./errorMiddleware");

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await repo.find(req.params.id);
    if (!user) {
      const err = new Error("User not found");
      err.name = "NotFoundError";
      return next(err);
    }
    res.json(user);
  } catch (err) {
    next(err); // 交給統一錯誤處理器
  }
});

app.use(errorMiddleware);
app.listen(3000);


前端 UI 友善處理:錯誤邊界與回饋

React Error Boundary(捕捉 render 時的錯)

import React from "react";

class ErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { hasError: false }; }
  static getDerivedStateFromError() { return { hasError: true }; }
  componentDidCatch(error, info) { console.error("[ErrorBoundary]", error, info); }
  render() {
    if (this.state.hasError) return <Fallback />;
    return this.props.children;
  }
}


非同步錯誤:用 hook 包起來

import { useState } from "react";

export function useAsync(fn) {
  const [state, set] = useState({ loading: false, error: null, data: null });
  const run = async (...args) => {
    set({ loading: true, error: null, data: null });
    try {
      const data = await fn(...args);
      set({ loading: false, error: null, data });
      return data;
    } catch (e) {
      set({ loading: false, error: e, data: null });
      throw e;
    }
  };
  return [state, run];
}


搭配 UI 顯示 loading、錯誤提示與重試按鈕,用戶體驗就能「穩」起來。


輸入驗證與防衛式程式設計

進入點就驗(前端表單 + 後端再驗一次)

善用 可選鏈結 obj?.a?.b 與 Null 合併 x ?? default

對外部資料(localStorage、location.search、第三方 API)保持懷疑

驗證工具可搭配 zod/yup(若不能用第三方,也至少手寫 schema 檢查)

function getInt(qs, key, def = 0) {
  const v = Number(new URLSearchParams(qs).get(key));
  return Number.isInteger(v) ? v : def;
}


團隊層面的保險絲:ESLint、型別與規範

ESLint 規則:

no-empty-function / no-empty(避免空 catch)

promise/catch-or-return(保證有處理 rejection)

no-throw-literal(不要丟字串)


TypeScript(若可):

幫助你提前辨識「可能為 undefined」的路徑

但型別不是萬靈丹,runtime 驗證仍要做


常見錯誤與雷點清單

1.    空的 catch 區塊

現象:錯誤被吃掉,現場只有沉默

對策:至少 console.error;更好是上送日誌、顯示 UI 提示


2.    丟出字串或非 Error 物件

現象:沒有 stack、難以追蹤

對策:統一 throw new Error(message, { cause })


3.    忘記 await

現象:try…catch 抓不到、UI 不更新

對策:函式命名以 Async 結尾;ESLint 規則補強


4.    只檢查 200,不檢查內容

現象:後端回 HTML 或錯誤格式時,前端 JSON.parse 爆掉

對策:同時驗 res.ok 與 content-type,再 res.json()


5.    Promise 陣列不 await

現象:非同步錯誤被攤平,沒有一處能接住

對策:await Promise.all([...]),或針對單筆包 try-catch 回傳結果/錯誤


6.    全域監聽沒有上報

現象:線上明明有錯,但你完全不知道

對策:把 window.error 與 unhandledrejection 上報到後端


7.    Node 硬撐不退出

現象:服務似乎還活著,但狀態已經壞掉

對策:uncaughtException / unhandledRejection 記錄後退出,交由進程管理器重啟


8.    忽略使用者可行動建議

現象:只顯示「發生錯誤」,使用者無所適從

對策:提供「重試」「回上一頁」「重新登入」「回報問題」等選項


9.    未使用 Source Map

現象:線上錯誤堆疊只有壓縮後的亂碼

對策:上傳 source map 並在 log 平台還原


10.    敏感資訊外洩

現象:把 stack/SQL/Token 直接回給前端

對策:對外訊息泛化;機密只留在伺服器端日誌


進階:Result/Either 模式(不一定要 throw)

對於「可預期、經常發生」又不想用例外中斷流程的情境,可以用 Result 物件明確回傳成功/失敗。

const Ok = (value) => ({ ok: true, value });
const Err = (error) => ({ ok: false, error });

function safeParse(json) {
  try {
    return Ok(JSON.parse(json));
  } catch (e) {
    return Err(e);
  }
}

// 用法
const r = safeParse(input);
if (!r.ok) {
  console.error("parse failed:", r.error);
} else {
  console.log("data:", r.value);
}


這種寫法可讀性高、流程清楚,特別適合資料轉換與驗證。


可直接套用的工具函式

包一層「帶超時、帶錯誤轉換」的 fetch

async function httpJson(url, { method = "GET", body, timeout = 8000, headers = {} } = {}) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeout);
  try {
    const res = await fetch(url, {
      method,
      body: body ? JSON.stringify(body) : undefined,
      headers: { "content-type": "application/json", ...headers },
      signal: controller.signal,
    });
    if (!res.ok) throw new Error(`HTTP_${res.status}`);
    const ct = res.headers.get("content-type") || "";
    if (!ct.includes("application/json")) throw new Error("NOT_JSON");
    return await res.json();
  } catch (err) {
    // 統一錯誤碼轉換
    if (err.name === "AbortError") err.code = "TIMEOUT";
    else if (!err.code) err.code = "NETWORK_OR_SERVER";
    throw err;
  } finally {
    clearTimeout(id);
  }
}


包裝任意 async 函式,讓它回傳 Result

async function toResult(promise) {
  try {
    const data = await promise;
    return [data, null];
  } catch (err) {
    return [null, err];
  }
}

// 用法
const [data, err] = await toResult(httpJson("/api"));
if (err) { /* 顯示錯誤 */ }


延伸閱讀推薦:



張貼留言 (0)
較新的 較舊