跳至主要內容
Skip to content

錯誤處理的藝術:統一處理與優雅降級

在真實世界中,網路請求隨時可能失敗。優秀的錯誤處理不只是 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/業務 分別處理
優雅降級失敗時提供替代方案
離線支援離線佇列、快取優先
錯誤上報追蹤錯誤,持續改進
用戶體驗友善訊息、恢復建議

> **核心原則**:

  • 永遠不要讓用戶面對白屏或技術性錯誤訊息
  • 可重試的錯誤就提供重試按鈕
  • 記錄錯誤以便追蹤改進

進階挑戰

  1. 實作一個完整的錯誤處理中間層,整合錯誤分類、上報、用戶提示。
  2. 建立一個支援離線操作的表單,在網路恢復後自動同步。
  3. 設計一個 A/B 測試,比較不同錯誤訊息對用戶重試率的影響。

延伸閱讀與資源