一次商品详情页上线后,偶发"整页白屏"。查下来是一个"猜你喜欢"小组件里访问了 undefined 的字段,而它在渲染树里位置很靠上——React 在渲染期抛异常时会卸载整个子树,于是"一个推荐位挂了,整页空白"。
前端错误处理的核心问题不是"怎么捕获",而是**"在哪一层兜住"**。而 React 的默认行为让这件事更尖锐:渲染期未捕获的异常会卸载整棵树(从 v16 起),所以"没有边界"等价于"任何一处错都会白屏"。而这个行为在开发环境表现为"红屏 + 提示",在生产上是"白屏",两者差距极大。
兜得太靠上,一处错误毁掉整页;兜得太靠下,错误会向上逃逸。
1. 三层错误来源
| 层 | 例子 | 能否被边界捕获 |
|---|---|---|
| 渲染期 | 组件里访问 undefined | 能(ErrorBoundary) |
| 事件处理 | onClick 里抛错 | 不能 |
| 异步回调 | fetch 后 then 里抛错 | 不能 |
第二项与第三项不会被错误边界捕获——这是最常被误解的一点。边界只处理"渲染、生命周期、构造函数"中的错误。
所以"加了 ErrorBoundary 就安全了"是错的:多数真实错误发生在异步回调里第二项与第三项的边界有个例外:在事件处理里同步抛出的错误会被 window.onerror 捕获,但它不会触发任何 UI 变化——于是"按钮点了没反应"比白屏更难排查。
,而它们需要 window.onerror / unhandledrejection 或局部 try/catch。
// 边界只捕获渲染期错误
class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) { return { error }; }
componentDidCatch(error, info) { report(error, info.componentStack); }
render() {
if (this.state.error) return <Fallback onRetry={() => this.setState({ error: null })} />;
return this.props.children;
}
}
// 事件与异步里的错误:需要另外的机制
2. 边界的粒度
| 粒度 | 效果 |
|---|---|
| 整页一个 | 一处错全白 |
| 每个路由一个 | 单页崩,导航仍可用 |
| 每个功能区一个 | 局部失效,其余正常 |
| 每个组件一个 | 过细,兜底 UI 反而更乱 |
第一项(整页一个边界)还有个"雪崩"风险:边界重试时重新渲染整棵树,如果错误原因未消除,会立刻再崩一次,于是用户看到"反复闪现的加载失败"。
第三项是实践中的正解:按"功能区"(推荐位、评论、购物车)划分边界,让失败的模块显示"加载失败,点击重试",而页面其它部分正常。
第二项是底线:至少保证"导航与页头"不在边界内,这样用户还能离开坏页面。
// 按功能区划分:一个崩了不影响其它
<Page>
<Header /> {/* 不在边界内,始终可用 */}
<ErrorBoundary fallback={<BlockFailed name="商品详情" />}>
<ProductDetail id={id} />
</ErrorBoundary>
<ErrorBoundary fallback={<BlockFailed name="推荐" />}>
<Recommendations id={id} />
</ErrorBoundary>
</Page>
3. 兜底 UI 的三个要求
| 要求 | 说明 |
|---|---|
| 说明发生了什么 | "推荐加载失败"而非"出错了" |
| 提供重试 | 一次点击能恢复 |
| 不丢失上下文 | 用户仍在原页面、原路由 |
第三项最容易被忽略:兜底 UI 若把用户跳到首页,等于丢失了他正在做的事。第三项在"表单页"上尤其重要:用户填了半屏内容,一个侧栏组件崩了,若跳首页则内容全丢。所以"原地替换"不只是体验问题,它保住了用户已经投入的工作。
正确做法是"原地替换该区块"。
第一项的要求是"具体":"出错了"无法让用户判断"要不要重试",而"网络不稳,推荐未加载"可以。
4. 事件与异步的错误
| 机制 | 覆盖 |
|---|---|
window.onerror |
同步脚本错误 |
unhandledrejection |
未处理的 Promise |
| 局部 try/catch | 可控的异步逻辑 |
| 请求库的拦截器 | HTTP 层错误 |
第二项的实现要注意"重复上报":同一个 Promise 拒绝可能被多个监听器处理,于是同一条错误上报多次。所以监听里应当带"去重标记"或按指纹采样。
第二项在现代前端里最重要:大量逻辑在 async/await 里,而一个漏掉的 catch 就变成"静默失败"。
第四项是"最少遗漏"的位置:在请求层统一处理错误(超时、非 2xx、业务错误码)第四项的收益在"一致性":统一处理能保证"超时"与"业务错误"在 UI 上有一致的表现,而不是每个调用点各自弹不同的提示。
,比在每个调用点写 try/catch 可靠。
// 全局兜底:未处理的 Promise 拒绝
window.addEventListener("unhandledrejection", (e) => {
report({ type: "unhandledrejection", reason: String(e.reason), stack: e.reason?.stack });
showToast("操作未完成,请重试"); // 至少让用户知道
e.preventDefault(); // 避免控制台报错刷屏
});
还有一个"边界与状态"的坑:边界捕获后,子组件的状态被卸载。所以"重试"不是"把 error 置空"那么简单——它会让子树重新挂载,用户在该区块里的输入会丢。设计时要判断"这块的状态是否值得保留"。
// 重试用 key 强制重挂(子组件状态会重置)
const [retryKey, setRetryKey] = useState(0);
<ErrorBoundary key={retryKey} fallback={<BlockFailed onRetry={() => setRetryKey(k => k + 1)} />}>
<Recommendations id={id} />
</ErrorBoundary>
// 若区块内有用户输入(如搜索框),重挂会丢输入 → 此时应"只重试数据请求",不重挂组件
5. 上报与分析
| 要素 | 说明 |
|---|---|
| 错误分组 | 按 message + 栈指纹 |
| 上下文 | 路由、版本、用户环境 |
| 采样 | 高频错误降采样 |
| 关联 | 与后端 traceId 对齐 |
第三项(采样)要按"错误类型"分层:低频错误全留、高频错误降采样。因为低频错误往往最难复现,而它们的量本来就小。
第一项决定"能不能聚合":没有指纹时,同一个错误会因为参数不同而被当成一千个。
第三项在"循环触发的错误"上必要:一个渲染错误可能每帧上报一次,几分钟就是几十万条。
// 一次上报的最小字段(六项)
{type, message, stack_fingerprint, route, app_version, session_id}
// 缺 route → 不知道哪个页面坏
// 缺 version → 不知道是不是这次发布引入的
服务端渲染(SSR)场景还多一层:渲染在服务端抛错时,错误边界不生效(那里没有 React 的渲染循环),所以需要服务端自己的兜底——返回一个"降级 HTML"而不是 500。
// SSR 的三处兜底(缺一处就会白屏)
1 服务端渲染异常 → 返回降级 HTML(含"重试"与静态导航)
2 客户端 hydrate 异常 → 边界接管
3 数据请求异常 → 区块级 fallback(不阻塞首屏)
// 关键:第 1 处最容易被忘,因为本地开发用 CSR 走不到
6. 排错:五种"错误处理没起作用"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 整页白屏 | 边界太靠上/没有边界 | 按功能区划 |
| 点了没反应 | 事件里抛错被吞 | 全局监听 + 局部 try |
| 上报量暴涨 | 循环错误未采样 | 加去重与采样 |
| 无法定位 | 无路由/版本 | 补上下文 |
| 兜底后无法恢复 | 无重试或跳首页 | 原地重试 |
第二项最常见:**事件处理里的错误不会白屏,而是"点了没反应"**第五项(兜底后无法恢复)有个"最小修复":兜底 UI 里加一个"重试"按钮,并且重试只重挂该区块(用 key 变化强制重建),而不是刷新整页。
——用户以为按钮坏了,而实际是代码抛了异常。
最后一个"验收"方式:人为注入错误,看页面表现。比如给某个区块传一个 null 的必填字段,观察是否只有该区块显示失败态、导航是否仍可用、上报是否到达。这类演练一次五分钟,能暴露掉大部分设计缺陷。
// 三个注入点(每季度做一次)
1 数据为空/为 undefined → 区块是否显示 fallback
2 异步请求抛错 → 是否出现"点了没反应"
3 上报接口不可用 → 是否阻塞渲染(不应阻塞)
7. 边界:什么情况不必精细划分
| 场景 | 为什么 | 做法 |
|---|---|---|
| 简单页面(少量组件) | 划太细反而乱 | 一个路由级边界 |
| 内部工具 | 用户能反馈 | 简单兜底 |
| 静态页(无数据依赖) | 出错概率低 | 不做 |
| 已有全局兜底 + 监控 | 能快速发现 | 只做路由级 |
最后一条与"上报"有关的边界:边界本身也要能上报,否则"白屏"在监控里是"0 错误"——因为没有 JS 异常被捕获(它们只是被 React 吞掉了)。所以 componentDidCatch 必须调上报,而不是只改状态。
判断口径一句话:这个区块崩了,用户还能不能完成他来这里要做的事。能(推荐位、评论),就该单独兜住;不能(主流程),兜住整个流程更有意义。
8. 自测
- 三层错误来源里,哪两层不会被 ErrorBoundary 捕获?"加了边界就安全"为什么是错的?
- 四种边界粒度里,哪一种最不实用?为什么"至少让导航在边界外"是底线?
- 兜底 UI 的三个要求是什么?为什么"跳到首页"是坏设计?
- 为什么"请求层统一处理错误"比每个调用点写 try/catch 更可靠?
- 上报字段里,哪两项缺失会导致"无法定位是哪个页面、哪次发布"?
- 判断"是否需要精细划分边界"的那个问题是什么?