前端错误边界与可观测性:让白屏有证据
白屏不可怕,没证据才可怕
去年接手过一个电商后台项目,用户隔三差五反馈「页面打不开」,但每次我们去查日志,Sentry 里只有零星的几条压缩后异常堆栈,既不知道用户当时在哪个路由,也不知道是哪个接口返回了异常数据。最夸张的一次,同一个页面在一周内被四个用户报告白屏,我们花了两天才定位到是某个字段从 null 变成了 undefined 导致的渲染崩溃——而这个差异只在特定订单状态下才出现。
这件事让我意识到:前端错误处理的核心问题不是「能不能捕获」,而是「捕获之后有没有足够的证据还原现场」。ErrorBoundary 负责兜底 UI,可观测性负责还原现场,两者缺一不可。
错误边界的工作原理
React 的 Error Boundary 机制从 React 16 开始引入,官方文档把它定义为「可以捕获子组件树中 JavaScript 错误的组件」。它的底层依赖 componentDidCatch 和 getDerivedStateFromError 两个生命周期方法,前者负责捕获错误并上报,后者负责渲染降级 UI。
这里有一个关键限制:Error Boundary 只能捕获渲染阶段的错误,事件处理函数里的异常、异步代码(setTimeout、Promise.then)、服务端渲染和错误边界组件自身的错误都不在它的管辖范围内。React 团队在 RFC 中讨论过这个设计决策——他们认为渲染阶段的错误最需要兜底,因为未捕获的渲染异常会导致整棵组件树卸载,页面直接白屏。
把这个原理画清楚:
理解了这个流程,就能明白为什么单靠 ErrorBoundary 不够——它只解决了「不白屏」的问题,如果 componentDidCatch 里没有足够的上下文信息,开发团队拿到的仍然是一条无法复现的孤立异常。
分级部署错误边界
在实际项目中,我倾向于把错误边界分成三个层级,每层的职责和降级策略不同。
全局层:兜底整个应用
应用最外层放一个全局错误边界,职责是防止「整站白屏」。当出现意料之外的级联错误时,至少给用户一个可操作的页面,而不是一片空白。
// ❌ 坏做法:全局边界只返回一行文字,用户不知所措
class AppErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error: Error) {
// 只上报了 error,没有上下文
reportError(error)
}
render() {
if (this.state.hasError) {
return <p>出错了</p>
// 用户看到这三个字,除了刷新什么也做不了
}
return this.props.children
}
}// ✅ 好做法:全局边界提供明确信息和恢复路径
class AppErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error: Error | null }
> {
state = { hasError: false, error: null }
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
reportError({
error,
// 关键差异:同时上报组件堆栈和运行时上下文
componentStack: info.componentStack,
route: location.pathname,
buildVersion: BUILD_VERSION,
userId: getCurrentUserId(),
})
}
render() {
if (this.state.hasError) {
return (
<GlobalFallback
onRetry={() => this.setState({ hasError: false, error: null })}
onBack={() => navigate(-1)}
/>
)
// 用户可以选择重试或返回上一页
}
return this.props.children
}
}两种写法的核心差异不在 UI 层面,在于 componentDidCatch 里是否记录了足够的定位信息。组件堆栈(componentStack)能告诉你错误发生在组件树的哪一层,配合路由和构建版本号,基本上能在 5 分钟内定位到代码位置。
功能区块层:隔离独立模块
一个后台页面通常包含多个独立模块——数据表格、图表、侧边筛选器。某个图表渲染崩了不应该影响表格数据的展示。Sentry 的工程博客也建议「按功能区域设置错误边界,让错误的爆炸半径尽可能小」。
// ❌ 坏做法:整个页面共用一个 ErrorBoundary
function DashboardPage() {
return (
<ErrorBoundary fallback={<PageFallback />}>
<DataPanel />
<ChartPanel /> {/* 这里崩了,DataPanel 也没了 */}
<FilterPanel />
</ErrorBoundary>
)
}// ✅ 好做法:每个独立模块有自己的错误边界
function DashboardPage() {
return (
<div className="dashboard-grid">
<ErrorBoundary
fallback={<ModuleFallback title="数据面板" />}
// 标签用于监控平台的分组过滤
tags={{ module: 'data-panel' }}
>
<DataPanel />
</ErrorBoundary>
<ErrorBoundary
fallback={<ModuleFallback title="趋势图" />}
tags={{ module: 'chart-panel' }}
>
<ChartPanel />
</ErrorBoundary>
<ErrorBoundary
fallback={<ModuleFallback title="筛选器" />}
tags={{ module: 'filter-panel' }}
>
<FilterPanel />
</ErrorBoundary>
</div>
)
}分区部署的另一个好处是监控平台可以按 module 标签聚合错误。上线一个月后拉个报表,chart-panel 的错误率是 2.3%、data-panel 是 0.1%——哪个模块最不稳定一目了然。
组件层:高风险组件的防护网
某些组件天然是错误高发区:接收外部数据的第三方组件、渲染用户上传内容的组件、使用 dangerouslySetInnerHTML 的组件。这类组件外面值得单独包一层 ErrorBoundary。
// 第三方图表组件可能在不同数据格式下抛异常
function SafeChart({ data, config }: ChartProps) {
return (
<ErrorBoundary
fallback={<ChartUnavailable dataId={config.dataId} />}
tags={{ component: 'third-party-chart', dataId: config.dataId }}
>
<ThirdPartyChart data={data} config={config} />
</ErrorBoundary>
)
}可观测性:让错误有上下文
ErrorBoundary 解决「捕获」和「降级」,可观测性解决「定位」和「预防」。一套可观测体系需要回答三个问题:错误发生在谁的身上、他当时在做什么、系统当时的状态是什么。
结构化错误上报
直接把 error.message 扔到监控平台是远远不够的。我通常按以下结构组织一条错误日志:
| 字段 | 说明 | 示例 |
|---|---|---|
error.name | 错误类型 | TypeError |
error.message | 错误信息 | Cannot read properties of undefined (reading 'map') |
error.stack | 调用堆栈 | 经过 SourceMap 还原后的堆栈 |
componentStack | React 组件堆栈 | in ChartPanel > in DashboardPage > in App |
route | 当前路由 | /dashboard/orders |
queryParams | 查询参数 | { page: 3, status: 'pending' } |
userActions | 最近 10 步操作 | ['click:filter', 'api:GET/orders', ...] |
apiStates | 近期接口响应 | [{ url: '/api/orders', status: 500 }] |
buildVersion | 构建版本号 | a3f2c1d |
userAgent | 浏览器/设备信息 | Chrome 125 / macOS |
// ❌ 坏做法:上报一个裸 Error 对象
function componentDidCatch(error: Error) {
Sentry.captureException(error)
// 在 Sentry 里只能看到一行堆栈
// 不知道用户在哪个页面、做了什么操作、接口返回了什么
}// ✅ 好做法:上报结构化上下文
function componentDidCatch(error: Error, info: React.ErrorInfo) {
Sentry.withScope((scope) => {
scope.setTag('module', this.props.tags?.module ?? 'unknown')
scope.setTag('build', BUILD_VERSION)
scope.setContext('react', { componentStack: info.componentStack })
scope.setContext('route', {
path: location.pathname,
query: Object.fromEntries(new URLSearchParams(location.search)),
})
scope.setContext('environment', {
buildVersion: BUILD_VERSION,
env: APP_ENV,
})
// 面包屑已经在 Sentry.init 中通过 integrations 自动记录了
// 包括路由变化、点击事件、fetch/XHR 请求
Sentry.captureException(error)
})
}差异在于:前者在 Sentry 里只能看到一条异常堆栈,需要反复联系用户问「你在哪个页面操作的时候出的问题」;后者直接能看到用户在 /dashboard/orders?page=3 触发了一个 500 响应,然后组件树在 ChartPanel 渲染时崩溃,完整的证据链直接支撑定位。
错误分类与分级
不是所有错误都值得报警。我把前端错误分成三类,每类对应不同的处理策略:
| 分类 | 特征 | 处理策略 | 是否报警 |
|---|---|---|---|
| 可恢复错误 | 网络超时、接口限流、数据加载中异常 | 自动重试 + 展示 loading 或骨架屏 | 否,只记录 |
| 业务空状态 | 数据为空、无权限、资源不存在 | 展示空状态 UI 或权限提示 | 否,属于正常业务路径 |
| 真异常 | TypeError、ReferenceError、数据格式异常 | 降级 UI + 立即上报 + 严重等级标记 | 是,按影响面设置告警 |
// ❌ 坏做法:所有错误一视同仁
function handleApiError(error: unknown) {
Sentry.captureException(error)
showToast('加载失败,请稍后重试')
// 网络超时和数据结构异常走了同一条路
// 超时应该静默重试,不该和用户说「请稍后」
}// ✅ 好做法:按错误类型分别处理
function handleApiError(error: unknown) {
if (isNetworkError(error)) {
// 网络错误:静默重试,不打扰用户
return retryRequest(error.config, { maxRetries: 3 })
}
if (isBusinessError(error)) {
// 业务错误:正常路径,不需要上报
return showEmptyState(error.code)
}
// 真异常:上报 + 降级 + 报警
Sentry.captureException(error, {
tags: { errorCategory: 'unexpected' },
extra: { responseStatus: error.status, responseData: error.data },
})
showFallbackUI()
notifyOnCall(error)
}面包屑追踪
Sentry 的面包屑(Breadcrumbs)机制是可观测性里最被低估的部分。它自动记录用户在出错前的操作序列——路由跳转、DOM 点击、console 调用、HTTP 请求——形成一条时间线。
| 面包屑类型 | 自动记录 | 需要手动添加 |
|---|---|---|
| 路由变化 | ✅(react-router integration) | — |
| DOM 点击 | ✅(默认开启) | — |
| HTTP 请求 | ✅(fetch/XHR 拦截) | — |
| console.log | ✅(默认开启) | — |
| 自定义业务事件 | — | ✅(用户登录状态、权限变更等) |
| 组件状态快照 | — | ✅(关键表单数据、选中项等) |
手动添加业务面包屑的方式很简单:
// 在关键业务节点添加面包屑
function handleOrderSubmit(orderId: string) {
Sentry.addBreadcrumb({
category: 'order',
message: `提交订单: ${orderId}`,
level: 'info',
data: { orderId, itemCount: cart.items.length },
})
// ...提交逻辑
}当这个用户后续触发了异常,在 Sentry 的面包屑时间线里就能看到「用户先添加了 3 件商品 → 提交了订单 → 接口 500 → 页面崩溃」的完整路径。
重试与恢复策略
错误边界捕获了异常、展示了降级 UI 之后,用户最自然的反应是「再试一次」。但直接让用户刷新页面是最差的恢复体验。更好的做法是在错误边界内部提供恢复机制。
| 恢复方式 | 适用场景 | 实现成本 | 用户体验 |
|---|---|---|---|
| 组件内重试 | 接口临时故障、网络抖动 | 低 | 最佳,无感恢复 |
| 重置错误状态 | 数据格式异常、依赖外部状态 | 低 | 好,点击按钮即可 |
| 路由跳转 | 当前路由数据彻底不可用 | 低 | 一般,需要用户重新导航 |
| 页面刷新 | 应用状态完全损坏 | 最低 | 最差,丢失所有上下文 |
// ✅ 带重试的错误边界
function ErrorBoundaryWithRetry({
children,
fallback,
maxRetries = 2,
...rest
}: ErrorBoundaryProps) {
const [retryCount, setRetryCount] = useState(0)
return (
<ErrorBoundary
{...rest}
fallbackRender={({ error, resetErrorBoundary }) => {
// 在自动重试次数内静默重试
if (retryCount < maxRetries) {
setTimeout(() => {
setRetryCount((c) => c + 1)
resetErrorBoundary()
}, 1000 * (retryCount + 1))
// 指数退避:1s, 2s, 3s
return <LoadingHint retryCount={retryCount + 1} />
}
// 超过重试次数,展示可操作的降级 UI
return fallback ?? (
<FallbackCard
error={error}
onRetry={() => {
setRetryCount(0)
resetErrorBoundary()
}}
/>
)
}}
>
{children}
</ErrorBoundary>
)
}重试策略有两个容易踩的坑。第一是无限重试:如果后端服务确实宕了,前端疯狂重试只会加重服务端压力,必须设置最大重试次数和退避间隔。第二是重试时不清理状态:如果错误是由脏数据导致的,重置错误边界后同样的数据还会触发同样的错误,需要在重试前重新拉取数据。
错误处理的反面模式
做了这些建设之后,我复盘了之前项目里常见的几个反面模式,写出来当检查项用。
// ❌ 模式一:吞掉错误
try {
const data = JSON.parse(response)
return <Chart data={data} />
} catch {
return <div>--</div>
// 错误被静默吞掉了,页面上显示一个短横线
// 开发不知道这里出过错,数据团队也不知道数据有异常
}// ❌ 模式二:catch 了但不上报
async function fetchData() {
try {
const res = await fetch('/api/orders')
return await res.json()
} catch (error) {
console.error(error)
// console.error 在生产环境没人看
// 错误在这里消失了
return null
}
}// ❌ 模式三:错误边界里没有状态重置
class MyErrorBoundary extends React.Component {
static getDerivedStateFromError() {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return <p>出错了</p>
// 即使路由变了,hasError 仍然是 true
// 用户导航到其他页面仍然看到降级 UI
// 必须同时监听路由变化来重置状态
}
return this.props.children
}
}// ❌ 模式四:在事件处理函数里依赖 ErrorBoundary
function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
const handleClick = async () => {
await onSubmit()
// 如果 onSubmit 内部抛异常
// ErrorBoundary 不会捕获它——事件处理函数不在渲染阶段
}
return <button onClick={handleClick}>提交</button>
}
// ✅ 事件处理函数需要自己 try-catch
function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
const handleClick = async () => {
try {
await onSubmit()
} catch (error) {
// 这里需要手动上报
Sentry.captureException(error)
showToast('提交失败')
}
}
return <button onClick={handleClick}>提交</button>
}这四个反面模式对应四种不同的遗漏:吞错误、不上报、不恢复、捕获范围误解。在代码审查时我通常会逐一检查。
落地检查清单
以下清单按实施阶段分组,可以在项目里直接使用。
阶段一:基础建设
- 应用最外层部署全局 ErrorBoundary,包含降级 UI 和上报逻辑
- Sentry(或其他监控平台)完成初始化,开启
reactRouterV6Integration自动记录路由面包屑 - 配置 SourceMap 上传,确保生产环境堆栈可还原
- 设置
beforeSend钩子,过滤开发环境错误和已知的第三方脚本报错 - 错误上报包含
buildVersion、env、userId等基础上下文标签
阶段二:分级部署
- 按功能模块(非按页面)部署分区 ErrorBoundary,每个带
module标签 - 高风险第三方组件外包独立 ErrorBoundary
- 全局边界和分区边界的
componentDidCatch使用统一的reportError函数 - 降级 UI 提供至少一种恢复路径(重试、返回、导航到首页)
阶段三:错误分类与处理
- 接口错误处理层区分网络错误(重试)、业务错误(展示空状态)和真异常(上报)
- 事件处理函数中的异步错误有独立的 try-catch 和上报
- 重试策略设置了最大次数和指数退避间隔
- 没有
catch {}静默吞掉错误的代码路径
阶段四:可观测性完善
- 关键业务节点手动添加 Sentry 面包屑(订单提交、支付确认、表单提交)
- 监控平台按
module、errorCategory、buildVersion配置告警规则 - 每周查看错误 Top 10 列表,推动修复高频问题
- 新页面或新功能上线时同步检查错误边界覆盖
小结
ErrorBoundary 和可观测性不是两个独立的话题。ErrorBoundary 没有上下文上报等于白捕获,可观测性没有 ErrorBoundary 兜底等于只看数据不让用户用。我的经验是把它们当作一个整体来设计:错误边界负责「不让用户看到崩溃」,可观测性负责「让工程师看到真相」。
白屏本身不可怕——任何复杂系统都会出错。可怕的是白屏之后,团队花三天时间还没定位到原因。把证据链做完整,白屏就变成了一个可以快速修复的工程问题。
参考资料
- React 官方文档 - Error Boundaries
- Sentry - React Error Boundary 组件文档
- Sentry Blog - Guide to Error and Exception Handling in React
- Smashing Magazine - React Error Handling And Reporting With Error Boundary And Sentry
- OpenTelemetry Blog - Dude, where's my error? How OpenTelemetry records errors
- 腾讯云 - 剖析前端异常及其降级处理和防范方案
- B站技术团队 - B站前端错误监控实践
- OpenTelemetry - Web Frontend Instrumentation and Monitoring