白屏不是一种错误,而是分类失败
用户看到白屏,通常不是因为某一个组件抛了异常,而是因为异常没有被任何一层接住。渲染树在最外层直接中断,浏览器或原生容器只剩一个空容器。此时日志里可能只有一句 TypeError: Cannot read properties of undefined,既看不出影响范围,也看不出用户还能做什么。
更隐蔽的情况是“兜底本身也会失败”。某个页面用一段条件渲染作为兜底,条件依赖同一个坏数据,于是兜底组件再次抛错,边界被击穿。表面上是白屏,实质上是错误分类、边界放置、恢复预算三件事同时缺位。
这一节先把错误分成三类,再把“谁接住、还能重试几次、什么时候升级”写成不依赖 DOM 的模型。框架可以是 React、Vue、小程序或原生,判断逻辑应该先独立于框架存在。
三类错误与四个判断维度
端上错误先按“用户还能不能继续完成任务”分类,而不是按异常类型分类:
| 类别 | 含义 | 默认动作 | 重试预算 |
|---|---|---|---|
| 可恢复 | 网络抖动、限流、临时 5xx | 原地重试或重新认证 | 有上限 |
| 可降级 | 资源不存在、契约不符、局部渲染失败 | 展示缓存、空态或关闭功能 | 通常为 0 |
| 致命 | 重复渲染失败、认证状态不可判定 | 升级到更大边界或应用级兜底 | 0 |
判断时看四个维度:来源(网络、渲染、契约、认证、未知)、状态码、同一位置的连续失败次数、适配器是否明确标记可重试。四个维度里,retryable: false 的优先级高于状态码,因为它来自更了解现场的那一层。
错误分类器
下面的分类器只接收一个普通对象,返回类别、动作、预算和原因。它不抛异常、不读全局状态,也不依赖任何 UI 框架。
// file: src/error/error-boundary.ts
// tier: L1
// verify: node --test test/error/error-boundary.test.ts
export type ErrorSource = "network" | "render" | "contract" | "auth" | "unknown";
export type ErrorClass = "recoverable" | "degradable" | "fatal";
export type RecoveryAction = "retry" | "reauth" | "fallback" | "disable-feature" | "reload" | "contact-support";
export type FallbackScope = "inline" | "section" | "page" | "app";
export interface ErrorSignal {
source: ErrorSource;
/** HTTP 状态码,只在 network/auth 来源下有意义 */
status?: number;
/** 同一位置连续失败的次数,由调用方统计 */
repeats?: number;
/** 适配器给出的可重试提示;缺省时按状态码推断 */
retryable?: boolean;
message?: string;
}
export interface ErrorDecision {
class: ErrorClass;
action: RecoveryAction;
retryBudget: number;
reason: string;
}
const FATAL_REPEATS = 2;
function decision(
cls: ErrorClass,
action: RecoveryAction,
retryBudget: number,
reason: string,
): ErrorDecision {
return { class: cls, action, retryBudget, reason };
}
/**
* 把一次失败信号翻译成三类处理策略。
* 分类只回答「这个错误应该被谁接住」,不负责渲染,也不读取 DOM。
*/
export function classifyError(signal: ErrorSignal): ErrorDecision {
const repeats = signal.repeats ?? 0;
const status = signal.status ?? 0;
if (signal.source === "auth") {
if (status === 401) return decision("recoverable", "reauth", 0, "凭证过期,需要用户重新登录后重放");
if (status === 403) return decision("degradable", "disable-feature", 0, "当前角色无权限,隐藏入口比反复重试更诚实");
return decision("fatal", "contact-support", 0, "认证状态无法判定,继续重试只会放大故障");
}
if (signal.source === "network") {
if (status === 401) return decision("recoverable", "reauth", 0, "网关要求重新认证");
if (status === 403) return decision("degradable", "disable-feature", 0, "请求被拒绝,重试不会改变权限");
if (status === 404 || status === 410) return decision("degradable", "fallback", 0, "资源已不存在,应展示空态或上一份缓存");
if (status === 400 || status === 422) return decision("degradable", "disable-feature", 0, "客户端与服务端契约不一致,原样重试没有意义");
if (status === 429) return decision("recoverable", "retry", 1, "被限流,只允许一次带延迟的重试");
if (signal.retryable === false) return decision("degradable", "fallback", 0, "适配器明确标记不可重试,直接进入兜底");
if (status === 0 || status === 408 || status >= 500) {
return decision("recoverable", "retry", 2, "超时或服务端暂时不可用,可在预算内重试");
}
}
if (signal.source === "render") {
if (repeats >= FATAL_REPEATS) return decision("fatal", "reload", 0, `同一渲染位置连续失败 ${repeats} 次,局部兜底已不可信`);
return decision("degradable", "fallback", 0, "首次渲染失败,先替换该子树并保留页面其余部分");
}
if (signal.source === "contract") {
return decision("degradable", "disable-feature", 0, "数据结构不符合预期,关闭依赖该字段的功能");
}
if (repeats >= FATAL_REPEATS) {
return decision("fatal", "contact-support", 0, "未知错误重复出现,需要人工介入");
}
return decision("degradable", "fallback", 0, "未知错误首次出现,先用局部兜底隔离影响面");
}
export interface BoundarySpec {
id: string;
/** 父边界;critical 边界不允许局部降级,必须向上一层升级 */
parentId?: string;
/** 该边界失败时替换的最小区域 */
scope: FallbackScope;
critical?: boolean;
/** 恢复尝试次数上限,默认 2 */
retryBudget?: number;
}
export type BoundaryStatus = "active" | "tripped" | "recovering";
export interface BoundarySnapshot {
id: string;
status: BoundaryStatus;
failures: number;
recoveryAttempts: number;
lastMessage: string;
lastAtMs: number;
}
export interface Incident {
boundaryId: string;
decision: ErrorDecision;
fallback: FallbackScope;
/** 升级后的边界 id;应用级兜底用 "app" 表示 */
escalatedTo: string | null;
snapshot: BoundarySnapshot;
}
export interface RecoveryResult {
status: "recovered" | "tripped" | "escalated";
boundaryId: string;
nextBoundaryId: string | null;
retriesLeft: number;
}
/**
* 边界模型只维护「谁接住了错误、还剩几次恢复机会」。
* 真实框架可以把它映射成组件树,测试里则完全不需要 DOM。
*/
export class BoundaryModel {
private readonly now: () => number;
private readonly specs: Map<string, BoundarySpec>;
private readonly runtimes: Map<string, BoundarySnapshot>;
constructor(now: () => number) {
this.now = now;
this.specs = new Map();
this.runtimes = new Map();
}
register(spec: BoundarySpec): void {
if (this.specs.has(spec.id)) throw new Error(`边界 ${spec.id} 重复注册`);
if (spec.parentId !== undefined && !this.specs.has(spec.parentId)) {
throw new Error(`父边界 ${spec.parentId} 尚未注册`);
}
this.specs.set(spec.id, spec);
this.runtimes.set(spec.id, {
id: spec.id, failures: 0, recoveryAttempts: 0,
status: "active", lastMessage: "", lastAtMs: this.now(),
});
}
snapshot(id: string): BoundarySnapshot | undefined {
const runtime = this.runtimes.get(id);
return runtime === undefined ? undefined : { ...runtime };
}
/**
* 可恢复错误留在原边界;可降级错误替换局部;
* 致命错误或关键边界上的降级错误向父边界升级。
*/
capture(boundaryId: string, signal: ErrorSignal): Incident {
const spec = this.specs.get(boundaryId);
const runtime = this.runtimes.get(boundaryId);
if (spec === undefined || runtime === undefined) throw new Error(`边界 ${boundaryId} 未注册`);
runtime.failures += 1;
runtime.lastMessage = signal.message ?? "";
runtime.lastAtMs = this.now();
const decision = classifyError({
...signal,
repeats: Math.max(signal.repeats ?? 0, runtime.failures),
});
const mustEscalate =
decision.class === "fatal" ||
(spec.critical === true && decision.class !== "recoverable");
runtime.status = "tripped";
if (!mustEscalate) {
return {
boundaryId,
decision,
fallback: spec.scope,
escalatedTo: null,
snapshot: this.snapshot(boundaryId)!,
};
}
const parent = spec.parentId ?? null;
if (parent !== null) {
const parentRuntime = this.runtimes.get(parent);
if (parentRuntime !== undefined) {
parentRuntime.status = "tripped";
parentRuntime.failures += 1;
parentRuntime.lastMessage = runtime.lastMessage;
parentRuntime.lastAtMs = runtime.lastAtMs;
}
}
return {
boundaryId,
decision,
fallback: parent === null ? "app" : this.specs.get(parent)!.scope,
escalatedTo: parent ?? "app",
snapshot: this.snapshot(boundaryId)!,
};
}
/**
* 用户或上层触发一次恢复。成功则复位;失败则消耗一次恢复预算,
* 预算耗尽后升级到父边界。时钟通过构造函数注入,不依赖真实定时器。
*/
recover(boundaryId: string, success: boolean): RecoveryResult {
const spec = this.specs.get(boundaryId);
const runtime = this.runtimes.get(boundaryId);
if (spec === undefined || runtime === undefined) throw new Error(`边界 ${boundaryId} 未注册`);
const budget = spec.retryBudget ?? 2;
if (success) {
runtime.status = "active";
runtime.failures = 0;
runtime.recoveryAttempts = 0;
return { status: "recovered", boundaryId, nextBoundaryId: null, retriesLeft: budget };
}
runtime.recoveryAttempts += 1;
const retriesLeft = budget - runtime.recoveryAttempts;
if (retriesLeft > 0) {
runtime.status = "tripped";
return { status: "tripped", boundaryId, nextBoundaryId: null, retriesLeft };
}
const parent = spec.parentId ?? "app";
runtime.status = "tripped";
if (parent !== "app") {
const parentRuntime = this.runtimes.get(parent);
if (parentRuntime !== undefined) parentRuntime.status = "tripped";
}
return { status: "escalated", boundaryId, nextBoundaryId: parent, retriesLeft: 0 };
}
}
几个判断值得说明。
第一,401 归为可恢复,但动作是 reauth 而不是 retry。没有新凭证时重试只会重复失败,预算设为 0 是在阻止无意义的重放。403 则归为可降级,因为权限不会因为多试几次而改变,隐藏入口比反复弹错更诚实。
第二,400/422 被归为契约问题。客户端把服务端明确拒绝的请求原样重发,既浪费预算,也污染错误率指标。正确动作是关闭依赖该字段的功能,并把版本号带进日志。
第三,渲染错误用连续失败次数决定升级。第一次失败先替换子树,第二次仍然失败说明局部兜底不可信,必须换成 reload 或更大的边界。FATAL_REPEATS = 2 是案例假设值,团队可以按页面重要程度调整,但必须写进决策记录。
边界不是 try/catch 的别名
try/catch 只能接住同步调用栈里的异常,接不住异步渲染、子组件生命周期和流式返回。边界要回答的是三个结构问题:这个错误应该由哪一层接住、接住后替换多大区域、替换失败后向哪里升级。
模型里的 scope 表示替换范围:inline 只替换一行或一个卡片,section 替换一个区块,page 替换整页,app 是最后的应用级兜底。critical: true 表示这一层不允许局部降级,例如结算面板缺了金额字段就不能只显示“加载失败”,必须把整页交给上层处理。
app
└── page: 商品详情
├── section: 评价列表 scope=section
└── section: 结算面板 scope=section, critical=true
└── inline: 优惠券行 scope=inline
这张树里,优惠券行失败只影响一行;结算面板失败会升级到页面;页面也接不住时才落到应用级兜底。边界注册时要求父边界先注册,避免运行时才发现升级目标不存在。
恢复流程
恢复不是“再渲染一次”。一次恢复要回答:谁触发、成功怎么复位、失败怎么消耗预算、预算耗尽后去哪里。
模型把恢复尝试次数和错误发生次数分开计数。capture 记录失败,recover 记录用户或上层发起的恢复。恢复成功时失败计数和恢复计数一起清零;恢复失败时只增加恢复计数,剩余预算递减;预算耗尽后返回 escalated,由父边界接手。时钟通过构造函数注入,测试不需要真实定时器,也不会因为 CI 机器慢而抖动。
这里有一个容易忽略的细节:升级不是把错误“重新抛一遍”。父边界收到的是一个已经分类过的 Incident,它知道子边界为什么失败、失败了几次,可以决定展示页面级兜底还是直接刷新。把分类结果随错误一起传递,能避免每一层重复推断。
测试与真实输出
测试覆盖九种情况:超时与 5xx、404、契约与权限、渲染重复失败、局部捕获、关键边界升级、无父边界落到应用级、恢复成功复位、恢复预算耗尽。在实验工程根目录执行单文件命令:
node --test test/error/error-boundary.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 超时与服务端 5xx 归为可恢复错误 (0.540333ms)
✔ 404 归为可降级错误,直接进入兜底 (0.067584ms)
✔ 契约不一致与无权限都关闭依赖功能,而不是重试 (0.554458ms)
✔ 渲染错误连续两次升级为致命错误 (0.1305ms)
✔ 边界捕获可恢复错误时保留局部兜底 (0.202833ms)
✔ 关键边界不允许局部降级,必须升级到父边界 (0.086709ms)
✔ 没有父边界的致命错误落到应用级兜底 (0.079333ms)
✔ 恢复成功后边界复位并清空失败计数 (0.108458ms)
✔ 恢复预算耗尽后升级,剩余次数逐次递减 (0.104042ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 138.942125
不要用 node --test test/error/ 这种目录形式。Node 26 在这个工程里会按模块入口解析目录,报 MODULE_NOT_FOUND;单文件命令才和文章里的输出一致。
一次局部兜底扩大故障的白屏
某版本的商品详情页把错误边界放在每个评价卡片内部,页面级没有边界。一次接口改字段后,评价数据整体变成 undefined,所有卡片同时抛错。每个卡片都试图渲染自己的“加载失败”占位,占位又读取同一个字段,于是异常在同一帧内反复出现,最终整页白屏。
复盘有三个结论。第一,边界位置错了:数据整体缺失应该在列表层接住,而不是让每个卡片独自处理。第二,兜底组件复用了坏数据:兜底必须只依赖常量与安全字段,不能再次读取失败来源。第三,没有升级路径:局部边界失败后没有父边界可去,只能白屏。
修正方式不是加更多 try/catch,而是把列表边界设为 section,把页面设为父边界,并规定兜底组件不得读取业务数据。改完后同样的坏数据只会让评价区显示“暂时无法加载”,商品标题、价格和购买按钮仍然可用。
与降级策略的分工
错误边界管“异常发生后谁来接”,弱网兜底管“请求没拿到完整结果时用什么数据”。前者关注控制流,后者关注数据可信度。两者都做,才能覆盖“抛异常”和“没抛异常但结果不完整”两类故障。
| 问题 | 错误边界 | 弱网兜底 |
|---|---|---|
| 触发条件 | 抛出异常、渲染失败 | 超时、失败、半成功 |
| 输出 | 兜底 UI 与升级路径 | 缓存、降级内容、重试 |
| 关键输入 | 来源、状态码、重复次数 | 缓存年龄、缺失字段、预算 |
| 失败后 | 向父边界升级 | 交还用户手动重试 |
下一节会把镜头移到请求层,看不抛异常的失败如何被识别和兜底。
小结
白屏治理的起点不是写一个更漂亮的错误页,而是先承认错误需要分类。可恢复、可降级、致命三类对应不同的动作与预算;边界模型把“谁接住、替换多大区域、失败后去哪”变成可测试的结构。
分类器与边界模型都不依赖 DOM,这让它们可以在单元测试里覆盖升级、恢复和预算耗尽。框架会换,边界树的形状和升级规则不应该跟着换。
阅读导航:上一节:10.3 渲染模式的选择与退出 · 下一节:11.2 弱网与接口失败兜底 。
常见错误与边界
- 把白屏当成样式问题。白屏首先查渲染树有没有被异常中断,再查兜底组件是否再次抛错。
- 只用
try/catch包住入口。异步渲染、子组件生命周期和流式返回都不在同一个调用栈里。 - 兜底组件读取失败来源的数据。兜底必须只依赖常量与安全字段,否则会把局部失败放大成整页失败。
- 所有边界都设成
critical。关键边界是例外,不是默认值;滥用会让一个小错误升级成整页不可用。 - 恢复预算和错误次数混在一起。两者含义不同,混用会让“重试了两次”和“失败了两次”无法区分。
- 把
retryable: false当成普通失败继续重试。适配器的明确标记优先于状态码推断。 - 升级时重新抛原始异常。父边界需要的是已分类的
Incident,不是又一份未处理的堆栈。 - 认为分类一次就永远正确。接口契约、权限模型和页面结构都会变,分类规则要随版本复核。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。