前端错误监控与异常边界:从全局捕获到组件级降级的工程实践

一、前端错误的隐蔽性:用户不会告诉你页面崩了
前端应用的错误分为两类:可捕获的(JS 异常、Promise rejection、资源加载失败)和不可捕获的(组件渲染白屏、交互无响应)。前者可以通过 window.onerror 和 unhandledrejection 全局捕获,后者则需要更精细的监控手段。更严重的是,大多数用户遇到错误时不会反馈,而是直接离开——错误监控是发现线上问题的唯一可靠手段。
React 的 Error Boundary 提供了组件级的错误隔离能力:当子组件渲染抛出异常时,Error Boundary 捕获异常并展示降级 UI,而非整个页面白屏。这是前端错误处理从"全局兜底"到"组件级降级"的架构演进。
二、前端错误监控的架构设计
2.1 错误捕获的分层模型
flowchart TB
subgraph Global["全局捕获层"]
G1[window.onerror] –> G2[unhandledrejection]
G2 –> G3[资源加载错误]
G3 –> G4[iframe 异常]
end
subgraph Framework["框架捕获层"]
F1[React Error Boundary] –> F2[Vue errorHandler]
F2 –> F3[路由错误处理]
end
subgraph Business["业务捕获层"]
B1[API 请求拦截] –> B2[业务逻辑 try-catch]
B2 –> B3[用户行为追踪]
end
subgraph Report["上报与聚合"]
R1[错误去重] –> R2[堆栈压缩]
R2 –> R3[采样上报]
R3 –> R4[错误聚合与告警]
end
Global –> Report
Framework –> Report
Business –> Report
2.2 错误分类与优先级
| JS 运行时异常 | window.onerror | 当前操作 | P0 |
| 未捕获的 Promise | unhandledrejection | 异步流程 | P0 |
| 资源加载失败 | addEventListener error | 页面功能 | P1 |
| React 渲染异常 | Error Boundary | 组件区域 | P1 |
| API 请求失败 | 拦截器 | 数据展示 | P2 |
| 控制台警告 | console 劫持 | 潜在问题 | P3 |
三、错误监控与异常边界的代码实现
3.1 全局错误捕获与上报
interface ErrorReport {
type: 'js' | 'promise' | 'resource' | 'react' | 'api';
message: string;
stack?: string;
filename?: string;
lineno?: number;
colno?: number;
url: string;
timestamp: number;
userAgent: string;
extra?: Record<string, unknown>;
}
class ErrorMonitor {
private queue: ErrorReport[] = [];
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private readonly FLUSH_INTERVAL = 5000; // 5秒批量上报
private readonly MAX_QUEUE_SIZE = 50;
init(): void {
this.captureJSErrors();
this.capturePromiseRejections();
this.captureResourceErrors();
}
// 捕获 JS 运行时异常
private captureJSErrors(): void {
window.onerror = (message, filename, lineno, colno, error) => {
this.report({
type: 'js',
message: String(message),
stack: error?.stack,
filename: filename || undefined,
lineno: lineno || undefined,
colno: colno || undefined,
url: location.href,
timestamp: Date.now(),
userAgent: navigator.userAgent,
});
};
}
// 捕获未处理的 Promise rejection
private capturePromiseRejections(): void {
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
this.report({
type: 'promise',
message: reason?.message || String(reason),
stack: reason?.stack,
url: location.href,
timestamp: Date.now(),
userAgent: navigator.userAgent,
});
});
}
// 捕获资源加载失败
private captureResourceErrors(): void {
window.addEventListener('error', (event) => {
const target = event.target as HTMLElement;
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT' || target.tagName === 'LINK')) {
this.report({
type: 'resource',
message: `Failed to load ${target.tagName.toLowerCase()}`,
url: location.href,
timestamp: Date.now(),
userAgent: navigator.userAgent,
extra: {
src: (target as HTMLImageElement).src || (target as HTMLLinkElement).href,
},
});
}
}, true); // 使用捕获阶段
}
// 上报错误(批量 + 去重)
report(error: ErrorReport): void {
// 去重:相同 message + stack 前三行视为同一错误
const fingerprint = this.getFingerprint(error);
if (this.queue.some(e => this.getFingerprint(e) === fingerprint)) {
return;
}
this.queue.push(error);
// 队列满时立即上报
if (this.queue.length >= this.MAX_QUEUE_SIZE) {
this.flush();
return;
}
// 延迟批量上报
if (!this.flushTimer) {
this.flushTimer = setTimeout(() => this.flush(), this.FLUSH_INTERVAL);
}
}
private async flush(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
if (this.queue.length === 0) return;
const batch = […this.queue];
this.queue = [];
try {
// 使用 sendBeacon 确保页面卸载时也能上报
const data = JSON.stringify(batch);
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/errors', data);
} else {
await fetch('/api/errors', { method: 'POST', body: data, keepalive: true });
}
} catch {
// 上报失败时存入 localStorage,下次重试
try {
const pending = JSON.parse(localStorage.getItem('__error_queue__') || '[]');
pending.push(…batch);
localStorage.setItem('__error_queue__', JSON.stringify(pending.slice(-100)));
} catch {
// localStorage 也失败了,放弃
}
}
}
// 错误指纹:用于去重
private getFingerprint(error: ErrorReport): string {
const stackLines = (error.stack || '').split('\\n').slice(0, 3).join('');
return `${error.type}:${error.message}:${stackLines}`;
}
}
3.2 React Error Boundary 组件
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode; // 自定义降级 UI
onError?: (error: Error, info: ErrorInfo) => void; // 错误回调
resetKeys?: unknown[]; // 重置触发键
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// 上报错误到监控系统
errorMonitor.report({
type: 'react',
message: error.message,
stack: error.stack,
url: location.href,
timestamp: Date.now(),
userAgent: navigator.userAgent,
extra: {
componentStack: info.componentStack,
},
});
// 执行自定义回调
this.props.onError?.(error, info);
}
componentDidUpdate(prevProps: ErrorBoundaryProps): void {
// resetKeys 变更时重置错误状态
if (this.state.hasError && this.props.resetKeys) {
const changed = this.props.resetKeys.some(
(key, i) => key !== prevProps.resetKeys?.[i]
);
if (changed) {
this.setState({ hasError: false, error: null });
}
}
}
render(): ReactNode {
if (this.state.hasError) {
// 自定义降级 UI
if (this.props.fallback) {
return this.props.fallback;
}
// 默认降级 UI
return (
<div style={{
padding: '24px',
textAlign: 'center',
color: '#666',
}}>
<p>该区域加载异常</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
style={{
padding: '8px 16px',
background: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
重试
</button>
</div>
);
}
return this.props.children;
}
}
// 使用示例:组件级错误隔离
function App() {
return (
<div>
<Header />
<ErrorBoundary fallback={<div>侧边栏加载失败</div>}>
<Sidebar />
</ErrorBoundary>
<ErrorBoundary resetKeys={[currentId]}>
<MainContent id={currentId} />
</ErrorBoundary>
</div>
);
}
3.3 API 请求错误拦截
import axios from 'axios';
const apiClient = axios.create({
baseURL: '/api',
timeout: 10000,
});
// 响应拦截器:统一处理 API 错误
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const report: ErrorReport = {
type: 'api',
message: error.message,
url: location.href,
timestamp: Date.now(),
userAgent: navigator.userAgent,
extra: {
method: error.config?.method,
url: error.config?.url,
status: error.response?.status,
data: error.response?.data,
},
};
errorMonitor.report(report);
// 401 跳转登录
if (error.response?.status === 401) {
window.location.href = '/login';
return Promise.reject(error);
}
return Promise.reject(error);
}
);
四、错误监控的架构权衡
4.1 上报量与成本的平衡
全量上报在高流量应用中会产生大量数据。建议采用采样策略:P0 错误 100% 上报,P1 错误 50% 采样,P2/P3 错误 10% 采样。同时设置单用户单日上报上限,防止恶意刷量。
4.2 Error Boundary 的粒度
Error Boundary 的粒度决定了错误爆炸半径。粒度过粗(整个页面一个 Boundary),一个小组件的错误导致整个页面降级;粒度过细(每个组件一个 Boundary),代码冗余。建议按功能区域划分:导航栏、侧边栏、主内容区各一个 Boundary。
4.3 SourceMap 与堆栈还原
生产环境的 JS 代码经过压缩混淆,错误堆栈不可读。需要在错误上报后,通过 SourceMap 文件还原原始堆栈。SourceMap 文件不应部署到生产服务器,而应存储在内部服务中,由错误聚合服务在服务端完成还原。
五、总结
前端错误监控从全局捕获到组件级降级,是"防御纵深"的工程实践。全局捕获确保不遗漏任何异常,Error Boundary 实现组件级错误隔离,API 拦截器覆盖网络层错误。落地时建议先部署全局捕获和上报,建立错误可见性,再逐步引入 Error Boundary 和组件级降级。核心原则是:错误监控的目标不仅是发现问题,更是保障用户在出错时仍能使用核心功能。



