React 基础体系 · 第 19/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 错误边界与可观测性:异常、日志、Web Vitals 和发布诊断

前端故障通常不是“页面报错”这么简单。一个用户看到白屏,可能由渲染异常、接口失败、静态资源版本不一致、Content Security Policy(CSP)阻止脚本、服务端渲染失败,或某次发布只影响部分用户造成。错误边界只能处理其中一部分问题;日志、指标、链路标识和发布信息必须共同工作,才能回答以下问题:

  1. 哪一类异常发生了?
  2. 它影响了哪些用户、路由、浏览器和发布版本?
  3. React 是否成功恢复了界面?
  4. 性能是否已经恶化到影响用户体验?
  5. 这是代码回归、后端故障、资源部署问题,还是环境差异?
  6. 修复或回滚之后,故障是否真正消失?

本文以 React 19、现代 TypeScript 和浏览器客户端为基础,分别说明客户端 React 树、服务端渲染和框架运行时的边界。


一、先建立可观测性的对象模型

**可观测性(observability)**是从系统外部输出的信号推断系统内部状态的能力。前端通常使用三类信号:

  • 日志(log):某个事件发生了什么,例如一个错误对象、组件栈、请求状态。
  • 指标(metric):事件的聚合统计,例如每分钟错误数、错误用户率、LCP 的 p75。
  • 链路(trace):一次用户操作跨越浏览器、前端代码、API 网关和后端服务时,各阶段如何关联。

一个错误事件可以抽象为:

E=(t,u,s,r,v,k,c)E = (t, u, s, r, v, k, c)

其中:

  • tt:发生时间;
  • uu:用户或匿名会话标识;
  • ss:页面和功能区域;
  • rr:发布版本或构建标识;
  • vv:浏览器、设备、网络等环境;
  • kk:错误类型、名称和指纹;
  • cc:上下文,例如组件栈、请求、路由、业务参数。

只记录 error.message,通常只能得到 kk 的一小部分,无法判断它是否集中在某个版本、路由或浏览器。因此错误日志的关键不是“把所有变量都打印出来”,而是建立足够的关联维度,同时避免把密码、令牌、身份证号等敏感数据发送到日志系统。

1. 错误、异常和失败不是同一个概念

在前端工程中应区分:

  • 异常(exception):程序执行过程中出现了未按正常返回值表达的错误,例如 throw new Error(...)
  • 失败(failure):业务或基础设施没有完成预期任务,例如 HTTP 503、表单校验失败、用户主动取消请求。
  • 崩溃(crash):错误导致当前页面、React 子树或某个功能无法继续运行。

一个接口返回 404 可能是可预期的业务状态,不应自动当作未捕获异常;一个组件渲染时访问 undefined.name 则通常是程序异常。可观测性系统必须保留这种分类,否则告警会被大量正常业务失败淹没。

2. 错误率需要明确分母

设某个发布版本中发生错误的会话数为 UeU_e,活跃会话数为 UU,则用户错误率可以写成:

Ru=UeUR_u = \frac{U_e}{U}

若统计的是错误事件,则分母可能是页面加载次数、功能操作次数或请求次数。两者含义不同:

  • 一个用户连续触发十次同一异常,事件数会增加十次,但用户错误率只增加一次;
  • 一个错误发生在 1% 的页面加载中,和发生在 1% 的活跃用户中,影响面也不完全相同。

因此告警应明确指标定义,例如“生产版本中每 5 分钟受影响会话率超过 1%”,而不是只说“错误数量超过 100”。


二、React 错误边界到底捕获什么

**错误边界(Error Boundary)**是一个 React 组件,用于捕获其子树在渲染、生命周期方法或构造函数中抛出的错误,并渲染备用 UI,而不是让整个 React 树直接失效。

React 官方支持的错误边界形式仍然是类组件。一个组件只要实现以下生命周期之一,就可能成为错误边界:

  • static getDerivedStateFromError(error):根据错误更新状态;
  • componentDidCatch(error, errorInfo):执行日志记录等副作用。

最小形式如下:

import { Component, type ErrorInfo, type ReactNode } from "react";

type BoundaryProps = {
  children: ReactNode;
  fallback?: ReactNode;
};

type BoundaryState = {
  hasError: boolean;
};

export class ErrorBoundary extends Component<
  BoundaryProps,
  BoundaryState
> {
  state: BoundaryState = { hasError: false };

  static getDerivedStateFromError(): BoundaryState {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.error("React subtree crashed", {
      error,
      componentStack: errorInfo.componentStack,
    });
  }

  render(): ReactNode {
    if (this.state.hasError) {
      return this.props.fallback ?? <p>页面暂时无法显示。</p>;
    }

    return this.props.children;
  }
}

故障路径是:

  1. React 尝试渲染 children
  2. 子树中的渲染逻辑抛出错误;
  3. React 向上寻找最近的错误边界;
  4. 先调用 getDerivedStateFromError,得到备用状态;
  5. React 用新状态渲染 fallback;
  6. 提交阶段调用 componentDidCatch,用于上报日志。

这里的“最近”很重要。若一个页面内有多个边界,内部边界会优先接管错误;内部边界自身无法渲染时,错误会继续向外传播。

1. 错误边界捕获范围

错误边界主要覆盖以下场景:

function UserPanel({ user }: { user?: { name: string } }) {
  // user 为 undefined 时,渲染期间抛出 TypeError
  return <h2>{user!.name}</h2>;
}

如果 UserPanel 位于错误边界内部,边界可以捕获该渲染异常。

生命周期和类组件构造函数中的异常也属于边界处理范围:

class LegacyWidget extends Component {
  constructor(props: {}) {
    super(props);
    throw new Error("widget initialization failed");
  }

  render() {
    return null;
  }
}

2. 错误边界不捕获的场景

错误边界不是全局 try/catch。它不负责捕获以下错误:

事件处理器中的异常

function SaveButton() {
  function handleClick() {
    throw new Error("save failed");
  }

  return <button onClick={handleClick}>保存</button>;
}

这是浏览器事件回调中的异常,不属于 React 渲染阶段。应在事件处理器中显式处理:

function SaveButton() {
  const handleClick = async () => {
    try {
      await saveData();
    } catch (error) {
      reportError(error, {
        source: "save-button",
        operation: "save-data",
      });
      // 更新按钮或表单状态,而不是期待 Error Boundary 捕获
    }
  };

  return <button onClick={handleClick}>保存</button>;
}

异步回调中的异常

useEffect(() => {
  setTimeout(() => {
    throw new Error("timer failed");
  }, 1000);
}, []);

该异常发生在之后的定时器任务中,不会被包裹它的渲染边界自动捕获。异步代码应该通过 try/catch、Promise rejection 处理器或状态转换传递给界面。

服务端渲染过程中的异常

客户端错误边界不能替代服务端渲染框架的错误处理。服务端生成 HTML 时,浏览器端的错误边界尚未运行。服务端必须在渲染入口、框架请求处理器或流式渲染回调中处理错误。

错误边界自身的错误

如果 fallback 组件本身抛错,当前边界无法处理自己的错误,需要由更外层边界接管。因此生产系统通常会有一个足够简单的根边界,并确保其 fallback 依赖少、逻辑少。


三、设计错误边界的层级和恢复语义

错误边界的职责不是“把错误吞掉”,而是把故障范围限制在合理的子树内。

例如:

function App() {
  return (
    <ErrorBoundary fallback={<FullPageFallback />}>
      <Layout>
        <ErrorBoundary fallback={<SidebarFallback />}>
          <Sidebar />
        </ErrorBoundary>

        <ErrorBoundary fallback={<MainContentFallback />}>
          <MainContent />
        </ErrorBoundary>
      </Layout>
    </ErrorBoundary>
  );
}

故障范围如下:

  • Sidebar 出错:只替换侧栏;
  • MainContent 出错:只替换主内容;
  • Layout 出错:内部边界可能无法完成布局,由根边界接管;
  • 根边界也出错:通常只能依赖框架或浏览器层面的最终错误处理。

1. 边界位置是可用性和一致性的取舍

边界放得太高,局部故障会导致整页 fallback;放得太低,则可能留下多个互相不一致的局部区域。例如订单页面中的价格、库存和提交按钮如果必须保持一致,不能简单地为每个小组件设置独立 fallback,否则用户可能看到“库存失败但仍可提交”的错误状态。

边界的粒度应与故障隔离单元一致:

  • 独立侧栏、推荐区、评论区适合局部边界;
  • 同一事务中的多个字段适合共享边界;
  • 根边界负责最后兜底和诊断。

2. fallback 不应只是静态文案

一个可诊断的 fallback 至少需要:

  • 告知用户当前功能不可用;
  • 提供重新加载或重试入口;
  • 显示可向客服提供的错误编号,而不是堆栈;
  • 不泄露内部路径、令牌和源码;
  • 避免重复触发同一个失败操作。

示例:

type FallbackProps = {
  errorId: string;
  onRetry: () => void;
};

function SectionFallback({ errorId, onRetry }: FallbackProps) {
  return (
    <section role="alert">
      <h2>该区域暂时无法显示</h2>
      <p>请稍后重试。错误编号:{errorId}</p>
      <button onClick={onRetry}>重试</button>
    </section>
  );
}

3. 重试必须真正重新挂载或重新执行失败逻辑

错误边界进入错误状态后,不会因为父组件“重新渲染”就自动恢复。可以通过改变边界的 key 重新挂载:

function RetryableWidget() {
  const [attempt, setAttempt] = useState(0);

  return (
    <ErrorBoundary
      key={attempt}
      fallback={
        <SectionFallback
          errorId="widget-render"
          onRetry={() => setAttempt((value) => value + 1)}
        />
      }
    >
      <Widget />
  </ErrorBoundary>
  );
}

每次点击重试的状态变化是:

正常错误key 改变重新挂载正常或再次错误\text{正常} \rightarrow \text{错误} \rightarrow \text{key 改变} \rightarrow \text{重新挂载} \rightarrow \text{正常或再次错误}

但重试并不能修复确定性的代码错误。如果 Widget 每次挂载都会访问不存在的属性,用户只会看到 fallback 闪烁。生产实现应限制重试次数,区分可恢复的网络失败和不可恢复的代码异常。


四、把错误边界接入结构化日志

console.error 适合本地调试,不足以承担生产日志职责。生产事件应采用结构化对象,并携带构建、路由、会话和组件栈信息。

1. 定义统一错误事件

type ErrorContext = {
  source: "react-boundary" | "event-handler" | "unhandled-rejection" | "resource";
  route?: string;
  feature?: string;
  operation?: string;
  buildId?: string;
  extra?: Record<string, unknown>;
};

type ClientErrorEvent = {
  type: "client_error";
  name: string;
  message: string;
  stack?: string;
  componentStack?: string;
  source: ErrorContext["source"];
  route: string;
  buildId: string;
  sessionId: string;
  timestamp: string;
  fingerprint: string;
  extra?: Record<string, unknown>;
};

function getErrorMessage(error: unknown): string {
  if (error instanceof Error) return error.message;
  return String(error);
}

function getErrorStack(error: unknown): string | undefined {
  return error instanceof Error ? error.stack : undefined;
}

function createFingerprint(input: {
  name: string;
  message: string;
  source: string;
  buildId: string;
}): string {
  // 示例指纹。生产环境可在服务端基于规范化堆栈计算哈希。
  return [input.name, input.message, input.source, input.buildId].join("|");
}

这里的 fingerprint 用于聚合“同一个问题”。直接把完整 message 作为指纹通常不可靠,例如消息中包含用户 ID 或动态 URL,会把同一缺陷拆成大量事件。

2. 处理未知类型的 thrown value

JavaScript 允许抛出任何值:

throw "failed";
throw { code: "E_TIMEOUT" };

因此日志函数不能只接受 Error

const BUILD_ID = "web-2025-03-08-a";
const SESSION_ID = crypto.randomUUID();

function reportError(error: unknown, context: ErrorContext): void {
  const name = error instanceof Error ? error.name : "NonErrorThrown";
  const message = getErrorMessage(error);

  const event: ClientErrorEvent = {
    type: "client_error",
    name,
    message,
    stack: getErrorStack(error),
    componentStack: context.extra?.componentStack as string | undefined,
    source: context.source,
    route: location.pathname,
    buildId: context.buildId ?? BUILD_ID,
    sessionId: SESSION_ID,
    timestamp: new Date().toISOString(),
    fingerprint: createFingerprint({
      name,
      message,
      source: context.source,
      buildId: context.buildId ?? BUILD_ID,
    }),
    extra: sanitizeExtra(context.extra),
  };

  void sendTelemetry("/telemetry/errors", event);
}

function sanitizeExtra(
  extra?: Record<string, unknown>,
): Record<string, unknown> | undefined {
  if (!extra) return undefined;

  const forbidden = new Set(["authorization", "cookie", "password", "token"]);

  return Object.fromEntries(
    Object.entries(extra).filter(([key]) => !forbidden.has(key.toLowerCase())),
  );
}

该示例只做了字段级过滤。真实系统还应对字符串内容、URL 查询参数、表单字段和服务端返回体进行脱敏,不能认为“没有记录密码字段名”就足够安全。

3. 发送日志时使用 sendBeacon,但不能假设它永远成功

async function sendTelemetry(
  url: string,
  payload: unknown,
): Promise<void> {
  const body = JSON.stringify(payload);

  if (navigator.sendBeacon) {
    const accepted = navigator.sendBeacon(
      url,
      new Blob([body], { type: "application/json" }),
    );

    if (accepted) return;
  }

  try {
    await fetch(url, {
      method: "POST",
      body,
      headers: { "content-type": "application/json" },
      credentials: "omit",
      keepalive: true,
    });
  } catch {
    // 遥测失败不能再次触发全局错误循环
  }
}

sendBeacon 表示浏览器接受了发送请求,不等于服务端已经成功处理。日志接口自身应尽量简单、快速,并对重复事件、异常大 payload 和恶意构造进行限制。

4. 在边界中记录组件栈

export class ReportingBoundary extends Component<
  { children: ReactNode; fallback?: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError(): { hasError: boolean } {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: ErrorInfo): void {
    reportError(error, {
      source: "react-boundary",
      feature: "account-page",
      extra: {
        componentStack: info.componentStack,
      },
    });
  }

  render(): ReactNode {
    return this.state.hasError
      ? this.props.fallback ?? <p>加载失败</p>
      : this.props.children;
  }
}

componentStack 是 React 提供的组件调用路径信息,和 JavaScript stack 的含义不同:

  • JavaScript stack 说明异常经过了哪些函数和文件;
  • React componentStack 说明 React 组件树中经过了哪些组件。

两者结合后,才能同时回答“在哪段代码抛出”和“哪个界面区域触发”。


五、避免重复上报和开发模式误判

React 开发环境可能启用 Strict Mode,以帮助发现不安全的副作用。开发时某些初始化、渲染或 effect 行为可能出现额外调用;这不是生产环境事件数的可靠代表。错误上报系统不能把本地开发控制台输出直接当作生产统计。

生产环境也可能因为同一错误触发多次渲染、多个用户重复上报。因此需要客户端节流和服务端聚合:

const reported = new Map<string, number>();
const DEDUPE_WINDOW_MS = 60_000;

function reportOnce(
  error: unknown,
  context: ErrorContext,
): void {
  const name = error instanceof Error ? error.name : "NonErrorThrown";
  const message = getErrorMessage(error);
  const fingerprint = createFingerprint({
    name,
    message,
    source: context.source,
    buildId: context.buildId ?? BUILD_ID,
  });

  const now = Date.now();
  const previous = reported.get(fingerprint);

  if (previous !== undefined && now - previous < DEDUPE_WINDOW_MS) {
    return;
  }

  reported.set(fingerprint, now);
  reportError(error, context);
}

这段逻辑只适合减少同一页面会话内的重复事件。不能只依赖客户端去重,因为不同用户和不同标签页之间仍然需要由服务端聚合。


六、全局异常和 Promise rejection:边界之外的补充

错误边界覆盖不了事件处理器、定时器和异步 Promise。浏览器提供了全局事件,可以作为最后的诊断入口:

export function installGlobalErrorHandlers(): () => void {
  const onError = (event: ErrorEvent) => {
    reportOnce(event.error ?? event.message, {
      source: "event-handler",
      extra: {
        filename: event.filename,
        line: event.lineno,
        column: event.colno,
      },
    });
  };

  const onUnhandledRejection = (event: PromiseRejectionEvent) => {
    reportOnce(event.reason, {
      source: "unhandled-rejection",
    });
  };

  window.addEventListener("error", onError);
  window.addEventListener("unhandledrejection", onUnhandledRejection);

  return () => {
    window.removeEventListener("error", onError);
    window.removeEventListener("unhandledrejection", onUnhandledRejection);
  };
}

这类监听器的作用是记录“漏出业务处理边界的异常”,不是替代局部错误处理:

  • 事件处理器仍应在操作附近显示失败状态;
  • 请求失败仍应区分可重试和不可重试;
  • 全局处理器不应自动弹出重复提示;
  • 监听器本身不能抛出新异常,否则会形成故障循环。

资源加载错误也需要单独考虑。windowerror 事件可以报告部分脚本、样式和图片资源失败,但跨域脚本可能只有有限信息;CSP 违规则通常通过 securitypolicyviolation 事件或服务端 CSP 报告机制诊断。


七、异常传播中的 cause、网络错误和业务错误

现代 JavaScript 支持错误原因链:

class ApiError extends Error {
  constructor(
    message: string,
    public readonly status: number,
    options?: ErrorOptions,
  ) {
    super(message, options);
    this.name = "ApiError";
  }
}

async function loadProfile(): Promise<Profile> {
  try {
    const response = await fetch("/api/profile");

    if (!response.ok) {
      throw new ApiError(`profile request failed: ${response.status}`, response.status);
    }

    return (await response.json()) as Profile;
  } catch (error) {
    throw new Error("无法加载个人资料", { cause: error });
  }
}

记录时应保留错误类型和原因链,但要限制深度和大小:

function serializeError(error: unknown, depth = 0): unknown {
  if (depth > 3) return "[cause depth limit]";

  if (error instanceof Error) {
    return {
      name: error.name,
      message: error.message,
      stack: error.stack,
      cause: serializeError(error.cause, depth + 1),
    };
  }

  return String(error);
}

HTTP 401、403、404、409、429 和 5xx 不应统一当作“系统崩溃”:

  • 401 可能表示会话过期,应进入重新认证流程;
  • 403 可能是权限不足,应显示业务提示;
  • 404 可能是合法的资源不存在;
  • 409 可能是并发编辑冲突;
  • 429 和 5xx 才通常具有重试或服务降级价值。

错误类别决定恢复策略,也决定告警优先级。


八、服务端渲染、流式渲染和 React Server Components 的边界

1. 服务端渲染不是客户端错误边界的前置版本

服务端生成初始 HTML 时,浏览器还没有执行客户端 JavaScript,因此客户端 Error Boundary 不可能捕获服务端渲染阶段的异常。服务端入口需要处理:

import { renderToPipeableStream } from "react-dom/server";
import type { Request, Response } from "express";
import { App } from "./App";

export function renderPage(req: Request, res: Response) {
  let didError = false;

  const stream = renderToPipeableStream(<App />, {
    onShellReady() {
      res.statusCode = didError ? 500 : 200;
      res.setHeader("content-type", "text/html; charset=utf-8");
      stream.pipe(res);
    },
    onShellError(error) {
      didError = true;
      reportServerError(error, {
        source: "ssr-shell",
        route: req.url,
      });

      res.statusCode = 500;
      res.end("<!doctype html><p>页面暂时无法生成</p>");
    },
    onError(error) {
      didError = true;
      reportServerError(error, {
        source: "ssr-stream",
        route: req.url,
      });
    },
  });
}

上面的回调体现了两个不同故障阶段:

  • onShellError:初始 shell 无法生成,通常还可以返回完整的错误响应;
  • onError:流式渲染已经开始后又发生错误,响应头可能已经发送,服务器未必还能改变 HTTP 状态码。

因此“页面显示错误”和“HTTP 状态码是 200”可能同时成立。监控系统必须同时记录服务端渲染错误和客户端 hydration、运行时错误。

2. hydration 错误是另一类故障

服务端 HTML 和客户端首次渲染结果不一致时,会出现 hydration mismatch。常见原因包括:

function Clock() {
  return <time>{new Date().toISOString()}</time>;
}

服务端和客户端执行时间不同,输出自然不同。解决方案不是忽略告警,而是明确动态内容的来源,例如在客户端 effect 后更新,或由服务端把同一个时间值传入客户端。

生产诊断需要记录:

  • SSR 构建版本;
  • 客户端构建版本;
  • 路由;
  • 是否发生 hydration mismatch;
  • 服务端输出和客户端预期是否来自不同发布版本。

3. React Server Components 的边界

React Server Components 在服务端执行,不能直接使用浏览器 API、事件处理器或客户端状态。客户端错误边界只能保护客户端组件子树,不能把服务端组件执行异常变成客户端边界异常。

实际使用 Next.js、Remix 或其他框架时,应分别查看:

  • 服务端请求错误处理;
  • RSC/数据加载错误处理;
  • 客户端 error boundary;
  • 框架提供的 error.tsxErrorBoundary 或文档规定的错误入口。

这些是框架能力,不应假设“React 自身的 Error Boundary 会自动包住整个服务器请求生命周期”。


九、Web Vitals:从“页面快不快”转为可量化信号

Web Vitals 是一组用于衡量 Web 用户体验的指标。当前核心用户体验指标主要包括:

  • LCP(Largest Contentful Paint):视口内最大内容元素完成绘制的时间,主要反映主要内容何时出现;
  • INP(Interaction to Next Paint):用户交互到下一次可见绘制的响应延迟,反映交互响应性;
  • CLS(Cumulative Layout Shift):页面生命周期内无预期布局偏移的累计分数,反映视觉稳定性。

它们不是 React 专属指标,也不是“组件渲染时间”的直接替代品。

1. 指标的直觉

对于 LCP:

LCPmax(关键资源等待, 主线程阻塞, 元素绘制时间)LCP \approx \max(\text{关键资源等待},\ \text{主线程阻塞},\ \text{元素绘制时间})

因此把某个 React 组件拆小,不一定改善 LCP;如果最大图片仍被延迟发现、服务器响应慢或主线程被大包阻塞,LCP 仍可能很差。

对于 CLS,页面中每次布局偏移的影响可以近似表达为:

shift score=impact fraction×distance fraction\text{shift score} = \text{impact fraction} \times \text{distance fraction}

CLS 是这些偏移分数的聚合,而不是简单的“跳动次数”。没有预留图片尺寸、异步插入广告或字体切换,都可能产生布局偏移。

对于 INP,用户交互的完整路径包括:

输入事件事件处理React 更新浏览器绘制\text{输入事件} \rightarrow \text{事件处理} \rightarrow \text{React 更新} \rightarrow \text{浏览器绘制}

事件处理函数返回得很快,但 React 更新后主线程执行大量同步工作,仍然会导致较高 INP。

2. 使用 web-vitals 采集真实用户数据

浏览器原生 PerformanceObserver 可以读取部分性能条目,但直接计算核心指标容易遗漏浏览器兼容、生命周期和聚合规则。生产项目通常使用版本固定的 web-vitals 包:

npm install web-vitals
// webVitals.ts
import { onCLS, onINP, onLCP, type Metric } from "web-vitals";

type VitalEvent = {
  type: "web_vital";
  name: Metric["name"];
  value: number;
  rating: Metric["rating"];
  id: string;
  navigationType?: string;
  route: string;
  buildId: string;
};

const BUILD_ID = "web-2025-03-08-a";

function reportVital(metric: Metric): void {
  const event: VitalEvent = {
    type: "web_vital",
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    id: metric.id,
    navigationType: metric.navigationType,
    route: location.pathname,
    buildId: BUILD_ID,
  };

  const body = JSON.stringify(event);

  if (navigator.sendBeacon) {
    const accepted = navigator.sendBeacon(
      "/telemetry/web-vitals",
      new Blob([body], { type: "application/json" }),
    );
    if (accepted) return;
  }

  void fetch("/telemetry/web-vitals", {
    method: "POST",
    body,
    headers: { "content-type": "application/json" },
    keepalive: true,
  }).catch(() => {});
}

export function installWebVitals(): void {
  onCLS(reportVital);
  onINP(reportVital);
  onLCP(reportVital);
}

web-vitals 的具体 API 和指标支持应以项目锁定的包版本为准;不要把某个版本的参数写法直接复制到未验证的旧版本中。上例假定使用提供 onINP 的现代版本。

使用方式:

// client.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { installWebVitals } from "./webVitals";
import { installGlobalErrorHandlers } from "./globalErrors";

installGlobalErrorHandlers();
installWebVitals();

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

采集函数可以在首次用户交互或页面卸载时收到最终值。特别是 LCP 和 INP,过早把中间值当成最终值会导致指标重复统计或失真。服务端应按 id 去重,并按页面、版本、设备、网络和地区聚合。

3. 不要只看平均值

性能指标通常使用分位数而非平均值。设一组用户的 LCP 排序后为:

1200, 1400, 1800, 2200, 5000

平均值是 2320 ms,但 p75 落在 2200 ms 附近,p95 则接近 5000 ms。平均值会被极端值拉高,也无法直接回答“绝大多数用户体验如何”。

还应同时分析:

  • p50:典型用户;
  • p75:常用体验质量门槛;
  • p95:长尾用户;
  • 按版本和浏览器分组:定位回归;
  • 按网络和设备分组:区分代码问题与环境问题。

4. Web Vitals 与 React 错误需要关联

性能恶化和错误往往相互关联。例如某版本引入的组件异常可能导致主内容无法渲染,使 LCP 没有正常完成;一个同步大计算可能既提高 INP,也增加渲染异常发生概率。

因此错误和指标至少应共享:

  • buildId
  • 页面路由;
  • 匿名会话 ID;
  • navigation ID 或页面加载 ID;
  • 时间窗口;
  • 浏览器和设备类别。

这样才能查询“某发布版本中,发生 React 边界错误的会话,其 INP 是否也明显升高”。


十、React 性能诊断和 Web Vitals 的关系

React Profiler 记录的是 React 提交和组件渲染开销,Web Vitals 记录的是用户真正看到和感受到的浏览器结果,两者不是同一层。

例如:

function SearchResults({ query }: { query: string }) {
  const results = expensiveSearch(query);

  return (
    <ul>
      {results.map((result) => (
        <li key={result.id}>{result.title}</li>
      ))}
    </ul>
  );
}

expensiveSearch 可能导致 INP 变差,但只有当它阻塞了交互后的绘制时,才会体现在 INP 中。React Profiler 可以帮助定位哪个提交耗时,而 Web Vitals 可以验证真实用户是否受到影响。

诊断过程应按因果链进行:

  1. 先确认 INP 是否在某版本、某交互类型上恶化;
  2. 找到对应页面和操作;
  3. 用 React Profiler 或浏览器 Performance 面板确认主线程长任务;
  4. 判断时间花在 React render、事件处理、布局、脚本解析还是网络;
  5. 修复后通过真实用户数据验证,而不是只看本地开发机。

十一、发布诊断:没有构建标识就无法可靠归因

前端发布诊断最重要的字段之一是 build ID。它必须随前端代码、错误事件、Web Vitals 和服务端渲染日志一起出现。

构建标识可以来自:

  • Git commit SHA;
  • CI 生成的不可变构建号;
  • 发布系统生成的版本号。

不应使用容易复用的 latest 作为唯一版本标识。一个简单的构建注入方式:

// build-info.ts,由构建脚本生成
export const BUILD_ID = "web-2025-03-08-a";

页面启动时可以把版本放入 DOM 或全局诊断对象:

declare global {
  interface Window {
    __APP_BUILD_ID__?: string;
  }
}

window.__APP_BUILD_ID__ = BUILD_ID;

1. 静态资源版本不一致的故障路径

单页应用常见的发布故障是 HTML、JavaScript chunk 和服务端版本不一致:

  1. 用户打开旧版本 HTML;
  2. 新版本发布并删除旧 chunk;
  3. 用户执行路由跳转或懒加载;
  4. 浏览器请求旧 chunk;
  5. 返回 404 或错误内容;
  6. import() Promise rejection;
  7. 组件无法加载,可能表现为白屏或局部失败。

这不是普通 React 渲染异常。需要对动态模块加载失败进行识别、记录和有限重载:

export async function loadWithOneReload<T>(
  loader: () => Promise<T>,
): Promise<T> {
  try {
    return await loader();
  } catch (error) {
    const key = "chunk-reload-attempted";

    if (sessionStorage.getItem(key) !== "1") {
      sessionStorage.setItem(key, "1");
      reportError(error, {
        source: "resource",
        operation: "dynamic-import",
        extra: {
          buildId: BUILD_ID,
        },
      });
      location.reload();
    }

    throw error;
  }
}

风险在于:如果服务端持续返回错误,自动刷新会造成循环。因此必须有一次性标记,并在刷新后仍失败时显示明确的错误页面。更稳妥的部署方式是保留一段时间的旧静态资源,并让 HTML 和资源采用一致的缓存策略。

2. Source Map 的作用和风险

生产压缩代码中的堆栈通常只有短变量名和 bundle 行号。Source Map 可以把它还原到 TypeScript 源文件,但 Source Map 可能包含源码、注释和内部路径。

常见取舍是:

  • 不把完整 Source Map 公开给所有浏览器请求;
  • 上传到受控的错误收集系统;
  • 通过发布版本关联 Source Map;
  • 验证错误平台上的 release 与 buildId 一致;
  • 不把 Source Map 当作访问密钥或安全边界。

如果日志只记录 buildId,但错误平台没有该版本的 Source Map,诊断人员只能看到压缩堆栈,定位速度会显著下降。

3. 灰度发布中的比较方法

灰度发布的目的不仅是降低风险,也提供对照组。设旧版本和新版本的用户错误率分别为 RoR_oRnR_n,应在相近时间、相似流量和相似用户群中比较:

ΔR=RnRo\Delta R = R_n - R_o

如果新版本在低端设备上的 INP p75 显著上升,而桌面端没有变化,说明平均值可能掩盖了回归。发布判断还应同时考虑:

  • React 错误边界触发率;
  • 未处理 Promise rejection;
  • 动态 chunk 失败率;
  • LCP、INP、CLS 分位数;
  • 关键 API 错误率;
  • 受影响会话率。

十二、从一次故障事件还原完整链路

下面是一个典型的数据流:

flowchart LR
    U[用户操作] --> E[React 渲染或事件处理]
    E -->|渲染异常| B[Error Boundary]
    E -->|事件/异步异常| G[全局错误处理器]
    E -->|交互性能| V[Web Vitals]
    B --> L[结构化错误事件]
    G --> L
    V --> M[性能指标事件]
    L --> C[采集接口]
    M --> C
    C --> A[聚合与告警]
    A --> D[版本/路由/浏览器分析]
    D --> R[修复、灰度或回滚]

一次实际诊断可以这样展开:

  1. 用户在 checkout 页面点击“提交订单”;
  2. 事件处理器发起 API 请求;
  3. API 返回 500;
  4. 业务层将失败转换为表单错误,用户仍可重试;
  5. 若错误处理代码自身访问了不存在字段,则事件处理器异常进入全局 error
  6. 如果重试后组件状态导致渲染异常,则错误边界显示局部 fallback;
  7. 错误事件带有 buildId=web-2025-03-08-a、路由和匿名会话 ID;
  8. 同一会话的 INP 事件显示点击后的响应时间变差;
  9. 灰度对照发现只有新版本存在该指纹;
  10. 发布系统暂停扩容或回滚;
  11. 回滚后错误率和 INP 分位数恢复,故障关闭。

关键点是:错误边界日志只说明 React 子树出错,不能单独证明根因是 API。必须结合网络事件、业务状态、构建版本和性能数据。


十三、常见错误实现及其失败原因

1. 用 try/catch 包住 JSX

try {
  return <RiskyComponent />;
} catch {
  return <Fallback />;
}

这不能可靠替代 Error Boundary。React 的渲染、调度和提交不是一个普通同步函数调用,错误边界是 React 专门提供的恢复机制。try/catch 仍可用于普通函数、事件处理和序列化逻辑,但不应作为组件树异常隔离方案。

2. 认为所有错误都应交给根边界

根边界能避免整页白屏,但会失去局部恢复能力。更严重的是,如果把网络失败、权限不足和代码 bug 都显示成“系统出错”,用户无法采取正确行动,日志也无法区分故障类别。

3. 在 componentDidCatch 中再次渲染错误 UI

componentDidCatch 适合副作用,例如上报。备用 UI 应由 getDerivedStateFromError 更新状态产生。把状态切换和日志副作用混在一起,容易造成重复上报、更新循环或生命周期语义不清。

4. 只记录 message

以下两个错误的消息可能相同:

Cannot read properties of undefined

但它们可能来自完全不同的页面和构建版本。至少应记录错误名称、规范化堆栈、组件栈、路由、构建号和来源类别。

5. 把所有 Promise rejection 设为高优先级告警

Promise rejection 可能来自用户取消请求、权限过期、网络切换或第三方脚本。应在业务层尽可能处理预期失败,在全局监听器中记录未处理 rejection,并根据错误类别、影响用户数和版本集中度设置告警。

6. 只在本地测 Web Vitals

本地高性能设备的 LCP、INP 不能代表移动设备、弱网和低端 CPU。真实用户监控应分组采样,并且注意隐私、采样率和数据成本。若全量发送每个性能事件,遥测流量本身可能成为成本和稳定性问题。


十四、测试错误边界和可观测性

错误边界不仅要测试“能显示 fallback”,还要测试日志、恢复和去重语义。

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, test, expect } from "vitest";

function BrokenWidget() {
  throw new Error("widget failed");
}

test("渲染异常时显示 fallback 并允许重新尝试", async () => {
  const user = userEvent.setup();
  const onRetry = vi.fn();

  render(
    <ErrorBoundary
      fallback={
        <div>
          <p role="alert">加载失败</p>
          <button onClick={onRetry}>重试</button>
        </div>
      }
    >
      <BrokenWidget />
    </ErrorBoundary>,
  );

  expect(screen.getByRole("alert")).toHaveTextContent("加载失败");

  await user.click(screen.getByRole("button", { name: "重试" }));
  expect(onRetry).toHaveBeenCalledTimes(1);
});

测试时应注意:

  • 在测试环境中,React 可能把预期的渲染异常写入控制台;
  • 可以局部 mock console.error,但测试结束必须恢复;
  • 不应只断言文案,还应断言 fallback 可访问性和恢复操作;
  • 日志上报函数应注入依赖,验证包含 componentStack、路由和版本;
  • 端到端测试应覆盖动态 chunk 失败、CSP 阻止脚本和错误页面刷新策略。

一个常见的依赖注入形式是:

type Reporter = (error: unknown, context: ErrorContext) => void;

class TestableBoundary extends Component<
  { children: ReactNode; report: Reporter },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    this.props.report(error, {
      source: "react-boundary",
      extra: { componentStack: info.componentStack },
    });
  }

  render() {
    return this.state.hasError ? <p role="alert">出错了</p> : this.props.children;
  }
}

这样测试关注的是用户行为和可观测事件,而不是依赖具体日志平台的网络实现。


十五、生产诊断的验证与恢复流程

当监控发现新版本错误率上升时,诊断应形成可验证的闭环:

第一步:确认事件是否真实

检查:

  • 是否为生产环境;
  • 是否集中在同一个 buildId
  • 是否因重复上报造成数量膨胀;
  • 是否来自同一浏览器扩展或第三方脚本;
  • 是否在错误平台完成 Source Map 符号化。

第二步:确认影响范围

按以下维度切分:

  • 路由和功能;
  • 浏览器与操作系统;
  • 设备性能;
  • 网络类型和地区;
  • 新旧版本;
  • 受影响会话率而非只有事件数。

第三步:确认故障路径

将错误时间与以下事件关联:

  • API 请求状态;
  • 动态资源加载;
  • CSP 报告;
  • SSR 日志;
  • hydration 错误;
  • LCP、INP、CLS;
  • 用户操作或页面导航。

第四步:选择恢复动作

不同根因对应不同动作:

  • 代码渲染异常:停止灰度、修复并重新发布;
  • 静态资源不一致:恢复旧资源、修正缓存或回滚;
  • 后端 5xx:降级前端功能或修复服务端;
  • CSP 配置错误:先修正策略和资源来源,再验证安全边界;
  • 单浏览器兼容问题:针对性修复或暂时降级;
  • 第三方脚本异常:隔离、延迟加载或移除。

第五步:验证恢复

回滚不是“执行命令后结束”。必须验证:

  1. 新错误事件是否停止产生;
  2. 旧版本的错误是否仍存在;
  3. 动态 chunk 和入口 HTML 是否来自一致发布;
  4. Web Vitals 是否回到历史基线;
  5. 关键用户流程是否通过测试;
  6. 灰度和全量用户看到的版本是否符合预期。

若 CDN、Service Worker 或浏览器缓存仍保留坏版本,单纯重新部署可能不会立刻消除故障。需要检查缓存键、资源保留策略、Service Worker 更新和页面强制刷新行为。


十六、客户端与服务端各自应承担的责任

可以把故障处理责任分成三层:

层次 主要职责 典型信号
React 子树 隔离渲染异常,显示局部 fallback componentDidCatch、组件栈
浏览器客户端 处理事件、异步、资源和性能异常 errorunhandledrejection、Web Vitals
服务端/框架 处理 SSR、RSC、API、日志和发布 HTTP 状态、SSR 回调、请求 trace

三层之间不能互相替代:

  • Error Boundary 不能捕获服务端初始渲染;
  • 全局 unhandledrejection 不能生成正确的业务恢复 UI;
  • Web Vitals 不能直接说明某个组件存在 bug;
  • 服务端 500 不能解释客户端为什么出现某个 TypeError;
  • 发布回滚不能代替错误归因和数据验证。

一个可靠系统会让这些层共享版本号、请求标识和页面标识,但保留各自准确的语义。


结语:把“报错”变成可恢复、可定位、可验证的事件

React 错误边界解决的是组件树中的一类渲染故障隔离问题。它通过状态转换渲染 fallback,通过 componentDidCatch 提供组件栈,但不覆盖事件处理器、异步回调、服务端渲染和所有资源加载失败。

完整的前端可观测性需要进一步建立:

  • 结构化错误日志,而不是只有控制台字符串;
  • 明确的错误分类、指纹、版本和路由;
  • 事件、Promise rejection 和资源加载的补充处理;
  • LCP、INP、CLS 等真实用户性能指标;
  • Source Map、构建标识和发布对照;
  • 与 SSR、API、CSP、静态资源和灰度系统的关联;
  • 从发现、诊断、回滚到恢复验证的闭环。

最终目标不是让页面永远不出错,而是在错误发生时限制影响范围,让用户得到可用的恢复路径,让工程师能从证据中重建故障因果,并确认修复确实改善了真实用户体验。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。