1. 先从“白屏事故”说起
如果你写过一段时间 React,大概率经历过这种场景:生产环境某个页面突然全白,用户刷了几次还是白,最后你登录后台一看,一条报错指向某个组件里undefined.xxx——渲染崩了,React 把整棵组件树都卸载掉了。
我刚入行那阵子就栽过一回。某个列表页在特定数据下会访问到不存在的属性,本地开发环境因为热更新和报错遮罩,甚至都没意识到问题已经发生。上线后用户一访问,直接白屏。那次排查花了半天,最后修复代码只加了一个问号。
后来我才真正意识到,React 的错误处理不是“写不写 try/catch”的问题,而是“有没有在正确的层级、用正确的机制、兜住正确类型的错误”的问题。这篇文章把我这些年的实操经验整理成一套可落地的方案,从 Error Boundary 的原理、写法,到事件处理器和异步任务怎么兜底,再到错误上报和排查实录,一步一步拆开讲。不管你是刚接触 React 还是已经在生产环境摸爬滚打,这套内容应该都能给你一些参考。
2. React 错误的分类与处理边界
2.1 渲染期错误、事件错误、异步错误,性质完全不同
React 官方文档里对错误边界(Error Boundary)的定位说得很清楚:它只能捕获组件渲染阶段、生命周期方法、构造函数里抛出的错误。这个“只能”不是偷懒,而是 React 刻意设计的边界,理解了这条边界,你才能把错误处理体系搭在正确的框架里。
我习惯把 React 应用里的错误分成三大类:
第一类是渲染期错误,包括组件首次渲染、状态更新触发的重渲染、getDerivedStateFromProps、componentDidMount等生命周期中抛出的异常。这类错误的特征是:发生在 React 的协调(reconciliation)过程中,React 有能力感知到,所以它能中断渲染流程,并把控制权交给最近的错误边界。
第二类是事件处理器错误,比如onClick、onChange里写的逻辑报错。这类错误 React 不会拦截,因为事件处理器本质上是你通过addEventListener挂在 DOM 节点上的回调,它不在 React 的渲染管线里。React 能做的只是回调执行完毕后检查有没有错误,然后打印到控制台,仅此而已。
第三类是异步错误,包括setTimeout、requestAnimationFrame、Promise 的 rejection、async/await里未捕获的异常、事件流(如 WebSocket 的 onmessage 回调)里的异常。这类错误发生在 React 渲染周期之外,React 根本无从感知,错误边界对它们完全无效。
这三类错误必须分别处理,没有一套机制能一网打尽。这是整个 React 错误处理体系的底层逻辑。
2.2 错误边界机制的三个关键局限
很多开发者第一次看到错误边界能阻止整棵组件树卸载,会觉得它很强,但实际使用中它的局限远比能力更值得关注。
第一个局限:错误边界本身不能捕获自己抛出的错误。如果错误边界组件里的getDerivedStateFromError或render出问题了,那它会直接崩掉,React 只能继续向上找更外层的错误边界。所以实践中不建议做“万能大边界”然后一次包裹整个应用,那样一旦边界自身出问题,连兜底方案都没了。
第二个局限:它捕获不到服务端渲染(SSR)过程中的错误。服务端渲染时组件在 Node 环境里执行,React 的错误边界机制只作用于客户端渲染流程。SSR 报错通常需要靠服务端框架自身的中间件去捕获。
第三个局限,也是很多人忽略的:错误边界捕获到错误后,React 会卸载整个子树。哪怕你只是想让一个小组件优雅降级,只要错误冒泡到边界,边界内的所有组件都会被卸载,替换成 fallback UI。这意味着错误边界的粒度设计直接影响用户体验——不能太大,否则一个小模块出错,整个页面都变成“出错了”三个字;也不能太小,否则每个卡片都包一个边界,代码冗余且维护成本高。
2.3 关于 getDerivedStateFromError 与 componentDidCatch 的分工
React 提供了两个生命周期方法用于错误边界,很多人搞不清楚它们的区别。static getDerivedStateFromError(error)在渲染阶段被调用,用来更新 state,从而触发 fallback UI 的渲染。它必须是一个静态方法,函数体内不能有任何副作用——不能上报日志、不能记录用户行为,因为渲染阶段可能被 React 多次调用,副作用会造成重复上报。
componentDidCatch(error, errorInfo)在错误被捕获后、提交 fallback UI 渲染之前调用,这里才是做错误日志上报的地方。errorInfo.componentStack能拿到组件栈,这对排查问题极其关键。不过 React 从 17 版本开始对这个方法标记了弃用(deprecated),官方建议把副作用放到componentDidCatch,把 state 更新放到getDerivedStateFromError,但未来版本可能会调整这两个方法的使用方式。目前生产项目中,两者配合使用依然是最稳的方案。
3. 基础错误边界组件的完整实现
3.1 从 class 组件说起:为什么函数组件写不了错误边界
先说一个让很多新手困惑的问题:为什么错误边界必须用 class 组件?因为 React 只在 class 组件的生命周期中提供getDerivedStateFromError和componentDidCatch这两个钩子,函数组件没有对应的生命周期,所以函数组件无法直接作为错误边界。React 官方文档明确写了这一点。
组件需要接收一个fallbackprop,同时暴露重置能力,方便用户在出错后重试。
import React from 'react'; class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null, errorInfo: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, errorInfo) { // 这里可以上报错误日志,不要在这里 setState // console.error 在开发环境很有用,生产环境建议替换为上报函数 console.error('ErrorBoundary caught an error:', error, errorInfo); } resetErrorBoundary = () => { this.setState({ hasError: false, error: null, errorInfo: null }); }; render() { if (this.state.hasError) { if (typeof this.props.fallback === 'function') { // 支持函数式 fallback,便于传入 reset 方法 return this.props.fallback({ error: this.state.error, reset: this.resetErrorBoundary, }); } return this.props.fallback; } return this.props.children; } } export default ErrorBoundary;组件的基本逻辑很简单:getDerivedStateFromError把错误状态挂到 state 上,render根据 state 决定渲染 fallback 还是正常子组件。componentDidCatch里同步打印错误信息,开发环境下很容易定位问题。
3.2 支持 reset 的进阶版本:让用户自己“重新加载”
上面这个版本只是兜底展示错误,用户一旦遇到错误就只能刷新页面。一个稍微好一点的方案是提供“重试”按钮。因为很多渲染期错误其实是后端数据异常导致的临时状态,数据恢复后重渲染就能恢复正常。
我推荐给这个基础版再加一个能力:通过监听 props 的变化来自动重置。比如传给 ErrorBoundary 一个resetKey,当resetKey变化时自动清除错误状态。这样父组件在拿到新数据后,可以自然地让边界“复活”。
import React from 'react'; class ErrorBoundaryWithReset extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, errorInfo) { console.error('ErrorBoundary caught an error:', error, errorInfo); } componentDidUpdate(prevProps) { if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { this.setState({ hasError: false, error: null }); } } handleReset = () => { this.setState({ hasError: false, error: null }); }; render() { if (this.state.hasError) { return this.props.renderFallback(this.state.error, this.handleReset); } return this.props.children; } } export default ErrorBoundaryWithReset;这里注意componentDidUpdate中判断resetKey变化后清除错误状态。为什么不在getDerivedStateFromProps里做?因为那是一个静态方法,无法访问this.state的实时值,做条件判断比较别扭。componentDidUpdate里可以直接比较prevProps和prevState,逻辑清晰,副作用(setState 触发重新渲染)也符合它的定位。
注意:
componentDidUpdate里 setState 必须加条件,否则任何一次更新都会触发 setState,可能会进入死循环。
3.3 边界粒度怎么定:我的分级设计思路
错误边界包的粒度直接决定用户体验和排查成本。我的经验是分三层嵌套:
第一层是应用级边界,包在最外层,fallback 是一整页的“页面开小差了,请刷新”,兜住所有未被捕获的渲染错误,保证应用不至于白屏。这一层是最后的防线。
第二层是模块级边界,比如路由懒加载的页面组件、复杂的业务大表单、数据看板这种单个模块的根部。fallback 是模块级提示,比如“该模块暂时无法显示”,并附带重试按钮。这一层的作用是:某个模块出错了,用户只损失这个模块,其他功能不受影响。
第三层是组件级边界,针对高风险组件,比如嵌入的第三方地图、富文本编辑器、大量的列表项等。这类组件往往容易因为异常输入数据崩溃,且崩溃后影响范围可控,组件级边界让它们单独降级。
这个分级不是绝对的,但原则很明确:不要让一个边界背负整个应用,也不要把每个按钮都包成边界。边界越多,React 渲染开销越大,代码维护成本也越高。一般一个中大型应用,10 到 20 个错误边界是合理的范围。
4. 事件处理器与异步错误的兜底策略
4.1 为什么 onClick 里的错误没人管
前面说过,事件处理器里的错误不会被错误边界捕获。很多项目里,一个点击事件调用的函数里如果抛出异常,唯一的表现就是控制台红色的报错,用户侧毫无感知,功能静默失败。
比如某个按钮点击后要发起请求并更新本地 state:
async function handleClick() { const data = await fetchData(); setList(data); }如果fetchData内部 throw 了,且调用方没有 try/catch,那这个 Promise 就变成一个 rejected 的 Promise。如果全局没有监听unhandledrejection,错误会直接丢给浏览器打印,页面上的按钮没有任何反馈,用户会以为点了没反应,疯狂连点。
我的习惯是:所有事件处理器里,涉及到异步操作的第一行就套上 try/catch。同时配合一个统一的错误提示机制,比如全局的 message/toast,告诉用户操作失败,而不是让用户对着一个毫无反应的按钮干瞪眼。
async function handleClick() { try { setLoading(true); const data = await fetchData(); setList(data); } catch (err) { // 上报 + 用户提示 reportError(err); showToast('数据加载失败,请稍后重试'); } finally { setLoading(false); } }这不是什么高深技巧,但能把用户从“点了没反应”的困惑里解放出来。很多 bug 之所以被用户吐槽“系统不好用”,不是底层逻辑有多复杂,而是错误发生后连一个反馈都没有。
4.2 异步错误的三种捕获思路
异步错误要分场景讨论。
第一种是 Promise 链式调用。如果你用.then().catch()的写法,务必在链尾加上.catch(),并且把catch里的回调也看作“事件处理器”,同样需要兜底。
第二种是async/await。核心思路是 try/catch 包裹一切可能 reject 的调用,但有些开发者会陷入“到处写 try/catch”的困境,代码里充满了重复的捕获逻辑。我的优化方案是把数据请求封装成专门的 request 函数或 hook,在封装层统一处理then和catch,业务代码里只用响应结果。
第三种是全局兜底。浏览器提供了两个全局事件:window.onerror和window.addEventListener('unhandledrejection')。前者捕获运行时同步错误,后者捕获未被处理的 Promise rejection。我自己项目的做法是:
window.addEventListener('unhandledrejection', (event) => { const reason = event.reason; // 过滤掉已经被业务层处理过的错误 if (reason && reason.__handled__) { event.preventDefault(); return; } report(reason); });要强调一点:unhandledrejection捕获的是“未被处理”的 rejection,如果业务代码已经 try/catch 了,这里不会触发。所以这个全局钩子纯粹是最后一道安全网,用来兜住那些“漏网之鱼”,而不是作为主要的错误处理手段。
4.3 请求层的统一错误处理
在真实业务里,90% 的错误其实都来自网络请求。我强烈建议你做一层统一的请求封装,把错误分类、上报、提示的逻辑收敛到一个地方,而不是让每个业务组件各自处理。
一个典型的请求封装大概是这样的逻辑:
async function request(url, options) { try { const response = await fetch(url, options); // 统一处理非 2xx 状态码 return await parseResponse(response); } catch (networkError) { // 网络异常:断网、超时、DNS 解析失败等 throw normalizeError(networkError, { type: 'NETWORK' }); } }调用方拿到normalizeError后的标准化错误对象,可以判断类型(网络错误、超时、业务错误、未授权等),然后决定是需要弹 toast、跳登录页还是静默重试。这套机制配合错误边界,覆盖了应用中绝大部分的错误场景。
5. 错误上报与用户反馈体系
5.1 上报的时机与去重策略
错误边界捕获到错误后,并不是每一次都需要上报。我的经验是加一道去重和采样逻辑:
- 同一个组件、同一个错误信息、同一时间段内只上报一次。
- 可以记录次数,但不需要每次都发网络请求,减少对监控平台的冲击。
- 生产环境可以按比例采样,比如 1% 的用户上报,但严重错误(比如白屏级别的)必须全量上报。
实现上很简单,内存里维护一个 Map,key 是${componentName}:${error.message},value 是时间戳。如果当前时间减时间戳小于某个阈值(比如 30 秒),就跳过上报。
5.2 上报数据里应该包含什么
一份可用的错误上报数据,至少包含以下字段:
- 错误信息
error.message - 错误堆栈
error.stack - 组件栈
errorInfo.componentStack - 当前路由
window.location.href - 用户代理
navigator.userAgent - 应用版本号,方便关联发布记录
这里特别强调组件栈。componentStack是 React 独家提供的信息,它能告诉你“出错的组件长在哪个组件树分支上”,作用类似于源码地图。没有它,你拿到error.stack往往只能看到 minified 后的代码位置,对定位业务问题帮助有限。所以上报时一定要带上。
5.3 给用户的降级反馈要有“出路”
错误边界 fallback 页面里,我始终坚持三条原则:
第一,告诉用户发生了什么,用中性语言,不要甩技术细节。比如“页面暂时无法显示”,比“TypeError: Cannot read properties of undefined”友好一万倍。
第二,提供可操作路径。刷新按钮是最基本的,重试按钮是更好的。如果是模块级边界,还可以提供“跳转到首页”的入口。
第三,保留线索。fallback 页面可以留一个“复制错误信息”的按钮,用户愿意配合时,能帮你拿到第一手现场数据。虽然大多数用户不会点,但偶尔会遇到愿意反馈的用户,这时的信息价值极高。
6. 常见问题排查与避坑实录
6.1 表格速查:这些场景为什么兜不住
我整理了一份高频问题对照表,基本都是我在真实项目中踩过的坑:
| 故障场景 | 表面现象 | 根本原因 | 正确处理方式 |
|---|---|---|---|
| 事件回调里抛异常 | 页面无报错弹窗,控制台有红色错误 | 错误边界不捕获事件处理器 | 事件处理器内自行 try/catch 或全局 error 兜底 |
| 请求失败后 UI 无反馈 | 按钮点击无反应,列表为空 | Promise rejection 未被处理 | 请求封装层统一处理 + 消息提示 |
| 动态数据缺失导致白屏 | 整页白屏,浏览器无报错提示 | 渲染期抛错被应用级边界兜住,但 fallback 被样式覆盖 | 调整 fallback UI,检查边界包裹层级 |
| 错误组件循环崩溃 | 页面反复挂载卸载 | 边界内 fallback 也里抛错 | fallback 要用极简静态组件,不依赖业务数据 |
| 生产环境看不到报错堆栈 | 只有 minified 后的代码 | 未启用 Source Map 或未上报 componentStack | 构建启用 source map,上报组件栈 |
| 错误边界自身报错 | 边界 fallback 没渲染,页面白屏 | 边界组件生命周期抛错,无外层边界兜底 | 设计最外层边界为“纯静态” |
6.2 真实案例:一个“无法退出”的错误循环
有一次模拟项目X里遇到一个诡异问题:错误边界捕获到错误后,fallback 正常显示,但用户点击重试按钮后,页面立刻再次崩溃,再重试再崩溃,循环不断。
最终排查发现,问题出在 fallback 组件本身:我设计的重试按钮走了一个全局数据请求,请求失败后又抛错,而这次抛错发生在按钮的事件处理器里,虽然不会触发错误边界,但事件处理器内部逻辑写得不严谨,把错误重新抛到了渲染层。具体来说,按钮点击后先 setState 触发了重渲染,这个重渲染路径上访问了一个尚未恢复的undefined。
这个案例给我的教训是:fallback 组件一定要做到极简,最好是无状态、无依赖、无逻辑的纯静态内容。重试按钮的“重试”动作本身可以复杂,但 fallback 的渲染路径绝不能再依赖任何可能出错的业务数据。
6.3 生产环境排错三板斧
生产环境下,错误边界弹了 fallback,用户截图过来了,你怎么快速定位?
第一板斧:看上报的错误信息和 componentStack。如果是undefined访问错误,重点检查接口返回的数据结构,特别是新增字段或数组长度边界。
第二板斧:用版本号定位。上报数据里带上应用版本号,对照发布记录,看是否最近上线改动引入的。回归到代码层面,重点 review 最近改动涉及到的组件。
第三板斧:用环境信息缩小范围。如果是特定浏览器、特定操作系统才报错,可能是兼容性问题。如果只在移动端微信内置浏览器里出现,通常要考虑 HTML5 特性兼容、内存限制或缓存问题。
这三板斧不能解决所有问题,但能帮你把问题范围缩小到“哪个组件、哪个版本、哪个环境”,剩下的事交给时间。
7. 沉浸式实操:给 React 应用装上四层兜底
最后分享一个我在团队里推的标准化做法,核心是“四层兜底架构”。如果你是项目负责人,可以直接抄这份方案往下落实。
第一层是事件/异步层。所有事件处理器使用统一的withErrorHandler高阶函数包裹,所有数据请求用统一的 request 封装,标准化错误类型。这一层解决的是“功能静默失败”问题。
第二层是组件边界层。三级粒度嵌套:应用级、模块级、组件级。错误边界组件用本文第 3 节那个支持 reset 的版本,fallback 极简,具备重试和跳转入口。
第三层是全局兜底层。window.onerror和unhandledrejection两个全局监听,避免任何漏网错误被浏览器吞掉。同时做去重和采样,避免上报风暴。
第四层是监控与复盘层。每次上线前 review 错误统计,关注 Top 10 错误,修复后验证上报量下降。错误处理不是部署完就结束了,它是一个需要持续迭代优化的体系。
这套架构下去之后,团队里再也没出现过一个组件崩溃导致整站白屏的情况。依赖的错误边界层级和 fallback 方案也都是可以随时调整的,不会有什么架构包袱。
8. 说说错误处理的心态
做错误处理久了,你会发现一个有意思的规律:真正把系统搞挂的,往往不是核心业务流程,而是那些你没预料到的边界情况。接口返回了个空数组、用户输入了个超长字符串、后端某个字段临时改了类型……这些都不是代码逻辑错了,是现实世界比我们想象的更复杂。
所以错误处理的核心问题不是“怎么写 try/catch”,而是“如何在错误发生后维持系统的可用性、可观测性和用户信任”。把这句话想明白了,你再回来看 Error Boundary 的粒度、上报的策略、fallback 的设计,你会发现每一个决策点都有了明确的取舍依据。
我个人在实际项目里最受益的一条经验是:永远给最外层留一个兜底边界,并且定期主动在测试环境模拟一次线上错误。模拟的方法很简单,在某个组件里临时写一行throw new Error('test'),发布后看 error 上报是否正常、fallback 是否按预期渲染、用户反馈是否可达。这套“故障演练”我大概每个季度做一次,每次都能找出一些遗漏,比如某个上报字段丢了、某个 fallback 的按钮跳错了路由。
如果这篇文章只能留下一句话,那就是:不要指望任何单一机制解决所有错误,把兜底做成体系,把体系当成产品来打磨,你的 React 应用会稳定得超出预期。