CClara轻量论坛系统

React 错误边界(Error Boundary)实现与最佳实践

玄墨染
玄墨染 正式会员正式会员认证极客认证极客
发布于 2026-11-02 14:08 ·1 浏览 ·6 回复
内容摘要

说明错误边界只能由类组件通过 getDerivedStateFromError 切换兜底 UI、componentDidCatch 记录上报来实现,需提供重置与重试机制并用 key 或 resetKeys 清空错误态,Hooks 项目推荐使用 react-error-boundary,同时指出重试应重新拉取数据以避免脏数据导致重复报错。

学完这篇,你能写出一个真正好用的 React 错误边界,知道该把它放在哪一层,也能避开「加了边界还是白屏」的常见坑。

第一步:写出最小可用的错误边界

错误边界目前只有类组件能定义,核心是两个生命周期:

class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };   // 渲染阶段兜底,必须同步返回新 state
  }

  componentDidCatch(error, info) {
    console.error(error, info.componentStack); // 这里做上报,别改 state
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? <p>这段内容加载出错了</p>;
    }
    return this.props.children;
  }
}

用法就是把它包在需要保护的子树外面:

<ErrorBoundary fallback={<p>侧边栏挂了</p>}>
  <Sidebar />
</ErrorBoundary>

getDerivedStateFromError 负责切到兜底 UI,componentDidCatch 负责记录。两者分工不要混:前者必须是纯函数,后者才允许副作用。

注意:getDerivedStateFromError 里不要写 console.log、请求、setState,它会在渲染阶段被调用,产生副作用会导致行为不可预期。

第二步:给兜底 UI 加上「重试」

错误边界一旦进入错误态,如果不重置 state 就会永远停在兜底页。最省事的做法是加一个重置方法,并允许父组件通过 key 强制重建:

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

render() {
  if (this.state.hasError) {
    return (
      <div>
        <p>加载失败</p>
        <button onClick={this.reset}>重试</button>
      </div>
    );
  }
  return this.props.children;
}

父组件侧可以这样用:

<ErrorBoundary key={userId} fallback={<Error />}>
  <Profile userId={userId} />
</ErrorBoundary>

切换用户时 key 变化,组件重建,错误态自动清空。

注意:如果错误是因为数据本身有问题(比如接口一直返回脏数据),用户点「重试」还是会立刻再次报错。重试按钮最好同时触发一次数据重新拉取,而不是只清 state。

第三步:函数组件项目直接用 react-error-boundary

手写类组件在 Hooks 项目里很别扭。社区方案 react-error-boundary 已经把常用能力封装好了:

npm i react-error-boundary
import { ErrorBoundary } from 'react-error-boundary';

function Fallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>出错了:{error.message}</p>
      <button onClick={resetErrorBoundary}>重试</button>
    </div>
  );
}

<ErrorBoundary
  FallbackComponent={Fallback}
  onError={(error, info) => reportToServer(error, info.componentStack)}
  onReset={() => queryClient.invalidateQueries()}
  resetKeys={[userId]}
>
  <Profile userId={userId} />
</ErrorBoundary>

resetKeys 里的值变化时会自动重置,比手动传 key 更直观;onReset 用来清理缓存、重新请求。

第四步:决定边界放在哪一层

这是最容易做错的地方。只在根节点包一层,等于任何一个角落报错都会整页白屏。

合理的分层是:

  • 路由层:每个路由页面一个边界,一个页面挂了不影响导航和全局布局
  • 区块层:侧栏、评论区、图表、第三方嵌入内容,各自独立包裹
  • 不包裹:按钮点击、表单提交这类交互,用 try/catch 就够了
<Layout>
  <ErrorBoundary fallback={<PageError />}>
    <ErrorBoundary fallback={<SidebarError />}>
      <Sidebar />
    </ErrorBoundary>
    <ErrorBoundary fallback={<ContentError />}>
      <Routes />
    </ErrorBoundary>
  </ErrorBoundary>
</Layout>

第五步:知道它捕获不到什么

错误边界只处理「渲染期间」抛出的错误,以下场景它管不了:

场景处理方式
事件处理器 onClick 里报错自己 try/catch
setTimeout / Promise 回调window.onunhandledrejection
服务端渲染服务端自己的错误处理
错误边界自身抛错上一层的边界接住
useEffect(() => {
  const handler = (e) => reportToServer(e.reason);
  window.addEventListener('unhandledrejection', handler);
  return () => window.removeEventListener('unhandledrejection', handler);
}, []);

注意:生产环境的兜底 UI 不要直接展示 error.stack 或后端原始错误信息,容易泄露路径和内部结构。展示一个错误 ID,把详情送到监控平台就好。

小结

  • 错误边界必须是类组件,靠 getDerivedStateFromError 切 UI、componentDidCatch 做上报
  • 一定要提供重置手段:resetKeys、key 或重置按钮
  • 边界要分层放,路由级 + 区块级,别只包根节点
  • 函数组件项目用 react-error-boundary,省掉样板代码
  • 它只捕获渲染期错误,异步和事件回调要另接 unhandledrejection 与 try/catch
  • 兜底 UI 不要暴露错误堆栈,给用户一个 ID,给监控完整堆栈
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1071.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

陈先生
陈先生 正式会员正式会员认证极客认证极客 1楼 2026-11-02 14:18

第三步的结论先说:函数组件项目直接上 react-error-boundary,别自己维护类组件,但边界放错层级才是白屏的真正原因,换库解决不了。

react-error-boundary 的核心就三个 API:<ErrorBoundary FallbackComponent={...} onReset={...} resetKeys={[userId]}> 包住子树,配合 useErrorBoundary() 在函数组件里手动 showBoundary(err) 把异步错误塞进来。resetKeys 就是帖子里 key 方案的进阶版——数组里任一值变化自动重置,不用整棵子树重建。FallbackComponent 收到的 props 里有 error 和 resetErrorBoundary,你可以在 reset 回调里同时触发重新请求,正好解决你提到的“数据脏导致重试秒挂”。

层级建议两层起步:路由层放一个全页兜底(配 resetKeys={[location.pathname]},换路由自动恢复),业务模块再各放一个局部兜底。只放最外层等于用户一个侧边栏挂掉整站变白屏,局部边界能把故障圈在最小范围。

几个它抓不到的场景,必须另做兜底:事件回调里的报错(onClick 内 throw)、setTimeout/Promise/async 里的异步错误、SSR 阶段、以及边界自身 render 抛出的错。这些走 window.onerror + unhandledrejection 全局监听上报,React 18 用 createRoot(container, { onUncaughtError, onRecoverableError }) 也能拿到。

最后一个坑:componentDidCatch 里上报要加节流/去重,同一个错误在快速重渲染下可能连发几十条,把监控打爆。上报字段至少带 componentStack,否则线上根本定位不到是哪个组件。

18565457544
18565457544 见习用户见习用户 #1974 2楼 2026-11-02 14:25
陈先生:第三步的结论先说:函数组件项目直接上 `react-error-boundary`,别自己维护类组件,但**边界放错层级才是白屏的真正原因**,换库解决不了。 …

层级那条我完全同意,再补一个更隐蔽的坑:边界捕获不到「自己抛的错」和「fallback 内部抛的错」。所以 fallback 组件千万别依赖同一个 Context / Provider:如果 Provider 自己挂了,兜底 UI 会跟着一起挂,那就真白屏了。把兜底 UI 写得尽量“无依赖”(纯文案 + 一个 reload 按钮),比什么都重要。

resetKeys 有个循环坑要注意:数据本身还是脏的,resetKeys 一变就重置 → 立刻再报错 → 用户看到闪烁。加个重试计数器(onError 里累加,超过 N 次切到「请联系客服 / 反馈」态)比无限重置体面得多,也顺便把重复上报压下去了。

再补两个线上高频场景:① Suspense + lazy 的 chunk 404,发版后旧 chunk 被清掉,用户点路由直接 Loading chunk failed。边界能抓到,但重试没用,兜底 UI 应该提示刷新页面,或者自动 location.reload() 一次(用 sessionStorage 打标记防死循环)。② StrictMode 下 dev 环境 componentDidCatch 会触发两次,你按 error.message 去重的话反而合了,但按次数统计会翻倍,用 error + componentStack 的 hash 做 key 更稳。

最后,建议给全局监听再补一层「白屏兜底」:定时或在路由变化后检查 root 容器是否为空,是则单独上报一次。这一层能覆盖边界完全没碰到的纯空白态,比等用户截图强。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1975 3楼 2026-11-02 14:33
18565457544:层级那条我完全同意,再补一个更隐蔽的坑:边界捕获不到「自己抛的错」和「fallback 内部抛的错」。所以 fallback 组件千万别依赖同一个 Contex…

这几条都踩在真问题上,尤其「fallback 不依赖同一个 Provider」比层级更致命——层级放错只是局部白屏,兜底 UI 自己挂了就是兜底全白,连重试按钮都点不到。

几个可以再收紧的点:

白屏检测别只判容器为空,误报会很多:Suspense 挂起中、懒加载 chunk 还没到时 root 容器本来就是空的。建议 MutationObserver 监听容器 + 连续两次 rAF(或 2-3s 延迟)二次确认仍为空才上报,页面里存在可见的 loading 节点就直接跳过这轮。

chunk reload 的 sessionStorage 标记别用布尔值,用 chunk 名或当前发版号做 key——共用布尔标记的话,一次会话里只救得回第一个挂掉的 chunk,第二个直接卡死。再叠一个 10s 时间窗比纯计数稳。

去重 key 建议用 error.message + 规范化 stack 前 3 帧。componentStack 在 prod 会被压缩,dev/prod 格式本身也不一样,直接 hash 会让两边数据对不上,反而更难聚合。

重试计数器要跟 resetKeys 联动清零:resetKeys 变了说明上下文换了,计数应归零,否则翻两次页就误进「联系客服」态。另外用指数退避(1s/2s/4s)比硬阈值体面。

补一个:动态 import 失败的 reject 会走 unhandledrejection,未必经过边界——reload 决策最好统一收在全局监听器里判断,别边界和监听器各写一套,容易出现双重 reload。

最长的电影
最长的电影 正式会员正式会员 #1976 4楼 2026-11-02 14:38
一只冷漠的狐狸:这几条都踩在真问题上,尤其「fallback 不依赖同一个 Provider」比层级更致命——层级放错只是局部白屏,兜底 UI 自己挂了就是兜底全白,连重试按钮…

哨兵节点那段我建议直接替掉「判空」方案:在 App 最外层渲染一个常驻的不可见标记(<div data-app-mounted />),检测逻辑就变成三段判断——标记不在,说明整棵树没挂上,这时不该调用 reload,而应上报一次初始化失败;标记在、视口内无可见内容、且没有可见 loading 节点,才是真正的白屏。这样 Suspense 挂起和 chunk 未到天然不会误报,比 rAF 二次确认省事,也不用猜 React 的渲染时序。再加一个 document.visibilityState === 'visible' 才会进上报队列,后台标签页直接跳过。

reload 收口这条我完全同意,具体就给一个模块级单例 scheduleReload(key),内部 Map<key, ts>,10s 窗口内同 key 只放行一次,边界、全局监听、preloadError 全调它。补一点:Vite 用户可以直接监听 vite:preloadError,它只在动态 import 预加载失败时触发,比在 unhandledrejection 里靠 message 正则匹配 Loading chunk 精准得多,也不会把业务 Promise 异常误判成 chunk 挂掉。

去重 key 我再加一维:release(发版号)要作为独立字段上报,hash 只用来做前端限流。聚合看 error.message,限流看 msg + stack 前3帧 + release,这样跨版本不会混在一起。规范化时记得剔掉 /assets/xxx.a1b2c3.js 这类带 hash 的路径和行列号,否则每次发版 hash 全变,等于没去重。

重试计数器用 ref + 指数退避没问题,resetKeys 变就清零这条我认同;到阈值切客服态时把 error.digest(React 19)一起带上,后端能直接对上日志。

坑:哨兵节点千万别放在路由内或任何会卸载的子树里,否则每次切路由都触发一次误报;另外 reload 记得带 cache-busting(location.replace(url + '?t=' + Date.now())),不然 CDN 缓存旧 HTML 时 reload 出来的还是坏的那份。

18565457544
18565457544 见习用户见习用户 #1977 5楼 2026-11-02 14:41
最长的电影:哨兵节点那段我建议直接替掉「判空」方案:在 App 最外层渲染一个常驻的不可见标记(``),检测逻辑就变成三段判断——标记不在,说明整棵树没挂上,这时不该调用 …

哨兵方案我采纳,但哨兵别用 React 渲染——放在 App 最外层虽然不会被业务子树卸载拖累,可如果是 SSR/预渲染,哨兵会跟着 HTML 一起下发,那「HTML 到了但 JS 没跑起来」这一段盲区就检测不到了。建议在入口脚本里 document.documentElement.dataset.appMounted = '1' 直接写 DOM,这样三态就完整了:没标记 = 入口没执行/初始化失败;有标记 + 空内容 = 真白屏;有标记 + 有 loading = 正常挂起。

第二段判断我再补一刀:可见内容别遍历子树,直接用 document.elementFromPoint(innerWidth/2, innerHeight/2),拿到的还是 body/html 就基本能定白屏,成本几乎为零。但「可见 loading 节点」得靠约定——给所有骨架屏/加载态统一挂 data-loading,否则你永远猜不到别人的 fallback 长什么样,误报全从这来。

scheduleReload 落地时记住两点:上报一定要走 navigator.sendBeacon 或 fetch(…, {keepalive:true}),location.replace 一执行,普通 fetch 直接被掐;另外 vite:preloadError 必须 e.preventDefault() 掉默认抛错,否则你还是会在 unhandledrejection 里收到一遍,去重逻辑白做。

error.digest 有个预期要放平:客户端 render 抛出的错误通常是 undefined,RSC/服务端那侧才有值,当可选项传就好。最后 ?t= 这个 cache-busting 参数会进 history 和统计口径,入口处记得 history.replaceState 清掉。

runyu
runyu 正式会员正式会员认证极客认证极客 #1978 6楼 2026-11-02 14:51
18565457544:哨兵方案我采纳,但**哨兵别用 React 渲染**——放在 App 最外层虽然不会被业务子树卸载拖累,可如果是 SSR/预渲染,哨兵会跟着 HTML 一起下发…

哨兵落成 DOM dataset 这一步比挂在 React 树上更对,尤其有 SSR/预渲染时,跟着 HTML 下发的标记确实把“HTML 到了但 JS 没跑”那段盲区漏掉了。我想再往前推一格:dataset 至少要有两级,才能对应出第三种状态。

入口脚本再往前拆一个标记。 appMounted 是在入口模块里写的,但入口 chunk 自己 404 时它也不会出现,结果会和“初始化失败”混成一类。可以在 <head> 里加一行内联 <script>document.documentElement.dataset.boot='1'</script>,这样就有四种状态:

  • 没有 boot:JS 被拦或 HTML 没到,不该 reload,直接上报异常;
  • 有 boot 但没有 appMounted:入口 chunk 挂了(典型发版),这时 reload 是有意义的;
  • 有 appMounted + 空内容:真白屏;
  • 有 appMounted + loading:正常挂起。

elementFromPoint 建议换成 elementsFromPoint 取整个栈。 取单点会遇到全屏透明遮罩(埋点 SDK、toast 容器、z-index 极高的 position:fixed 层),此时拿到的是一个真实元素,但页面其实是白的,会变成漏报。拿到栈之后从栈顶往下找第一个「有可见文本或 getBoundingClientRect 面积 > 0 且 opacity/visibility 正常」的元素,一路找到底都是 body/html 才判白,成本仍然是一次布局读取。

loading 检测别只靠 data-loading 约定。 补一条 aria-busy="true" 或 role="progressbar" 也算挂起,能覆盖掉一部分第三方组件和现成骨架屏;同时把 data-loading 写进脚手架模板和 CR 清单,否则约定半年就散了。

sendBeacon 有个 64KB 上限,componentStack 长的组件树很容易超,上报前把 componentStack 截到前 10 帧、stack 剔掉 asset hash,和你的去重 key 用同一套规范化函数,两边口径也能对齐。

最后 ?t= 清 history 时注意别把 hash 干掉,HashRouter 的站直接 replaceState(null, '', pathname + hash) 就行,别写成 pathname 单独一个。