2666 字
约 8 分钟
15
S14-A18 · 错误边界:一个组件崩了,页面为什么全白

一次商品详情页上线后,偶发"整页白屏"。查下来是一个"猜你喜欢"小组件里访问了 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. 自测

  1. 三层错误来源里,哪两层不会被 ErrorBoundary 捕获?"加了边界就安全"为什么是错的?
  2. 四种边界粒度里,哪一种最不实用?为什么"至少让导航在边界外"是底线?
  3. 兜底 UI 的三个要求是什么?为什么"跳到首页"是坏设计?
  4. 为什么"请求层统一处理错误"比每个调用点写 try/catch 更可靠?
  5. 上报字段里,哪两项缺失会导致"无法定位是哪个页面、哪次发布"?
  6. 判断"是否需要精细划分边界"的那个问题是什么?
S14-A18 · 错误边界:一个组件崩了,页面为什么全白
https://www.clxhxhhr.top/posts/6533/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。