《互联网产品的客户端架构·端形态与体验基础》11.1 错误边界与白屏治理

白屏往往不是某个组件写错,而是错误没有被分类、边界没有被放置。本节把端上错误分成可恢复、可降级、致命三类,给出不依赖 DOM 的分类器与嵌套边界模型,用注入时钟验证恢复预算与升级路径,并复盘一次局部兜底反而扩大故障的白屏事故,最后整理成可执行的评审清单。

白屏不是一种错误,而是分类失败

用户看到白屏,通常不是因为某一个组件抛了异常,而是因为异常没有被任何一层接住。渲染树在最外层直接中断,浏览器或原生容器只剩一个空容器。此时日志里可能只有一句 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,不是又一份未处理的堆栈。
  • 认为分类一次就永远正确。接口契约、权限模型和页面结构都会变,分类规则要随版本复核。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查