陌上人如玉
公子世无双

11.9 手写 Error Boundary

11.9 手写 Error Boundary

一句话:Error Boundary 只能由类组件实现——依赖 static getDerivedStateFromError(渲染降级 UI)与 componentDidCatch(上报),并要提供「重置」能力;用「包装组件 + 函数式 fallback」可以让使用方是函数组件。

一、为什么必须用类组件

Error Boundary 的能力是「在【渲染失败时】替换渲染结果」——
这要求一个「有状态、可被 React 在渲染失败时调用」的实体。

而 Hooks(useState/useEffect)都在「渲染成功之后」才运行,
【无法介入「渲染抛错」这个时机】。

所以 React 规定:
· `getDerivedStateFromError(error)` —— 静态方法,在「渲染阶段」被调用,返回新 state
· `componentDidCatch(error, info)` —— 在「提交阶段」被调用,可做副作用(上报)

✅ 但可以让「使用方」是函数组件(把类组件封装成「包装组件」)

二、基础实现

import { Component, type ErrorInfo, type ReactNode } from 'react';

interface Props {
  children: ReactNode;
  /** 降级 UI(可以是「静态节点」或「函数」——函数能拿到 error 与 reset) */
  fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
  /** 错误回调(用于上报) */
  onError?: (error: Error, info: ErrorInfo) => void;
}

interface State {
  error: Error | null;
}

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

  /** ① 渲染阶段:返回「新的 state」→ 触发降级 UI 的渲染 */
  static getDerivedStateFromError(error: Error): State {
    return { error };
  }

  /** ② 提交阶段:做副作用(上报、日志) */
  componentDidCatch(error: Error, info: ErrorInfo) {
    // ⭐ info.componentStack 是【定位 React 错误的核心】信息
    console.error('[ErrorBoundary]', error, info.componentStack);
    this.props.onError?.(error, info);
  }

  /** ③ 重置(否则降级 UI 会一直显示) */
  private reset = () => {
    this.setState({ error: null });
  };

  render() {
    const { error } = this.state;
    const { children, fallback } = this.props;

    if (error) {
      return typeof fallback === 'function' ? <>{fallback(error, this.reset)}</> : <>{fallback}</>;
    }

    return <>{children}</>;
  }
}
// ✅ 使用
function App() {
  return (
    <ErrorBoundary
      onError={(error, info) => reportError(error, { componentStack: info.componentStack })}
      fallback={(error, reset) => (
        <div role="alert" className="error-page">
          <h2>出错了</h2>
          {error.message}
          <button onClick={reset}>重试</button>
          <button onClick={() => (window.location.href = '/')}>返回首页</button>
        </div>
      )}
    >
      <Page />
    </ErrorBoundary>
  );
}
⭐ 基础版的三个关键点:
① `getDerivedStateFromError` 必须是【静态】【纯函数】(不能调 API)
② 只有 `componentDidCatch` 里能做副作用(上报)
③ 必须提供 `reset`(否则用户只能刷新页面)

三、完整实现(含 resetKeys 与「重挂载」)

import { Component, type ErrorInfo, type ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
  onError?: (error: Error, info: ErrorInfo) => void;
  /** 重置时的回调(清理状态,如「清空查询缓存」) */
  onReset?: () => void;
  /** ⭐ 这些值变化时自动重置(如「路由变化」「用户切换」时自动恢复) */
  resetKeys?: unknown[];
  /** 重置时是否「强制重新挂载子树」(推荐 true —— 保证内部状态被清掉) */
  remountOnReset?: boolean;
}

interface State {
  error: Error | null;
  /** 用于「强制重新挂载」的 key */
  resetCount: number;
}

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

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

  componentDidCatch(error: Error, info: ErrorInfo) {
    this.props.onError?.(error, info);
    // ⚠️ 不要在这里 setState(已经通过 getDerivedStateFromError 设置了)
  }

  componentDidUpdate(prevProps: Props) {
    // ⭐ resetKeys 变化时自动重置
    const { resetKeys } = this.props;
    if (this.state.error === null || !resetKeys) return;

    const keysChanged =
      !prevProps.resetKeys ||
      resetKeys.length !== prevProps.resetKeys.length ||
      resetKeys.some((k, i) => !Object.is(k, prevProps.resetKeys![i]));

    if (keysChanged) this.resetErrorBoundary();
  }

  resetErrorBoundary = () => {
    this.props.onReset?.();
    this.setState((prev) => ({ error: null, resetCount: prev.resetCount + 1 }));
  };

  render() {
    const { error, resetCount } = this.state;
    const { children, fallback, remountOnReset = true } = this.props;

    if (error) {
      return typeof fallback === 'function'
        ? <>{fallback(error, this.resetErrorBoundary)}</>
        : <>{fallback}</>;
    }

    // ⭐ 用 key 强制「重新挂载子树」——保证出错的内部状态被清掉
    return <div key={remountOnReset ? resetCount : undefined}>{children}</div>;
  }
}
// ✅ 使用:路由变化时「自动重置」
function App() {
  const location = useLocation();

  return (
    <ErrorBoundary
      resetKeys={[location.pathname]}          // ⭐ 切路由时自动重置
      onReset={() => queryClient.resetQueries()}   // ⭐ 重置时清缓存
      fallback={(error, reset) => <ErrorPage error={error} onRetry={reset} />}
    >
      <Routes />
    </ErrorBoundary>
  );
}

四、用「包装组件」让使用方是函数组件

/** ✅ 一个「函数式 API」的包装(内部仍是类组件) */
interface ErrorBoundaryProps {
  children: ReactNode;
  fallback: (args: { error: Error; reset: () => void }) => ReactNode;
  onError?: (error: Error, info: ErrorInfo) => void;
  onReset?: () => void;
  resetKeys?: unknown[];
}

export function ErrorBoundaryWrapper({ children, fallback, ...rest }: ErrorBoundaryProps) {
  return (
    <ErrorBoundary
      {...rest}
      fallback={(error, reset) => fallback({ error, reset })}
    >
      {children}
    </ErrorBoundary>
  );
}
// ✅ 使用(函数式)
<ErrorBoundaryWrapper
  resetKeys={[userId]}
  fallback={({ error, reset }) => (
    <div role="alert">
      <p>加载 {userId} 失败:{error.message}</p>
      <button onClick={reset}>重试</button>
    </div>
  )}
>
  <UserProfile userId={userId} />
</ErrorBoundaryWrapper>

五、分层放置 + 局部隔离

// ✅ 推荐结构:应用级兜底 + 区域级隔离
function App() {
  return (
    <ErrorBoundaryWrapper
      fallback={({ error, reset }) => <FullPageError error={error} onRetry={reset} />}   // ① 最后兜底
      onError={reportError}
    >
      <TopNav />
      <main>
        <ErrorBoundaryWrapper fallback={({ reset }) => <WidgetError onRetry={reset} />}>  {/* ② 区域 A */}
          <Dashboard />
        </ErrorBoundaryWrapper>

        <ErrorBoundaryWrapper fallback={({ reset }) => <WidgetError onRetry={reset} />}>  {/* ③ 区域 B */}
          <RecommendList />
        </ErrorBoundaryWrapper>
      </main>
      <Footer />
    </ErrorBoundaryWrapper>
  );
}
// ✅ 专门隔离「可能崩溃的第三方内容」(如用户输入的富文本、第三方图表)
function SafeRender({ children, raw }: { children: ReactNode; raw: string }) {
  return (
    <ErrorBoundaryWrapper
      fallback={() => (
        <div className="fallback">
          <p>内容渲染失败,已显示原始文本</p>
          <pre>{raw}</pre>              {/* ⭐ 降级方案:显示原始文本而不是崩溃 */}
        </div>
      )}
    >
      {children}
    </ErrorBoundaryWrapper>
  );
}

<SafeRender raw={content}>
  <RichTextRenderer content={content} />
</SafeRender>;

六、错误恢复策略

⭐ 「重试」时要考虑「状态是否已被污染」:

① 只替换渲染(默认)
   · 出错的子树被卸载,内部状态丢失
   · 但「全局状态/缓存」可能仍处于不一致状态
   → 适合「一次性渲染错误」(如某个数据字段为空导致的崩溃)

② 清理相关缓存后重试
   · 在 onReset 里调用「清缓存」(`queryClient.resetQueries()`)
   · 适合「数据导致的错误」(缓存里存了坏数据)

③ 强制重新挂载(remountOnReset = true)
   · 用 key 变化让子树「完全重建」
   · 保证「不会带着坏状态重试」
   → **推荐默认开启**

④ 降级到「只读/简化模式」
   · 如「富文本渲染失败 → 显示原始 markdown」
   · 适合「非关键功能」(用户仍能完成主流程)
// ✅ 一个完整的「重置 + 重挂载 + 清缓存」实现
function useErrorReset() {
  const queryClient = useQueryClient();
  return useCallback(() => {
    queryClient.resetQueries();                 // 清掉可能的坏缓存
    // 其它需要重置的全局状态
  }, [queryClient]);
}

七、与 Suspense / 数据路由的配合

// ✅ 标准顺序:ErrorBoundary 在外、Suspense 在内(见 8.4)
<ErrorBoundaryWrapper fallback={({ error, reset }) => <ErrorView error={error} onRetry={reset} />}>
  <Suspense fallback={<Skeleton />}>
    <LazyOrAsyncContent />
  </Suspense>
</ErrorBoundaryWrapper>
// ✅ 数据路由:errorElement 处理「loader/action 抛出的错误」
// (它也能处理渲染错误,且可以「按路由层级」配置)
const router = createBrowserRouter([
  {
    path: 'users/:id',
    element: <UserDetail />,
    loader: async ({ params }) => {
      const user = await fetchUser(params.id!);
      if (!user) throw new Response('Not Found', { status: 404 });   // ⭐ 交给 errorElement
      return user;
    },
    errorElement: <UserError />,       // 用 useRouteError + isRouteErrorResponse 读错误
  },
]);
错误类型 用什么
组件渲染错误 Error Boundary
loader/action 抛出的错误 数据路由的 errorElement
事件处理里的错误 try/catch(或 useErrorBoundary 手动上抛)
异步/Promise 错误 catch + unhandledrejection 全局监听
接口错误 请求层拦截器

八、常见坑

// ① 以为它能捕获「事件处理里的错误」
<button onClick={() => { throw new Error(); }} />          // ❌ 捕获不到

// ② 以为它能捕获「异步/Promise 的错误」
Promise.reject(new Error());                                // ❌

// ③ 以为它能捕获「useEffect 里的错误」(React 18 起捕获不到)
useEffect(() => { throw new Error('x'); }, []);             // ❌

// ④ 只实现 getDerivedStateFromError 而不提供「重置」
//    → 降级 UI 永远显示,用户只能刷新

// ⑤ fallback 只是一行「出错了」(没有出路)
fallback={<div>出错了</div>}                                 // ⚠️

// ⑥ 只放一个最外层(局部错误 → 整页崩)
// ⑦ fallback 里再抛错 → 需要「外层再包一层」
// ⑧ 忘记「上报」(Error Boundary 的一半价值是可观测性)
// ⑨ 在生产环境「把 error.stack 显示给用户」(泄露内部实现)
// ⑩ 重试时「不清理状态」(带着坏状态重试 → 又崩)
// ⑪ 用 try/catch 包 JSX(语法上行不通,捕获不到)
try { return <Comp />; } catch {}                            // ❌
// ⑫ 认为「React 18 用 try/catch 替代了 Error Boundary」(没有)
// ⑬ 把 Error Boundary 放在「会被卸载的位置」(如 Modal 内部)→ 起不到兜底作用
// ⑭ 在 StrictMode 下「错误被捕获两次」(上报要去重,见 8.6)
// ⑮ 忘记「componentDidCatch 里不要再 setState」(已经设置过 state 了)
// ⑯ 用 Error Boundary 代替「输入校验」(应该提前校验而不是靠崩溃兜底)
// ⑰ 「重置」后没有重新请求数据(用户看到的是旧数据或空)
//    ✅ 在 onReset 里触发 refetch / invalidate
// ⑱ 认为「Error Boundary 能恢复应用状态」(它只替换渲染)
// ⑰ 的完整处理
<ErrorBoundaryWrapper
  onReset={() => {
    queryClient.invalidateQueries({ queryKey: ['user'] });   // ⭐ 重置时重新拉数据
  }}
  fallback={({ error, reset }) => <ErrorView error={error} onRetry={reset} />}
>
  <UserProfile />
</ErrorBoundaryWrapper>
// ⑨ 的生产环境写法:只显示「友好信息」,详细堆栈只上报
function ErrorView({ error, onRetry }: { error: Error; onRetry: () => void }) {
  const isDev = import.meta.env.DEV;

  return (
    <div role="alert">
      <h2>页面出错了</h2>
      <p>请重试,或稍后再访问</p>

      {isDev && <pre>{error.stack}</pre>}       {/* ⭐ 只在开发环境显示堆栈 */}

      <button onClick={onRetry}>重试</button>
      <button onClick={() => window.location.reload()}>刷新页面</button>
    </div>
  );
}

九、面试延伸

  1. 「手写一个 Error Boundary」
    用类组件实现(因为「在渲染失败时替换渲染结果」必须有状态且能被 React 在渲染阶段调用):① static getDerivedStateFromError(error)——渲染阶段被调用,必须是静态纯函数,返回新的 state(用于渲染降级 UI);② componentDidCatch(error, info)——提交阶段被调用,可以做副作用(上报错误,info.componentStack 是定位 React 错误的核心信息);③ reset()——提供重置能力(否则降级 UI 会一直显示);④ render 里按 state.error 决定渲染 fallback 还是 children。

  2. 「为什么 Error Boundary 只能用类组件?」
    因为它的核心能力是「在渲染抛错时替换渲染结果」——这要求一个「有状态、可被 React 在「渲染失败」这个时机调用」的实体。而 Hooks(useState/useEffect)都在「渲染成功之后」才运行,无法介入「渲染失败」这个时机(useState 的初始化函数抛错时,组件函数本身就没返回)。所以 React 规定「Error Boundary 必须是类组件」;但可以用「包装组件」(把类组件包成一个 props 是函数的组件)让使用方是函数组件。

  3. 「Error Boundary 能捕获哪些、不能捕获哪些错误?」
    能捕获:子组件在「渲染」「生命周期方法」「构造函数」中抛出的错误(含所有后代)。不能捕获:① 事件处理函数里的错误(需要 try/catch);② 异步代码(setTimeout、Promise、async 函数);③ useEffect 里抛出的错误(React 18 起不捕获);④ 服务端渲染中的错误;⑤ Error Boundary 自身抛出的错误(需要外层再包一层)。所以「渲染错误用 Error Boundary,其它错误用全局监听/try-catch」(见 8.6)。

  4. 「为什么 getDerivedStateFromError 必须是静态纯函数?」
    因为它在「渲染阶段」被调用——而渲染阶段要满足「纯函数、可被中断、可被重放」(见 4.3)。所以它:① 必须是 static(不能访问 this,避免依赖实例状态导致不确定性);② 不能有副作用(不能调 API、不能改外部状态,否则会被重复执行);③ 只能「根据 error 计算出新的 state」。真正的副作用(上报、日志)应该放在 componentDidCatch(提交阶段,只调用一次)。

  5. 「Error Boundary 应该放在哪里?」
    建议分层放置:① 应用级(最外层,最后兜底,显示「整个页面出错」+ 刷新/返回);② 区域级(每个独立功能块,如「仪表盘」「推荐列表」,一个区域崩了不影响其它区域);③ 第三方内容级(富文本、图表、地图——容易因异常数据崩溃);④ 路由级(每个页面独立兜底,数据路由下用 errorElement)。只放一个最外层的问题是「任何局部错误都会让整页变成错误页」,用户体验很差。

  6. 「『重试』要注意什么?」
    三条:① 清理可能被污染的状态——错误可能来自「缓存里的坏数据」,所以 onReset 里应该 invalidateQueries/resetQueries;② 强制重新挂载——用「改变 key」让子树完全重建(避免「带着坏状态重试 → 又崩」),推荐作为默认行为(remountOnReset);③ resetKeys——在「某些值变化时自动重置」(如「路由变化」「用户切换」),避免用户被卡在错误页。此外「重试后要重新请求数据」(否则显示的还是旧数据)。

  7. 「Error Boundary 能恢复应用状态吗?」
    不能——它只做「替换渲染」。错误发生后:① 出错的子树被卸载,其内部状态丢失;② 但「其它地方的状态」(全局 store、TanStack Query 缓存)可能仍处于不一致的状态(比如「缓存里存了导致崩溃的坏数据」)。所以「恢复」需要主动做:① 清缓存(queryClient.resetQueries());② 重置相关全局状态;③ 重新请求数据;④ 必要时「强制重新挂载子树」。

  8. 「Error Boundary 和 errorElement(数据路由)有什么区别?」
    职责不同:① Error Boundary 是「React 本身的机制」——捕获「渲染、生命周期、构造函数」中的错误(只能是类组件实现);② errorElement 是「数据路由的机制」——它除了「渲染错误」外还能捕获「loader/action 抛出的错误」(尤其是 throw new Response(..., { status: 404 }) 这类「路由级错误」),并且可以「按路由层级配置」(最近的 errorElement 生效),还能用 useRouteError + isRouteErrorResponse 读取结构化的错误信息。实践上两者一起用:路由级错误交给 errorElement,组件内部的局部错误用 Error Boundary 隔离。

一句话速记

Error Boundary 只能用类组件实现(因为要「在渲染失败时替换结果」):static getDerivedStateFromError(渲染阶段、纯函数、返回新 state) + componentDidCatch(提交阶段、可上报,拿 info.componentStack) + 必须提供 reset;只能捕获「渲染 / 生命周期 / 构造函数」的错误,「事件处理、异步、useEffect、SSR、自身」都捕获不到;要分层放置(应用级 + 区域级 + 第三方内容级),只放一层会让局部错误变整页崩;重试三条是「清缓存(resetQueries)、强制重挂载(用 key)、resetKeys 自动重置」;生产环境别显示 error.stack(用 import.meta.env.DEV 判断)但要上报;与 Suspense 的顺序是「ErrorBoundary 在外、Suspense 在内」;路由级错误用 errorElement(能捕获 loader/action 的错误),与 Error Boundary 配合使用。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.9 手写 Error Boundary

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫