(Updated: 2025年10月17日
)
前端错误处理完全指南:从入门到精通
#错误处理
#JavaScript
#React
#前端开发
这篇不是“大全体”也不是“十条黄金法则”。我把最近两年在中大型前端项目里踩过的坑、做过的治理和复盘过的事故,整理成一套能直接落地的做法,尽量少空话,多给你复制即可用的代码片段和检查清单。
一次典型线上事故复盘(简化版)
- 现象:首页偶发白屏,错误上报量在 5 分钟内从 0 飙到 3k/min。
- 根因:接口返回结构变更,
data.items偶尔为null,渲染阶段触发Cannot read properties of null。 - 波及:受影响 PV ~8%,平均首屏延迟 +1.2s,转化率下降 3.7%。
- 复盘要点:
- 渲染层缺少防御性判断;
- 缺少“用户可见降级”;
- 错误上报有量无维度,缺乏
release、route、correlationId关联排障。
对应的改动:渲染层防御 + 统一请求封装 + 错误分级 + 观测维度补齐(见下文)。
错误分级与用户感知
把错误分 4 级,约束“什么时候必须降级、什么时候只打点”:
- S0(致命):白屏、路由无法进入、关键交易链路中断 → 立刻降级 + 弹性提示 + 阻断错误风暴。
- S1(严重):重要模块功能缺失、明显 UI 破坏 → 降级 + 降噪上报 + 提醒刷新。
- S2(一般):单点组件异常但可恢复 → 记录 + 采样上报。
- S3(低):非用户可感知类、开发期告警 → 本地或仅在调试环境上报。
落地做法:在上报维度里带上 severity,在渲染层用不同的兜底 UI。
捕获矩阵:哪里会出错,如何兜底
- 运行时异常:
try/catch、window.onerror、unhandledrejection、React Error Boundary。 - 资源加载:
img.onerror、link/script的onerror,提供本地/低清资源回退。 - 网络请求:统一请求封装(超时、重试、熔断、幂等、取消)。
- 第三方 SDK:隔离沙箱 + 超时保护 + 失败不阻断主流程。
React 层的兜底:Error Boundary + 可恢复
Error Boundary 仍然需要 class 组件实现,但可以配合“key 触发重置”的可恢复模式:
import React from 'react';
class ErrorBoundary extends React.Component<{ fallback?: React.ReactNode }, { hasError: boolean }>{
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(error: unknown, info: unknown) {
// TODO: 上报 error, info
}
render() {
if (this.state.hasError) return this.props.fallback ?? <div>出错了,请重试</div>;
return this.props.children as React.ReactElement;
}
}
export function RecoverableSection({ children }: { children: React.ReactNode }) {
const [key, setKey] = React.useState(0);
return (
<ErrorBoundary fallback={
<div className="p-4 rounded bg-amber-50 text-amber-800">
模块加载失败 <button className="ml-2 underline" onClick={() => setKey(k => k + 1)}>重试</button>
</div>
}>
<div key={key}>{children}</div>
</ErrorBoundary>
);
}
在页面布局中把“易碎模块”(如图表、富文本、第三方组件)包进 RecoverableSection,把白屏控制在最小范围。
请求层:一个能救火的 fetch 封装
目标:超时、重试(指数回退)、状态码统一、业务错误码分流、可取消、幂等。
export type Ok<T> = { ok: true; data: T; status: number };
export type Err = { ok: false; error: Error; status?: number };
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
export async function request<T>(
input: RequestInfo,
init: RequestInit & { retries?: number; timeout?: number; idempotencyKey?: string } = {}
): Promise<Ok<T> | Err> {
const { retries = 2, timeout = 8000, idempotencyKey, ...rest } = init;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(input, {
headers: {
'Content-Type': 'application/json',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
...(rest.headers || {}),
},
signal: controller.signal,
...rest,
});
if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`), status: res.status };
const json = (await res.json()) as { code?: number; msg?: string; data?: T } | T;
if (json && typeof json === 'object' && 'code' in (json as any)) {
const j = json as any;
if (j.code !== 0) return { ok: false, error: new Error(j.msg || 'BUSINESS_ERROR'), status: res.status };
return { ok: true, data: j.data as T, status: res.status };
}
return { ok: true, data: json as T, status: res.status };
} catch (e: any) {
if (retries > 0 && (e?.name === 'AbortError' || e?.message?.includes('network'))) {
await sleep(300 * Math.pow(2, 2 - retries));
return request<T>(input, { ...init, retries: retries - 1 });
}
return { ok: false, error: e };
} finally {
clearTimeout(timer);
}
}
使用:
const res = await request<User>('/api/user', { method: 'GET', retries: 2 });
if (!res.ok) {
// S1 以上:展示用户可见的兜底;S2/S3:静默 + 上报
}
资源加载:别让一张图拖垮首屏
- 图片:
onError切本地占位图 + 降低不透明度提示降级; - 脚本/样式:加
preload与超时兜底,失败回退到功能降级版; - 第三方:放到
RecoverableSection内,失败不阻断主流程。
项目里可复用的安全图片组件(已适配 CLS):
function SafeImage({ src, alt, fallback }: { src: string; alt?: string; fallback: string }) {
const [imgSrc, setImgSrc] = React.useState(src);
const [err, setErr] = React.useState(false);
return (
<img
src={imgSrc}
alt={alt}
onError={() => { if (!err) { setImgSrc(fallback); setErr(true); } }}
style={{ opacity: err ? 0.75 : 1 }}
loading="lazy"
decoding="async"
/>
);
}
上报与观测:先有维度,再谈定位
即便不用第三方平台,也请至少打点这些维度:
- 必填:
message、stack、severity、route、release、ua、ts; - 排障增强:
correlationId(贯穿请求与上报)、reqId、bucket(灰度/渠道)。
Sentry 集成(最小可用):
import * as Sentry from '@sentry/browser';
Sentry.init({ dsn: 'https://<your-dsn>', release: 'web@1.3.5', tracesSampleRate: 0.1 });
window.addEventListener('error', (e) => {
Sentry.captureException(e.error ?? new Error(e.message));
});
window.addEventListener('unhandledrejection', (e) => {
Sentry.captureException(e.reason instanceof Error ? e.reason : new Error(String(e.reason)));
});
如果你更倾向自建,把错误 POST 到你的 /api/errors,服务端落表并按 severity 告警(项目中可对接 Supabase/PG)。
排查清单(现场可直接用)
- 错误集中在哪些
route/release?是否和某次灰度有关? - 栈里是否包含第三方 SDK?可临时禁用验证假设。
- 是否是 Promise 链“吃掉错误”?
unhandledrejection量级如何? - 近 15 分钟失败接口的状态码分布(4xx/5xx/0)?是否跨域/超时居多?
- 有无单机集中爆发(浏览器/系统版本聚集)?
落地清单(1 天内能完成)
- 为易碎模块加
RecoverableSection; - 接口统一走
request封装,并标注severity; - 接入
window.onerror与unhandledrejection,上报最小维度; - 为图片与第三方脚本加
onError回退; - 在上报里补齐
release/route/correlationId。
常见坑与正确写法
- 只在
try/catch里包await的表达式,别把整段业务逻辑都包住,降低误报面; Promise.all里任一失败会导致全失败,需要allSettled或降级;- 边界组件里上报时注意节流,避免“错误风暴”导致次生灾害;
- 组件
useEffect抛错不会被 Error Boundary 捕获,需要在异步回调里自行try/catch并上报。
如果你只能记住一件事:先把“可恢复”做起来(Error Boundary + 局部重试),再补观察维度。这样即便出错,用户也还有路可走,后续排查也有抓手。