跳到主要內容

FE Day 37 錯誤處理與使用者回饋

FE Day 37 錯誤處理與使用者回饋

執行需求:CPU 可跑。今天進入貫穿專案「預約管理系統」的錯誤處理篇。Day 36 我們把資料層從 mock 換到 FastAPI,但每一次請求都可能失敗:網路斷線、後端 500、表單驗證 422、登入逾期 401、時段衝突 409。這些失敗如果不處理,使用者看到的會是「按按鈕沒反應」、「畫面空白」或「一片紅字」。今天要做四件事:第一,把 Day 36 拋出的 ApiError 接上使用者介面;第二,寫一個 Toast 元件與 Context,集中管理短訊息;第三,用 ErrorBoundary 接住渲染階段的例外並提供重試畫面;第四,把欄位層級的驗證錯誤接回 Day 32 的 Input 元件。全程沿用 mock 模式,沒有後端也能重現所有錯誤情境。

引言

錯誤處理是前端專案最容易被低估、卻最能區分「能用」與「好用」的工作。看起來「在 catch 裡寫一行 console.log 就好」,但上線之後會出現三種症狀:使用者回報「按了沒反應」、客服收到「系統不能用」的抱怨、而監控後台每天跳出上百筆 5xx 卻沒人知道從哪裡來。這些都是「沒有結構化錯誤處理」的後果,而不是單一 bug。

今天的核心想法是把錯誤處理拆成三層,各司其職。第一層是 Toast:處理「可預期、不需要擋住畫面」的短訊息,例如操作成功、網路暫時斷線。第二層是 ErrorBoundary:處理「元件渲染時丟出例外」這種無法用 try/catch 接住的錯誤,提供整區塊的替代畫面。第三層是欄位錯誤:處理表單驗證失敗,把訊息緊貼在對應欄位下方。三層覆蓋了資料錯誤、渲染錯誤與輸入錯誤,開發者不需要在每個 catch 手寫文案。

貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、語言家教、諮詢工作室,所有資料皆為虛構示範)。Web 系列的 FastAPI 後端在 Day 36、Day 37 完成認證與預約 API,HTTP 錯誤碼遵循 REST 慣例:400 格式錯誤、401 未登入、403 權限不足、404 找不到、409 衝突、422 語意錯誤、5xx 伺服器錯誤。前端要針對每種狀態碼給出對應的回饋,而不是一律顯示紅色驚嘆號。

今天的目標有四:建立錯誤分類表、實作 Toast 與 ErrorBoundary、把欄位錯誤接回表單、並用四個具體情境驗證整套機制。讀完之後你會拿到一個完整的錯誤處理策略、約六個 React 元件與 helper、以及一份「什麼錯誤用哪種介面」的決策依據。

貫穿專案共用設定(Day 31–44 沿用)

今天的錯誤處理建立在 Day 31–36 已建好的技術堆疊上,繼續沿用:

  • 框架:Next.js 15(App Router、Server Components 預設)、React 19.x、TypeScript 5.9
  • 樣式:Tailwind CSS 4.x、CSS 變數主題;沿用 Day 32 的 components/ui/
  • 狀態管理:TanStack Query 5.x 管理伺服器狀態
  • 後端:FastAPI 預約管理系統(Web 系列 Day 35–44 定義的 REST API),錯誤格式為 { "detail": "..." },驗證錯誤的 detail 是陣列
  • Mock:NEXT_PUBLIC_API_MODE=mock 時走 lib/mock/*.ts fixture
  • 設計系統:Day 32 的 Button、Input、Card、Badge;今天的 Toast 與 ErrorBoundary 是新元件

這份設定從 Day 31 到 Day 44 不變。今天的錯誤處理只在既有設定上新增元件與 helper,不動資料層的函式簽章,也不動路由。

原理:錯誤分類與 HTTP 狀態碼對應

錯誤處理的第一步是分類。我們把所有可能的失敗收斂成一個 UiError 物件,帶著四個欄位:顯示強度(level)、給使用者的標題與訊息、能不能重試、以及原始錯誤。任何錯誤,不論來自哪一層,最後都先經過 classifyError 轉成這個形狀,介面元件只需要看這四個欄位決定行為:

// lib/errors.ts
// 把 HTTP 狀態碼、ApiError 與一般例外統一分類成畫面可用的形狀。
import { ApiError } from "@/lib/api/client";

export type ErrorLevel = "info" | "warning" | "error" | "critical";

export type UiError = {
  level: ErrorLevel;
  title: string;
  message: string;
  retryable: boolean;
  cause?: unknown;
};

const STATUS_MAP: Record<number, Omit<UiError, "cause">> = {
  400: { level: "warning", title: "資料格式錯誤", message: "請檢查欄位內容後再送出", retryable: false },
  401: { level: "warning", title: "登入已逾期", message: "請重新登入後再試", retryable: false },
  403: { level: "error", title: "權限不足", message: "你沒有執行這個操作的權限", retryable: false },
  404: { level: "info", title: "找不到資料", message: "這筆資料可能已被刪除", retryable: false },
  409: { level: "warning", title: "資料衝突", message: "資料已被其他人更新,請重新整理", retryable: true },
  422: { level: "warning", title: "欄位驗證失敗", message: "請檢查欄位內容", retryable: false },
};

export function classifyError(error: unknown): UiError {
  if (error instanceof ApiError) {
    if (error.status >= 500) {
      return { level: "critical", title: "伺服器錯誤", message: "系統發生問題,請稍後再試", retryable: true, cause: error };
    }
    const mapped = STATUS_MAP[error.status];
    if (mapped) return { ...mapped, cause: error };
  }
  if (error instanceof TypeError) {
    return { level: "error", title: "網路錯誤", message: "請檢查網路連線後重試", retryable: true, cause: error };
  }
  if (error instanceof Error) {
    return { level: "critical", title: "未預期的錯誤", message: error.message, retryable: false, cause: error };
  }
  return { level: "critical", title: "未知錯誤", message: "請聯絡客服並附上發生時間", retryable: false, cause: error };
}

這個分類表有三個設計重點。第一,5xx 一律歸為 critical 且可重試,因為伺服器問題通常是暫時的,讓使用者按一下重試就能救回大半。第二,401 歸為 warning 但不能重試,因為再試一百次也還是未登入,正確做法是引導重新登入(Day 38 會做 401 自動換 token)。第三,TypeError 在 fetch 失敗時會被瀏覽器拋出,通常代表網路斷線或跨來源被擋,因此對應「網路錯誤」而不是「系統錯誤」,訊息也該不同。

狀態碼 等級 可重試 畫面行為
400 / 422 warning 否 欄位錯誤緊貼欄位,其餘走 Toast
401 warning 否 Toast 提示並導向登入
403 / 404 error / info 否 Toast 或區塊內訊息
409 warning 是 Toast 加上「重新整理」按鈕
5xx critical 是 Toast 或 ErrorState,提供重試

分類完成後,重試策略也一併定義。不是所有錯誤都該重試,而且重試之間要有等待,否則只會把後端打得更慘。我們把「可重試」的判斷與指數退避寫成一個小工具:

// lib/retry.ts
export function isRetryable(error: unknown): boolean {
  if (error && typeof error === "object" && "status" in error) {
    const status = Number((error as { status: unknown }).status);
    return status >= 500 || status === 409 || status === 429;
  }
  return error instanceof TypeError;
}

export async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
  let lastError: unknown;
  for (let attempt = 0; attempt < attempts; attempt += 1) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      if (!isRetryable(error) || attempt === attempts - 1) throw error;
      // 指數退避:300ms、600ms、1200ms,避免同時重試把後端壓垮。
      const delay = 2 ** attempt * 300;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  throw lastError;
}

這段有兩個提醒。第一,只重試 5xx、409、429 與網路錯誤,4xx 的其他情況重試沒有意義。第二,重試次數預設三次、延遲指數成長,這是「禮貌重試」的基本原則。TanStack Query 本身也有 retry 設定,兩者擇一即可;我們把 query 層的重試設為 1,複雜的重試留給 withRetry 手動控制,避免疊加成九次。

完整實作:Toast 與 ErrorBoundary

Toast 是「非阻塞的短訊息」,適合操作成功、欄位之外的錯誤、網路斷線這類需要立即通知但不該擋住畫面的情境。我們用 Context 管理一個訊息佇列,任何元件呼叫 push 就能推送,Toast 自己處理顯示與自動消失。全部用 TypeScript 標型別,避免「餵進去的東西形狀不對」:

// lib/toast-context.tsx
"use client";
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { classifyError, type UiError } from "@/lib/errors";

export type ToastRecord = UiError & { id: string };
type ToastApi = { push: (input: unknown) => void; dismiss: (id: string) => void };

const ToastContext = createContext<ToastApi | null>(null);

export function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<ToastRecord[]>([]);

  const dismiss = useCallback((id: string) => {
    setToasts((current) => current.filter((t) => t.id !== id));
  }, []);

  const push = useCallback((input: unknown) => {
    const ui = classifyError(input);
    const id = crypto.randomUUID();
    setToasts((current) => [...current, { ...ui, id }]);
    const ttl = ui.level === "critical" ? 8000 : 5000;
    window.setTimeout(() => dismiss(id), ttl);
  }, [dismiss]);

  const value = useMemo<ToastApi>(() => ({ push, dismiss }), [push, dismiss]);

  return (
    <ToastContext.Provider value={value}>
      {children}
      <ToastViewport toasts={toasts} onDismiss={dismiss} />
    </ToastContext.Provider>
  );
}

export function useToast(): ToastApi {
  const ctx = useContext(ToastContext);
  if (!ctx) throw new Error("useToast 必須在 ToastProvider 內使用");
  return ctx;
}

function ToastViewport({ toasts, onDismiss }: { toasts: ToastRecord[]; onDismiss: (id: string) => void }) {
  return (
    <div aria-live="polite" className="fixed right-4 top-4 z-50 flex w-80 flex-col gap-2">
      {toasts.map((toast) => (
        <div
          key={toast.id}
          role={toast.level === "critical" ? "alert" : "status"}
          className="flex items-start gap-3 rounded-card border border-surface-border bg-surface p-3 shadow-sm"
        >
          <div className="flex-1">
            <div className="text-sm font-semibold text-text-primary">{toast.title}</div>
            <div className="mt-1 text-sm text-text-secondary">{toast.message}</div>
          </div>
          <button type="button" onClick={() => onDismiss(toast.id)} className="text-xs underline">關閉</button>
        </div>
      ))}
    </div>
  );
}

這個 Toast 有四個設計重點。第一,容器用 aria-live="polite",輔助科技會在使用者操作告一段落後才唸出新訊息;critical 等級改用 role="alert" 立即朗讀。第二,自動消失時間依嚴重度調整,critical 停留 8 秒、其餘 5 秒,重要訊息不會一閃而過。第三,push 接受任何 unknown,內部統一呼叫 classifyError,呼叫端不必先判斷錯誤類型。第四,Context 值用 useMemo 穩定參考,避免子元件因為 value 每次都是新物件而重複渲染。

TanStack Query 的全域錯誤可以接到 Toast,這樣就不用在每個 query 各自寫 onError。我們把 QueryCache 與 MutationCache 的 onError 指向 push,並讓 ToastProvider 包在 QueryProvider 外層:

// app/providers.tsx
"use client";
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { DEFAULT_STALE_TIME_MS } from "@/lib/query-keys";
import { ToastProvider, useToast } from "@/lib/toast-context";
import { ErrorBoundary } from "@/components/ErrorBoundary";

function QueryProvider({ children }: { children: ReactNode }) {
  const toast = useToast();
  const [client] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: { staleTime: DEFAULT_STALE_TIME_MS, refetchOnWindowFocus: false, retry: 1 },
        },
        queryCache: new QueryCache({ onError: (error) => toast.push(error) }),
        mutationCache: new MutationCache({ onError: (error) => toast.push(error) }),
      }),
  );
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ToastProvider>
      <QueryProvider>
        <ErrorBoundary>{children}</ErrorBoundary>
      </QueryProvider>
    </ToastProvider>
  );
}

這裡的順序很關鍵:ToastProvider 必須在 QueryProvider 外層,因為 QueryProvider 內要呼叫 useToast;而 ErrorBoundary 放在最內層,剛好包住所有頁面內容,Render 階段丟出的例外會被它接住。有了全域 onError,個別元件只需要處理「自己關心的錯誤」,例如表單要特別處理 422 的欄位訊息,其餘(例如背景自動重抓失敗)就自動變成 Toast。

接著是 ErrorBoundary。React 19 仍然沒有「catch 渲染錯誤」的鉤子,必須用 class component 實作 componentDidCatch 與 getDerivedStateFromError。它接住的錯誤會同時送到 Day 30 的監控層,並顯示一段可重試的替代畫面:

// components/ErrorBoundary.tsx
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Button } from "@/components/ui/Button";
import { monitor } from "@/lib/monitor";

type Props = { children: ReactNode; fallbackTitle?: string };
type State = { error: Error | null };

export class ErrorBoundary extends Component<Props, State> {
  state: State = { error: null };

  static getDerivedStateFromError(error: Error): State {
    return { error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    monitor.captureException(error, { extra: { componentStack: info.componentStack } });
  }

  reset = () => this.setState({ error: null });

  render() {
    if (this.state.error) {
      return (
        <main className="mx-auto max-w-2xl p-section">
          <section className="rounded-card border border-danger-500 bg-danger-500/5 p-card">
            <h1 className="text-xl font-semibold text-danger-600">
              {this.props.fallbackTitle ?? "發生未預期的錯誤"}
            </h1>
            <p className="mt-3 text-text-secondary">
              頁面渲染時發生問題,請先重試;若持續發生,請聯絡客服並附上發生時間。
            </p>
            <pre className="mt-4 overflow-auto rounded bg-surface-muted p-3 text-xs">
              {this.state.error.message}
            </pre>
            <div className="mt-4 flex gap-2">
              <Button onClick={this.reset}>重試一次</Button>
              <Button variant="ghost" onClick={() => window.location.reload()}>重新整理頁面</Button>
            </div>
          </section>
        </main>
      );
    }
    return this.props.children;
  }
}

ErrorBoundary 有四個重點。第一,getDerivedStateFromError 在 catch 階段更新 state,觸發替代畫面。第二,componentDidCatch 把錯誤與 component stack 送到監控層,這是「使用者看到畫面」與「工程師收到告警」之間的橋樑。第三,reset 把 state 清回 null 讓子樹重新渲染,適合處理「臨時性」的渲染錯誤;真正的程式錯誤則需要重新整理整頁。第四,錯誤訊息放在 pre 元素內,方便使用者複製貼上給客服,也方便工程師比對監控後台的紀錄。

元件層級的錯誤處理還有一種常見需求:某個資料區塊壞掉時,不要讓整個頁面崩潰。我們提供一個輕量的 ErrorState,讓元件在 query 失敗時顯示訊息與重試按鈕,而不是丟例外:

// components/ui/ErrorState.tsx
"use client";
import { Button } from "@/components/ui/Button";
import { classifyError } from "@/lib/errors";

export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
  const ui = classifyError(error);
  return (
    <div role="alert" className="rounded-card border border-danger-500 bg-danger-500/5 p-card text-center">
      <p className="font-medium text-danger-600">{ui.title}</p>
      <p className="mt-1 text-sm text-text-secondary">{ui.message}</p>
      {ui.retryable && onRetry ? (
        <Button className="mt-3" onClick={onRetry}>重試</Button>
      ) : null}
    </div>
  );
}

欄位驗證:把錯誤接回表單

表單的錯誤是第三層。FastAPI 的 422 回應會把驗證細節放在 detail 陣列裡,每一筆帶著 loc(錯誤位置)與 msg(訊息)。我們寫一個 helper 把這個結構整理成「欄位名稱到訊息」的對照表,餵給 Day 32 Input 元件的 error prop:

// lib/form-helpers.ts
import { ApiError } from "@/lib/api/client";

export type FieldErrors = Record<string, string>;

type FastApiIssue = { loc?: unknown[]; msg?: unknown };

export function parseFieldErrors(error: unknown): FieldErrors {
  if (!(error instanceof ApiError)) return {};
  const payload = error.payload as { detail?: unknown } | undefined;
  const detail = payload?.detail;
  if (!Array.isArray(detail)) return {};

  const out: FieldErrors = {};
  for (const raw of detail as FastApiIssue[]) {
    if (!Array.isArray(raw.loc)) continue;
    const field = raw.loc[raw.loc.length - 1];
    if (typeof field === "string" && !out[field]) {
      out[field] = typeof raw.msg === "string" ? raw.msg : "欄位格式不正確";
    }
  }
  return out;
}

把欄位錯誤與 Toast 串進表單送出流程,最乾淨的寫法是抽成一個專用鉤子。它負責三件事:成功時提示並讓「我的預約」清單失效重新抓取、失敗時先嘗試解析欄位錯誤、沒有欄位錯誤就交給全域 onError 處理:

// lib/api/use-create-booking.ts
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createBooking } from "@/lib/api/bookings";
import { parseFieldErrors, type FieldErrors } from "@/lib/form-helpers";
import { useToast } from "@/lib/toast-context";
import type { CreateBookingInput } from "@/lib/mock/types";

export function useCreateBooking(onFieldErrors: (errors: FieldErrors) => void) {
  const toast = useToast();
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (input: CreateBookingInput) => createBooking(input),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["bookings", "mine"] });
      toast.push({ level: "info", title: "預約已送出", message: "確認後會以 Email 通知", retryable: false });
    },
    onError: (error: unknown) => {
      const fieldErrors = parseFieldErrors(error);
      if (Object.keys(fieldErrors).length > 0) {
        onFieldErrors(fieldErrors);
        return;
      }
      // 其餘錯誤已由 MutationCache 的全域 onError 統一送 Toast。
    },
  });
}

這條「欄位錯誤進欄位、其他錯誤進 Toast」的分工是業界慣例,避免「每個欄位下面都塞一個紅色橫幅」。以 Day 34 的預約表單為例,只要把送出改成呼叫這個鉤子,並在 onFieldErrors 中把結果塞進 errors state,就能同時支援「前端 zod 驗證」與「後端 422 驗證」:前端先擋一次、後端再擋一次,兩邊都通過才真的建立預約。

錯誤訊息本身也值得遵循三個原則。第一,說清楚「發生什麼」而非只說「失敗了」:把「操作失敗」改成「這個時段已經被預約,請重新選擇」。第二,告訴使用者「下一步能做什麼」:可重試的就附上重試按鈕,需重新登入的就給登入連結。第三,不要顯示技術細節給一般使用者:stack trace 與狀態碼放進監控後台,畫面上只留可理解的訊息,需要細節時收進「顯示詳細資訊」的折疊區。

驗證:四種錯誤情境

整套機制可以用四個情境驗證,分別對應後端錯誤、驗證錯誤、網路錯誤與渲染錯誤。第一個情境:在 mock 模式的 fetchAllBookings 開頭加一行 throw new ApiError(500, "boom"),重新整理後台會看到紅色 Toast 與「重試」按鈕。第二個情境:對 createBooking 送出格式錯誤的 Email,後端(或 mock 檢查)回 422,欄位下方出現訊息。第三個情境:把開發者工具的 Network 設為 Offline 後送出,fetch 會以 TypeError 失敗,畫面跳出「網路錯誤」。第四個情境:在 BookingSummary 故意讀取一個不存在的屬性,渲染階段丟出例外,ErrorBoundary 接管並顯示重試畫面,而不是整頁白屏。

這四個情境是最完整的錯誤處理測試組合。Day 40 的測試篇章會把它們包成 Vitest 與 Playwright 的測試,讓 CI 每次合併都跑一輪;今天先用人工重現,確認每一層都真的會被觸發。

常見錯誤與踩雷

第一個踩雷是「用 try/catch 包住整個元件」。渲染階段的錯誤無法被元件的 try/catch 接住,硬包反而會把錯誤吞掉、讓畫面停在半壞的狀態。修法:try/catch 留在資料層(例如 apiFetch 內),元件只處理「呼叫 mutation」這一小段的失敗,渲染錯誤交給 ErrorBoundary。

第二個踩雷是「把欄位錯誤顯示成 Toast」。使用者把 Email 打錯,卻看到「Email 格式錯誤」出現在右上角,會不知道是哪個欄位有問題。修法:422 這類錯誤先經 parseFieldErrors 解析,送回對應欄位的 error prop;只有在找不到對應欄位時(例如 401、5xx)才走 Toast。

第三個踩雷是「Toast 停留太久或太多」。自動消失設成 30 秒會讓訊息一直擋住畫面;同時跳出十個 Toast 會讓使用者完全無法閱讀。修法:預設 5 秒(critical 8 秒)、可手動關閉,並考慮同時最多顯示三個、其餘排隊。今天的版本先求「都會出現、都會消失」,佇列等觀察到實際困擾再加。

第四個踩雷是「ErrorBoundary 放在錯的位置」。如果把 ErrorBoundary 放在 Providers 最外層、或寫在 Server Component 裡,會出現「client 元件包住 server 元件」的錯誤。修法:ErrorBoundary 是 client 元件,要放在 root layout 的 children 內部、且在所有 Provider 之後(今天的 Providers 就是這個順序)。

第五個踩雷是「錯誤訊息寫死在建構錯誤的地方」。如果每個 throw 都自己組字串,改文案時要全文搜尋。修法:把所有使用者可見的文案集中在 classifyError,元件的 throw 只帶語意(狀態碼與後端 detail),顯示文案由分類層決定,未來要國際化也只需要改一處。

效能與實務提醒

今天新增的兩個元件都很輕:Toast 約 2 KB、ErrorBoundary 約 1 KB,gzipped 後合計 3 KB 上下,對整體 bundle 影響可忽略。真正需要注意的是「Toast 的觸發頻率」:如果某個查詢每 30 秒自動重抓一次且一直失敗,會變成每 30 秒跳一個 Toast,反而讓畫面很吵。解法是對「背景自動重抓」的錯誤降低顯示等級、或只在第一次失敗時提示,後續靜默記錄到監控即可。

另一個實務提醒是「錯誤日誌要能對得起來」。使用者在畫面上看到的是「系統發生問題,請稍後再試」,但工程師需要知道是哪一個請求、哪一個版本、什麼時間。我們在 Day 30 的 monitor.captureException 會帶上 build 版本與使用者識別,兩邊對照就能快速定位。若沒有這層,客服只能拿到一句「系統壞了」,除錯會拖很久。

最後提醒「不要用 alert 做錯誤回饋」。alert 會中斷瀏覽器、無法樣式化、在手機上體驗特別差,而且會擋住使用者複製錯誤訊息。今天的 Toast、ErrorState 與欄位錯誤都是頁面內元素,既能無障礙朗讀,也不會打斷操作。

小結

今天把貫穿專案的錯誤處理從「一行 console.log」升級為三層體系:Toast 處理非阻塞通知、ErrorBoundary 處理渲染例外、欄位錯誤處理表單驗證。我們寫了 classifyError、withRetry、ToastProvider、ErrorBoundary、ErrorState、parseFieldErrors 與 useCreateBooking 共七個元件與 helper,並用四個情境驗證每一層都會被觸發。預期效益是:所有可預期錯誤都有對應畫面、所有渲染例外都能被監控抓到、欄位錯誤不再被誤認為系統錯誤。

結語

錯誤處理是專案從「能動」走向「可信」的分水嶺。今天花一整篇把三層錯誤體系建起來,明天的登入流程會直接受益:401 自動換 token 的失敗會走 Toast、登入表單的欄位驗證會走 Input 的 error prop、AuthProvider 初始化失敗會被 ErrorBoundary 接住。明天 Day 38 我們會實作登入與註冊頁、AuthProvider 與 refresh 攔截器,以及依角色導向不同首頁的邏輯。讀完這篇你應該能回答:錯誤要分幾層?哪些錯誤該重試?欄位錯誤為什麼不該用 Toast?ErrorBoundary 該放在哪裡?

延伸資源

  • React 錯誤邊界官方說明:https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
  • ARIA Live Regions 無障礙通知設計:https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Attributes/aria-live
  • FastAPI 錯誤處理官方文件:https://fastapi.tiangolo.com/tutorial/handling-errors/,422 驗證錯誤的結構來源。
  • TanStack Query 5.x 全域錯誤處理:https://tanstack.com/query/latest/docs/framework/react/guides/mutations#error-handling
  • Web 系列 Day 36 的認證 API 契約:https://blog.hao-code.com/2025/07/web-day-36.html,401 與 refresh 的介面定義。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...