錯誤處理的藝術:統一處理與優雅降級
在真實世界中,網路請求隨時可能失敗。優秀的錯誤處理不只是 try-catch,而是一套完整的策略,讓應用在各種異常情況下都能優雅應對。
一、 錯誤的分類
1.1 錯誤類型總覽
1.2 各類錯誤特徵
| 類型 | 特徵 | 範例 | 處理策略 |
|---|---|---|---|
| 網路錯誤 | 無回應 | 斷網、DNS 失敗 | 重試、離線模式 |
| 超時 | 請求過長 | 網路慢、伺服器忙 | 重試、增加超時 |
| 4xx | 客戶端問題 | 401、404、422 | 修正請求、提示用戶 |
| 5xx | 伺服器問題 | 500、502、503 | 重試、顯示維護頁 |
| 業務錯誤 | 200 + 錯誤內容 | 餘額不足 | 顯示業務訊息 |
二、 Fetch 錯誤處理
2.1 Fetch 的陷阱
WARNING
Fetch 只在網路錯誤時 reject,HTTP 錯誤狀態碼不會 reject!
javascript
// ❌ 這樣無法捕捉 404
fetch("/api/not-found")
.then((res) => res.json()) // 404 也會執行這裡
.catch((err) => console.error(err)); // 不會執行
// ✅ 正確做法
fetch("/api/data")
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.catch((err) => console.error(err)); // 會捕捉所有錯誤2.2 完整錯誤處理
javascript
async function fetchWithErrorHandling(url, options = {}) {
try {
const response = await fetch(url, options);
// HTTP 錯誤
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
error.statusText = response.statusText;
// 嘗試解析錯誤訊息
try {
error.data = await response.json();
} catch {
error.data = null;
}
throw error;
}
return await response.json();
} catch (error) {
// 網路錯誤
if (error instanceof TypeError) {
const networkError = new Error("Network error");
networkError.type = "network";
throw networkError;
}
// 超時
if (error.name === "AbortError") {
const timeoutError = new Error("Request timeout");
timeoutError.type = "timeout";
throw timeoutError;
}
throw error;
}
}三、 統一錯誤格式
3.1 前端錯誤類別
javascript
class ApiError extends Error {
constructor(message, options = {}) {
super(message);
this.name = "ApiError";
this.status = options.status;
this.code = options.code;
this.data = options.data;
this.type = options.type || "api";
this.retryable = options.retryable ?? false;
}
static network(message = "Network error") {
return new ApiError(message, {
type: "network",
retryable: true,
});
}
static timeout(message = "Request timeout") {
return new ApiError(message, {
type: "timeout",
retryable: true,
});
}
static http(status, data) {
const message = data?.message || `HTTP ${status}`;
return new ApiError(message, {
status,
code: data?.code,
data,
type: "http",
retryable: status >= 500,
});
}
static business(code, message, data) {
return new ApiError(message, {
code,
data,
type: "business",
retryable: false,
});
}
}3.2 統一錯誤回應格式
後端應返回一致的錯誤格式:
json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email format is invalid",
"details": [
{
"field": "email",
"message": "Invalid email format"
}
],
"requestId": "req-abc-123"
}
}四、 錯誤處理策略
4.1 按錯誤類型處理
javascript
async function handleApiCall(apiCall) {
try {
return await apiCall();
} catch (error) {
switch (error.type) {
case "network":
showToast("網路連線失敗,請檢查網路設定");
break;
case "timeout":
showToast("請求超時,請稍後重試");
break;
case "http":
handleHttpError(error);
break;
case "business":
showToast(error.message);
break;
default:
showToast("發生未知錯誤");
console.error(error);
}
throw error;
}
}
function handleHttpError(error) {
switch (error.status) {
case 401:
redirectToLogin();
break;
case 403:
showToast("您沒有權限執行此操作");
break;
case 404:
showToast("找不到請求的資源");
break;
case 422:
showValidationErrors(error.data?.details);
break;
case 429:
showToast("請求過於頻繁,請稍後再試");
break;
case 500:
case 502:
case 503:
showToast("伺服器暫時無法服務,請稍後重試");
break;
default:
showToast(`請求失敗 (${error.status})`);
}
}4.2 React 錯誤邊界
jsx
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// 上報錯誤
reportError(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="error-page">
<h1>出了一點問題</h1>
<button onClick={() => window.location.reload()}>重新整理</button>
</div>
)
);
}
return this.props.children;
}
}
// 使用
<ErrorBoundary fallback={<ErrorPage />}>
<App />
</ErrorBoundary>;五、 優雅降級
5.1 什麼是優雅降級?
當服務不可用時,提供替代方案而非完全失敗:
| 場景 | 完全失敗 | 優雅降級 |
|---|---|---|
| API 超時 | 白屏 | 顯示快取資料 |
| 圖片載入失敗 | 破圖 | 顯示預設圖片 |
| 推薦系統掛了 | 空白 | 顯示熱門內容 |
| 支付失敗 | 卡住 | 提供其他支付方式 |
5.2 快取優先策略
javascript
async function fetchWithFallback(key, fetcher, options = {}) {
const {
maxAge = 5 * 60 * 1000, // 5 分鐘
staleWhileRevalidate = true,
} = options;
const cached = getCache(key);
// 快取有效
if (cached && Date.now() - cached.timestamp < maxAge) {
return cached.data;
}
// 快取過期但可用,背景刷新
if (cached && staleWhileRevalidate) {
fetcher()
.then((data) => setCache(key, data))
.catch(() => {});
return cached.data;
}
// 請求新資料
try {
const data = await fetcher();
setCache(key, data);
return data;
} catch (error) {
// 請求失敗,返回過期快取
if (cached) {
console.warn("Using stale cache due to error");
return cached.data;
}
throw error;
}
}
// 使用
const users = await fetchWithFallback("users-list", () => api.get("/users"), {
maxAge: 60000,
});5.3 降級元件
jsx
function DataDisplay({ fetchData, fallbackData, fallbackUI }) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData()
.then(setData)
.catch((err) => {
setError(err);
// 使用降級資料
if (fallbackData) {
setData(fallbackData);
}
})
.finally(() => setLoading(false));
}, []);
if (loading) return <Skeleton />;
if (error && !data) {
return fallbackUI || <ErrorMessage error={error} />;
}
return (
<>
{error && <Banner type="warning">資料可能已過期</Banner>}
<DataList data={data} />
</>
);
}六、 離線支援
6.1 偵測網路狀態
javascript
// 監聽網路狀態
window.addEventListener("online", () => {
showToast("網路已恢復");
syncPendingRequests();
});
window.addEventListener("offline", () => {
showToast("網路已斷開,進入離線模式");
});
// 檢查是否在線
function isOnline() {
return navigator.onLine;
}6.2 離線佇列
javascript
class OfflineQueue {
constructor() {
this.queue = this.loadQueue();
}
loadQueue() {
const saved = localStorage.getItem("offline-queue");
return saved ? JSON.parse(saved) : [];
}
saveQueue() {
localStorage.setItem("offline-queue", JSON.stringify(this.queue));
}
add(request) {
this.queue.push({
id: Date.now(),
...request,
timestamp: new Date().toISOString(),
});
this.saveQueue();
}
async process() {
if (!navigator.onLine || this.queue.length === 0) return;
const pending = [...this.queue];
this.queue = [];
this.saveQueue();
for (const request of pending) {
try {
await fetch(request.url, request.options);
console.log("Synced:", request.id);
} catch (error) {
// 失敗的放回佇列
this.queue.push(request);
}
}
this.saveQueue();
}
}
const offlineQueue = new OfflineQueue();
// 網路恢復時處理佇列
window.addEventListener("online", () => {
offlineQueue.process();
});七、 錯誤上報
7.1 錯誤追蹤
javascript
function reportError(error, context = {}) {
const errorData = {
message: error.message,
stack: error.stack,
type: error.type || "unknown",
status: error.status,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
...context,
};
// 發送到錯誤追蹤服務
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/errors", JSON.stringify(errorData));
} else {
fetch("/api/errors", {
method: "POST",
body: JSON.stringify(errorData),
keepalive: true,
}).catch(() => {});
}
}7.2 整合錯誤處理
javascript
// 全域錯誤處理
window.addEventListener("unhandledrejection", (event) => {
reportError(event.reason, { type: "unhandledRejection" });
});
// API 錯誤自動上報
api.interceptors.response.use(
(response) => response,
(error) => {
// 只上報 5xx 錯誤和網路錯誤
if (error.status >= 500 || error.type === "network") {
reportError(error, {
api: error.config?.url,
method: error.config?.method,
});
}
return Promise.reject(error);
}
);八、 用戶體驗
8.1 友善的錯誤訊息
javascript
const errorMessages = {
network: "網路連線失敗,請檢查您的網路設定。",
timeout: "請求超時,請稍後再試。",
401: "登入已過期,請重新登入。",
403: "您沒有權限執行此操作。",
404: "找不到您請求的資源。",
422: "輸入資料有誤,請檢查並修正。",
429: "操作過於頻繁,請稍後再試。",
500: "伺服器發生錯誤,我們正在處理中。",
502: "服務暫時無法使用,請稍後再試。",
503: "系統維護中,請稍後再試。",
default: "發生未預期的錯誤,請稍後再試。",
};
function getErrorMessage(error) {
if (error.type === "network") return errorMessages.network;
if (error.type === "timeout") return errorMessages.timeout;
if (error.status) return errorMessages[error.status] || errorMessages.default;
return error.message || errorMessages.default;
}8.2 錯誤恢復建議
jsx
function ErrorMessage({ error, onRetry }) {
const message = getErrorMessage(error);
const canRetry = error.retryable;
return (
<div className="error-container">
<Icon name="error" />
<p>{message}</p>
{canRetry && <button onClick={onRetry}>重試</button>}
{error.status === 401 && (
<button onClick={() => redirectToLogin()}>重新登入</button>
)}
<details>
<summary>技術詳情</summary>
<pre>{JSON.stringify(error, null, 2)}</pre>
</details>
</div>
);
}總結
| 策略 | 說明 |
|---|---|
| 統一錯誤格式 | 自訂 Error 類別,包含完整資訊 |
| 按類型處理 | 網路/超時/HTTP/業務 分別處理 |
| 優雅降級 | 失敗時提供替代方案 |
| 離線支援 | 離線佇列、快取優先 |
| 錯誤上報 | 追蹤錯誤,持續改進 |
| 用戶體驗 | 友善訊息、恢復建議 |
> **核心原則**:
- 永遠不要讓用戶面對白屏或技術性錯誤訊息
- 可重試的錯誤就提供重試按鈕
- 記錄錯誤以便追蹤改進
進階挑戰
- 實作一個完整的錯誤處理中間層,整合錯誤分類、上報、用戶提示。
- 建立一個支援離線操作的表單,在網路恢復後自動同步。
- 設計一個 A/B 測試,比較不同錯誤訊息對用戶重試率的影響。