寫 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) { /* 顯示錯誤 */ }
