React 基础体系 · 第 19/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 错误边界与可观测性:异常、日志、Web Vitals 和发布诊断
前端故障通常不是“页面报错”这么简单。一个用户看到白屏,可能由渲染异常、接口失败、静态资源版本不一致、Content Security Policy(CSP)阻止脚本、服务端渲染失败,或某次发布只影响部分用户造成。错误边界只能处理其中一部分问题;日志、指标、链路标识和发布信息必须共同工作,才能回答以下问题:
- 哪一类异常发生了?
- 它影响了哪些用户、路由、浏览器和发布版本?
- React 是否成功恢复了界面?
- 性能是否已经恶化到影响用户体验?
- 这是代码回归、后端故障、资源部署问题,还是环境差异?
- 修复或回滚之后,故障是否真正消失?
本文以 React 19、现代 TypeScript 和浏览器客户端为基础,分别说明客户端 React 树、服务端渲染和框架运行时的边界。
一、先建立可观测性的对象模型
**可观测性(observability)**是从系统外部输出的信号推断系统内部状态的能力。前端通常使用三类信号:
- 日志(log):某个事件发生了什么,例如一个错误对象、组件栈、请求状态。
- 指标(metric):事件的聚合统计,例如每分钟错误数、错误用户率、LCP 的 p75。
- 链路(trace):一次用户操作跨越浏览器、前端代码、API 网关和后端服务时,各阶段如何关联。
一个错误事件可以抽象为:
其中:
- :发生时间;
- :用户或匿名会话标识;
- :页面和功能区域;
- :发布版本或构建标识;
- :浏览器、设备、网络等环境;
- :错误类型、名称和指纹;
- :上下文,例如组件栈、请求、路由、业务参数。
只记录 error.message,通常只能得到 的一小部分,无法判断它是否集中在某个版本、路由或浏览器。因此错误日志的关键不是“把所有变量都打印出来”,而是建立足够的关联维度,同时避免把密码、令牌、身份证号等敏感数据发送到日志系统。
1. 错误、异常和失败不是同一个概念
在前端工程中应区分:
- 异常(exception):程序执行过程中出现了未按正常返回值表达的错误,例如
throw new Error(...)。 - 失败(failure):业务或基础设施没有完成预期任务,例如 HTTP 503、表单校验失败、用户主动取消请求。
- 崩溃(crash):错误导致当前页面、React 子树或某个功能无法继续运行。
一个接口返回 404 可能是可预期的业务状态,不应自动当作未捕获异常;一个组件渲染时访问 undefined.name 则通常是程序异常。可观测性系统必须保留这种分类,否则告警会被大量正常业务失败淹没。
2. 错误率需要明确分母
设某个发布版本中发生错误的会话数为 ,活跃会话数为 ,则用户错误率可以写成:
若统计的是错误事件,则分母可能是页面加载次数、功能操作次数或请求次数。两者含义不同:
- 一个用户连续触发十次同一异常,事件数会增加十次,但用户错误率只增加一次;
- 一个错误发生在 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;
}
}
故障路径是:
- React 尝试渲染
children; - 子树中的渲染逻辑抛出错误;
- React 向上寻找最近的错误边界;
- 先调用
getDerivedStateFromError,得到备用状态; - React 用新状态渲染 fallback;
- 提交阶段调用
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>
);
}
每次点击重试的状态变化是:
但重试并不能修复确定性的代码错误。如果 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);
};
}
这类监听器的作用是记录“漏出业务处理边界的异常”,不是替代局部错误处理:
- 事件处理器仍应在操作附近显示失败状态;
- 请求失败仍应区分可重试和不可重试;
- 全局处理器不应自动弹出重复提示;
- 监听器本身不能抛出新异常,否则会形成故障循环。
资源加载错误也需要单独考虑。window 的 error 事件可以报告部分脚本、样式和图片资源失败,但跨域脚本可能只有有限信息;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.tsx、ErrorBoundary或文档规定的错误入口。
这些是框架能力,不应假设“React 自身的 Error Boundary 会自动包住整个服务器请求生命周期”。
九、Web Vitals:从“页面快不快”转为可量化信号
Web Vitals 是一组用于衡量 Web 用户体验的指标。当前核心用户体验指标主要包括:
- LCP(Largest Contentful Paint):视口内最大内容元素完成绘制的时间,主要反映主要内容何时出现;
- INP(Interaction to Next Paint):用户交互到下一次可见绘制的响应延迟,反映交互响应性;
- CLS(Cumulative Layout Shift):页面生命周期内无预期布局偏移的累计分数,反映视觉稳定性。
它们不是 React 专属指标,也不是“组件渲染时间”的直接替代品。
1. 指标的直觉
对于 LCP:
因此把某个 React 组件拆小,不一定改善 LCP;如果最大图片仍被延迟发现、服务器响应慢或主线程被大包阻塞,LCP 仍可能很差。
对于 CLS,页面中每次布局偏移的影响可以近似表达为:
CLS 是这些偏移分数的聚合,而不是简单的“跳动次数”。没有预留图片尺寸、异步插入广告或字体切换,都可能产生布局偏移。
对于 INP,用户交互的完整路径包括:
事件处理函数返回得很快,但 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 可以验证真实用户是否受到影响。
诊断过程应按因果链进行:
- 先确认 INP 是否在某版本、某交互类型上恶化;
- 找到对应页面和操作;
- 用 React Profiler 或浏览器 Performance 面板确认主线程长任务;
- 判断时间花在 React render、事件处理、布局、脚本解析还是网络;
- 修复后通过真实用户数据验证,而不是只看本地开发机。
十一、发布诊断:没有构建标识就无法可靠归因
前端发布诊断最重要的字段之一是 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 和服务端版本不一致:
- 用户打开旧版本 HTML;
- 新版本发布并删除旧 chunk;
- 用户执行路由跳转或懒加载;
- 浏览器请求旧 chunk;
- 返回 404 或错误内容;
import()Promise rejection;- 组件无法加载,可能表现为白屏或局部失败。
这不是普通 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. 灰度发布中的比较方法
灰度发布的目的不仅是降低风险,也提供对照组。设旧版本和新版本的用户错误率分别为 和 ,应在相近时间、相似流量和相似用户群中比较:
如果新版本在低端设备上的 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[修复、灰度或回滚]
一次实际诊断可以这样展开:
- 用户在
checkout页面点击“提交订单”; - 事件处理器发起 API 请求;
- API 返回 500;
- 业务层将失败转换为表单错误,用户仍可重试;
- 若错误处理代码自身访问了不存在字段,则事件处理器异常进入全局
error; - 如果重试后组件状态导致渲染异常,则错误边界显示局部 fallback;
- 错误事件带有
buildId=web-2025-03-08-a、路由和匿名会话 ID; - 同一会话的 INP 事件显示点击后的响应时间变差;
- 灰度对照发现只有新版本存在该指纹;
- 发布系统暂停扩容或回滚;
- 回滚后错误率和 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 配置错误:先修正策略和资源来源,再验证安全边界;
- 单浏览器兼容问题:针对性修复或暂时降级;
- 第三方脚本异常:隔离、延迟加载或移除。
第五步:验证恢复
回滚不是“执行命令后结束”。必须验证:
- 新错误事件是否停止产生;
- 旧版本的错误是否仍存在;
- 动态 chunk 和入口 HTML 是否来自一致发布;
- Web Vitals 是否回到历史基线;
- 关键用户流程是否通过测试;
- 灰度和全量用户看到的版本是否符合预期。
若 CDN、Service Worker 或浏览器缓存仍保留坏版本,单纯重新部署可能不会立刻消除故障。需要检查缓存键、资源保留策略、Service Worker 更新和页面强制刷新行为。
十六、客户端与服务端各自应承担的责任
可以把故障处理责任分成三层:
| 层次 | 主要职责 | 典型信号 |
|---|---|---|
| React 子树 | 隔离渲染异常,显示局部 fallback | componentDidCatch、组件栈 |
| 浏览器客户端 | 处理事件、异步、资源和性能异常 | error、unhandledrejection、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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 前端安全:XSS、dangerouslySetInnerHTML、URL、Token 和依赖
- 下一篇:React 组件库与设计系统:组合 API、Token、主题和版本治理
- 延伸:React 测试体系:Testing Library、Vitest、用户行为和端到端测试
- 延伸:React 生产交付:配置、静态资源、CSP、灰度、监控和回滚
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论