Advanced (更新: 2026/6/2)

用 Claude Code 安全实现 React Error Boundary 实战指南

用Claude Code实现React Error Boundary:捕获范围、放置策略、重置、隐私日志、测试与提示词。

用 Claude Code 安全实现 React Error Boundary 实战指南

React 前端最糟糕的故障,往往不是某个图表或按钮坏了,而是一个小组件的渲染异常把整个应用变成白屏。直接让 Claude Code「加一下错误处理」,它很可能只补几处 try/catch,但 React 渲染阶段抛出的异常仍然没有被正确隔离。

这篇指南讲的是如何让 Claude Code 安全地实现 React Error Boundary。Error Boundary 可以理解为组件树里的隔离墙:子组件在渲染过程中发生未预期异常时,它显示用户可理解的 fallback UI,并把经过脱敏的错误信息送到日志系统。它不会修复根因,但能限制影响范围,让用户继续操作,也让开发者能定位问题。

Masa 在一个后台仪表盘里试过两种做法。第一次只让 Claude Code 在应用最外层包一个边界,白屏确实减少了,但一个收入图表崩溃会让设置页和账单入口也一起消失,而且日志里还出现了带邮箱的查询参数。后来把边界位置、reset 规则、PII 脱敏和测试要求都写进提示词,生成的代码才变得可审查、可上线。

先用 React 官方文档固定事实

给 Claude Code 写提示词之前,先把事实讲清楚。React 官方的 Component 参考文档说明了两个生命周期的职责:static getDerivedStateFromError 用来切换到 fallback 状态,componentDidCatch 用来做日志上报等副作用。React 官方的 error-boundaries lint 文档也强调,围绕 JSX 写普通 try/catch 不是处理渲染异常的正确方式。

关键点是:Error Boundary 不是万能异常处理器。它能捕获子组件在渲染、生命周期执行、以及渲染流程中运行的 hook 或 memo 里抛出的异常。它不能自动捕获点击事件、普通 Promise 失败、setTimeout、服务端渲染错误,也不能捕获 Error Boundary 自己 fallback 里的异常。

发生位置Error Boundary 会捕获吗实务处理方式
子组件渲染时抛错显示 fallback UI,并发送脱敏日志
渲染流程中的 hook 或 memo 抛错通常会预期失败先做校验,未预期异常交给边界
点击、提交等事件处理器不会在 handler 内 try/catch,必要时通过状态重新抛给边界
setTimeout、动画回调、普通 Promise不会显式处理失败,并提供重试路径
服务端渲染不会用框架的错误页、服务端日志和 HTTP 状态处理
Error Boundary 自身报错不会fallback UI 保持简单,并在更上层放一个边界
flowchart TD
  A["子组件在渲染中抛出异常"] --> B["最近的 Error Boundary"]
  B --> C["用户可理解的 fallback UI"]
  B --> D["脱敏后的错误日志"]
  E["点击处理或 setTimeout 抛错"] --> F["本地处理,或通过状态交给边界"]
  F --> B

区分路由级和组件级边界

Error Boundary 不是越多越好。全应用只有一个边界会过于粗糙,但每个按钮都包一个边界也会让页面出现一堆碎片化错误提示。应该让 Claude Code 明确两类位置:路由级和组件级。

路由级边界负责保护一个页面职责,例如 dashboard、settings、billing、editor、admin audit log。路由变化时要重置边界,避免上一个页面的失败状态跟着用户进入下一个页面。

组件级边界适合放在页面内部可以独立失败、独立重试的区域,例如收入图表、通知面板、Markdown 预览、推荐模块、第三方嵌入、小型 JSON 查看器。普通输入框、提交按钮、标题、图标不适合单独包边界;这些应由表单校验或常规 UI 状态处理。

判断粒度时问三个问题:这个区域失败后用户还能继续别的工作吗?它能单独 reload 或 reset 吗?日志里加上 feature 名称后,排查会更快吗?这也和 Claude Code 测试策略一致:用户能重试的范围,最好也是测试能覆盖的范围。

可复制的 Error Boundary 组件

Error Boundary 本体仍然需要 class component。应用其它部分可以继续使用函数组件,只有这层公共边界需要 React 为错误边界提供的 class 生命周期。

// src/components/error-boundary/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from "react";

export type ErrorBoundaryFallbackProps = {
  error: Error;
  resetErrorBoundary: () => void;
};

type ErrorBoundaryProps = {
  children: ReactNode;
  fallback?: ReactNode | ((props: ErrorBoundaryFallbackProps) => ReactNode);
  onError?: (error: Error, info: ErrorInfo) => void;
  onReset?: () => void;
  resetKeys?: ReadonlyArray<unknown>;
};

type ErrorBoundaryState = {
  error: Error | null;
};

function normalizeError(value: unknown): Error {
  if (value instanceof Error) return value;
  return new Error(typeof value === "string" ? value : "Unknown render error");
}

function changedArray(
  previous: ReadonlyArray<unknown> = [],
  next: ReadonlyArray<unknown> = [],
): boolean {
  return (
    previous.length !== next.length ||
    previous.some((item, index) => !Object.is(item, next[index]))
  );
}

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

  static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
    return { error: normalizeError(error) };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    this.props.onError?.(normalizeError(error), info);
  }

  componentDidUpdate(previousProps: ErrorBoundaryProps) {
    if (
      this.state.error &&
      changedArray(previousProps.resetKeys, this.props.resetKeys)
    ) {
      this.resetErrorBoundary();
    }
  }

  resetErrorBoundary = () => {
    this.props.onReset?.();
    this.setState({ error: null });
  };

  render() {
    if (!this.state.error) return this.props.children;

    if (typeof this.props.fallback === "function") {
      return this.props.fallback({
        error: this.state.error,
        resetErrorBoundary: this.resetErrorBoundary,
      });
    }

    if (this.props.fallback) return this.props.fallback;

    return (
      <section role="alert" aria-labelledby="error-boundary-title">
        <h2 id="error-boundary-title">Something went wrong</h2>
        <p>Please retry. If the problem continues, contact support.</p>
        <button type="button" onClick={this.resetErrorBoundary}>
          Try again
        </button>
      </section>
    );
  }
}

fallback 可以是静态节点,也可以是函数。函数形式更实用,因为它拿得到 errorresetErrorBoundary。但是不要把 error.stack、完整 API 响应或内部调试信息直接显示给用户。用户需要的是简短说明、可操作按钮,以及必要时用于客服排查的事件编号。

Fallback UI、reset 和 retry

fallback UI 不是调试面板,而是正式产品界面。它要说明哪个区域停止工作、用户数据是否被修改、下一步是否可以重试。部署后出现 chunk loading 错误时,重新加载整个应用可能有效;普通组件崩溃时,优先只重试当前区域。

// src/components/error-boundary/AppErrorFallback.tsx
import type { ErrorBoundaryFallbackProps } from "./ErrorBoundary";

export function AppErrorFallback({
  error,
  resetErrorBoundary,
}: ErrorBoundaryFallbackProps) {
  const reloadRecommended =
    /ChunkLoadError|Loading chunk|dynamically imported module/i.test(
      error.message,
    );

  return (
    <section
      role="alert"
      aria-labelledby="app-error-title"
      className="error-fallback"
    >
      <div>
        <p className="error-fallback__eyebrow">This section stopped working</p>
        <h2 id="app-error-title">We could not render this part of the page.</h2>
        <p>
          Your account data was not changed. Retry this section first, then
          reload the app if the same message appears again.
        </p>
      </div>

      <div className="error-fallback__actions">
        <button type="button" onClick={resetErrorBoundary}>
          Try again
        </button>
        {reloadRecommended ? (
          <button type="button" onClick={() => window.location.reload()}>
            Reload app
          </button>
        ) : null}
      </div>
    </section>
  );
}
/* src/components/error-boundary/error-fallback.css */
.error-fallback {
  border: 1px solid #d7dde8;
  border-radius: 8px;
  padding: 16px;
  background: #fff;
  color: #1f2937;
}

.error-fallback__eyebrow {
  margin: 0 0 4px;
  color: #6b7280;
  font-size: 0.875rem;
}

.error-fallback h2 {
  margin: 0 0 8px;
  font-size: 1.125rem;
}

.error-fallback__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 12px;
}

常见 bug 是只重置边界状态,却没有改变导致异常的输入。如果 props、缓存、路由状态完全一样,点 retry 后会立刻再次崩溃。把路由 key、搜索条件、用户 ID、刷新计数、数据版本等放进 resetKeys,让输入变化时才自动恢复。

路由级和组件级实现例

使用 React Router 时,可以做一个很薄的 RouteBoundary。这里用 location.key 作为 reset key,并在日志里带上 feature 名称。Next.js、Remix 等框架有自己的 route error 文件,但设计原则相同:路由变化时 reset,页面级失败不要扩散到其它页面。

// src/AppRoutes.tsx
import { lazy, ReactNode, Suspense } from "react";
import {
  createBrowserRouter,
  RouterProvider,
  useLocation,
} from "react-router-dom";
import { ErrorBoundary } from "./components/error-boundary/ErrorBoundary";
import { AppErrorFallback } from "./components/error-boundary/AppErrorFallback";
import { currentErrorContext, reportReactError } from "./lib/error-reporting";
import { Layout } from "./routes/Layout";

const DashboardPage = lazy(() => import("./routes/DashboardPage"));
const SettingsPage = lazy(() => import("./routes/SettingsPage"));

function RouteBoundary({
  children,
  feature,
}: {
  children: ReactNode;
  feature: string;
}) {
  const location = useLocation();

  return (
    <ErrorBoundary
      resetKeys={[location.key]}
      fallback={(props) => <AppErrorFallback {...props} />}
      onError={(error, info) => {
        void reportReactError(
          error,
          info.componentStack,
          currentErrorContext(feature),
        );
      }}
    >
      <Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
    </ErrorBoundary>
  );
}

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      {
        path: "dashboard",
        element: (
          <RouteBoundary feature="dashboard">
            <DashboardPage />
          </RouteBoundary>
        ),
      },
      {
        path: "settings",
        element: (
          <RouteBoundary feature="settings">
            <SettingsPage />
          </RouteBoundary>
        ),
      },
    ],
  },
]);

export function AppRoutes() {
  return <RouterProvider router={router} />;
}

组件级边界应该围住可独立恢复的区域:图表、Markdown 预览、推荐面板、第三方 embed、JSON viewer。它不应该围住每一个输入框。支付失败、验证失败、登录过期是业务状态,应由表单或页面流程直接处理。

异步和事件处理要单独设计

Error Boundary 不会自动捕获点击处理器或普通 async 失败。预期失败应该留在本地 UI:字段旁边显示验证错误,登录流程显示认证错误,支付流程显示拒付原因。未预期异常才通过状态在下一次 render 中重新抛出,让最近的边界处理。

// src/components/error-boundary/useAsyncBoundary.ts
import { useCallback, useState } from "react";

function toError(value: unknown): Error {
  if (value instanceof Error) return value;
  return new Error(typeof value === "string" ? value : "Unknown async error");
}

export function useAsyncBoundary() {
  const [error, setError] = useState<Error | null>(null);

  if (error) {
    throw error;
  }

  return useCallback((value: unknown) => {
    setError(toError(value));
  }, []);
}
// src/components/settings/SaveButton.tsx
import { useState } from "react";
import { useAsyncBoundary } from "../error-boundary/useAsyncBoundary";

type SaveButtonProps = {
  onSave: () => Promise<void>;
};

export function SaveButton({ onSave }: SaveButtonProps) {
  const [pending, setPending] = useState(false);
  const throwToBoundary = useAsyncBoundary();

  async function handleClick() {
    setPending(true);

    try {
      await onSave();
    } catch (error) {
      throwToBoundary(error);
    } finally {
      setPending(false);
    }
  }

  return (
    <button type="button" disabled={pending} onClick={handleClick}>
      {pending ? "Saving..." : "Save"}
    </button>
  );
}

提示词里要写清楚:不要把所有 async 失败都丢给 Error Boundary。400 响应、字段错误、限流、登录过期都应有本地 UI。边界负责的是未预期异常、损坏响应、渲染假设失败等会导致白屏的问题。

记录日志但不泄露 PII

PII 指可以识别个人的信息,例如邮箱、电话、姓名、地址、token、卡号、自由文本留言等。componentDidCatch 很适合上报客户端错误,但必须先约束字段并脱敏。

建议记录 feature、release、route pathname、error name、脱敏后的 message、stack、componentStack。不要记录 query string、表单值、Cookie、Authorization header、完整 URL 或原始 API 响应。

// src/lib/error-reporting.ts
type ClientErrorContext = {
  route: string;
  release: string;
  feature?: string;
  userHash?: string;
};

const REDACTIONS: Array<[RegExp, string]> = [
  [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted-email]"],
  [/\b(?:\d[ -]*?){13,19}\b/g, "[redacted-number]"],
  [/\b(token|secret|password|authorization)=([^&\s]+)/gi, "$1=[redacted]"],
  [/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]"],
];

export function redactText(value: string | undefined): string | undefined {
  if (!value) return value;

  return REDACTIONS.reduce(
    (text, [pattern, replacement]) => text.replace(pattern, replacement),
    value,
  );
}

export function currentErrorContext(feature?: string): ClientErrorContext {
  const env = (import.meta as unknown as {
    env?: Record<string, string | undefined>;
  }).env;

  return {
    route: typeof window === "undefined" ? "server" : window.location.pathname,
    release: env?.VITE_APP_VERSION ?? "dev",
    feature,
  };
}

export async function reportReactError(
  error: Error,
  componentStack: string | undefined,
  context: ClientErrorContext,
) {
  const payload = {
    name: redactText(error.name) ?? "Error",
    message: redactText(error.message) ?? "Unknown error",
    stack: redactText(error.stack),
    componentStack: redactText(componentStack),
    route: context.route,
    release: context.release,
    feature: context.feature,
    userHash: context.userHash,
  };

  const body = JSON.stringify(payload);

  if (typeof navigator !== "undefined" && navigator.sendBeacon) {
    const sent = navigator.sendBeacon(
      "/api/client-errors",
      new Blob([body], { type: "application/json" }),
    );
    if (sent) return;
  }

  await fetch("/api/client-errors", {
    method: "POST",
    headers: { "content-type": "application/json" },
    credentials: "omit",
    keepalive: true,
    body,
  });
}

服务端也要再次脱敏。客户端脱敏只是第一层保护,不能当成合规边界。让 Claude Code 同时实现客户端和服务端过滤,并且只使用已经哈希化的用户标识。

测试和验证命令

Error Boundary 只有在出错时才发挥作用,所以测试必须覆盖出错路径。最少要验证 fallback 显示、onError 被调用、retry 后能重新渲染子组件。

// src/components/error-boundary/ErrorBoundary.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { ReactNode, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorBoundary } from "./ErrorBoundary";

function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
  if (shouldThrow) {
    throw new Error("profile widget crashed");
  }

  return <p>Profile loaded</p>;
}

function RetryHarness({ onError }: { onError: ReturnType<typeof vi.fn> }) {
  const [broken, setBroken] = useState(true);

  return (
    <ErrorBoundary
      onError={onError}
      fallback={({ resetErrorBoundary }) => (
        <button
          type="button"
          onClick={() => {
            setBroken(false);
            resetErrorBoundary();
          }}
        >
          Retry profile
        </button>
      )}
    >
      <Bomb shouldThrow={broken} />
    </ErrorBoundary>
  );
}

function StaticFallback({ children }: { children: ReactNode }) {
  return (
    <ErrorBoundary fallback={<p>Could not load this panel.</p>}>
      {children}
    </ErrorBoundary>
  );
}

describe("ErrorBoundary", () => {
  let consoleErrorSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
  });

  afterEach(() => {
    consoleErrorSpy.mockRestore();
  });

  it("renders fallback UI when a child throws", () => {
    render(
      <StaticFallback>
        <Bomb shouldThrow />
      </StaticFallback>,
    );

    expect(screen.getByText("Could not load this panel.")).toBeInTheDocument();
  });

  it("calls onError with the thrown error and component stack", () => {
    const onError = vi.fn();

    render(<RetryHarness onError={onError} />);

    expect(onError).toHaveBeenCalledTimes(1);
    expect(onError.mock.calls[0][0].message).toBe("profile widget crashed");
    expect(onError.mock.calls[0][1].componentStack).toContain("Bomb");
  });

  it("can reset and render children again", async () => {
    const user = userEvent.setup();
    const onError = vi.fn();

    render(<RetryHarness onError={onError} />);
    await user.click(screen.getByRole("button", { name: "Retry profile" }));

    expect(screen.getByText("Profile loaded")).toBeInTheDocument();
  });
});
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
npm run typecheck
npx vitest run src/components/error-boundary/ErrorBoundary.test.tsx
npm run build

给 Claude Code 的安全提示词

Add React Error Boundaries to this React + TypeScript app.

Constraints:
- Follow the official React Error Boundary model.
- Catch render errors from descendants, but handle event handlers and ordinary async failures separately.
- Implement a shared ErrorBoundary class, user-facing fallback UI, and reportReactError with PII redaction.
- Route-level boundaries must reset on navigation through resetKeys.
- Component-level boundaries should only wrap independent regions such as DashboardChart, MarkdownPreview, and RecommendationPanel.
- Do not log error.stack, query strings, form values, Authorization headers, cookies, or raw API responses without redaction.
- Add Vitest + Testing Library coverage for fallback UI, onError, and retry reset.
- Run npm run typecheck, npx vitest run, and npm run build, then report the results.

Read the existing routing, logging, and CSS conventions first. Keep the diff minimal.

审查提示词也要单独准备:

Review this diff only from the Error Boundary perspective.
List issues with boundary placement, async errors that are not caught, PII leakage, missing resetKeys, fallback accessibility, and missing tests.
Do not change code. Return file names and line numbers.

实用场景和常见坑

场景一是 SaaS 仪表盘。收入图表、活跃用户表、通知面板、第三方 embed 可以分别包边界,图表库 bug 不应该阻塞设置页和账单页。日志 feature 可以写成 dashboard.revenue-chart

场景二是内容编辑器。Markdown 预览、图片预览、AI 摘要面板都容易出错,但正文编辑和保存按钮应该保留。保存失败在事件处理器里显示,不要把用户正在编辑的页面整个替换成 fallback。

场景三是电商和报名表单。银行卡拒付、库存不足、表单验证失败不是边界错误,而是产品流程的一部分。推荐商品、活动 banner、评论模块这类辅助区域才适合组件级边界。

场景四是后台审计日志。大 JSON viewer 可能在格式化时抛错。只包 viewer,管理员仍然可以修改筛选条件、导出 CSV、查看其他用户。

常见坑包括:用 try/catch 包 JSX 后误以为安全;把所有 async 失败都交给边界;记录完整 URL 和 query string;在 UI 里显示 stack trace;reset 时没有改变坏输入;边界太碎导致页面出现很多小 fallback。团队落地时,可以把实现和审查提示词做成 Claude Code 自定义命令。需要统一培训和导入规则时,可以把下一步自然引到 Claude Code 培训与实施支持

总结

Error Boundary 不是通用异常捕获器,而是 React 渲染失败的隔离层。用 Claude Code 实现时,要同时指定捕获范围、路由级和组件级位置、reset 行为、PII 日志策略、测试和验证命令。

实际在仪表盘中试用后,先定义 resetKeys 和日志脱敏规则,再让 Claude Code 写代码,审查成本明显更低。一个 widget 崩溃不再拖垮整个页面,日志也能用于排查而不会暴露用户数据。

#Claude Code #React #Error Boundary #错误处理 #TypeScript
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。