ZACSOKACH
(Updated: 2025年10月17日 )

前端错误处理完全指南:从入门到精通

#错误处理 #JavaScript #React #前端开发

这篇不是“大全体”也不是“十条黄金法则”。我把最近两年在中大型前端项目里踩过的坑、做过的治理和复盘过的事故,整理成一套能直接落地的做法,尽量少空话,多给你复制即可用的代码片段和检查清单。

一次典型线上事故复盘(简化版)

  • 现象:首页偶发白屏,错误上报量在 5 分钟内从 0 飙到 3k/min。
  • 根因:接口返回结构变更,data.items 偶尔为 null,渲染阶段触发 Cannot read properties of null
  • 波及:受影响 PV ~8%,平均首屏延迟 +1.2s,转化率下降 3.7%。
  • 复盘要点:
    • 渲染层缺少防御性判断;
    • 缺少“用户可见降级”;
    • 错误上报有量无维度,缺乏 releaseroutecorrelationId 关联排障。

对应的改动:渲染层防御 + 统一请求封装 + 错误分级 + 观测维度补齐(见下文)。


错误分级与用户感知

把错误分 4 级,约束“什么时候必须降级、什么时候只打点”:

  • S0(致命):白屏、路由无法进入、关键交易链路中断 → 立刻降级 + 弹性提示 + 阻断错误风暴。
  • S1(严重):重要模块功能缺失、明显 UI 破坏 → 降级 + 降噪上报 + 提醒刷新。
  • S2(一般):单点组件异常但可恢复 → 记录 + 采样上报。
  • S3(低):非用户可感知类、开发期告警 → 本地或仅在调试环境上报。

落地做法:在上报维度里带上 severity,在渲染层用不同的兜底 UI。


捕获矩阵:哪里会出错,如何兜底

  • 运行时异常:try/catchwindow.onerrorunhandledrejection、React Error Boundary。
  • 资源加载:img.onerrorlink/scriptonerror,提供本地/低清资源回退。
  • 网络请求:统一请求封装(超时、重试、熔断、幂等、取消)。
  • 第三方 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"
    />
  );
}

上报与观测:先有维度,再谈定位

即便不用第三方平台,也请至少打点这些维度:

  • 必填:messagestackseverityroutereleaseuats
  • 排障增强:correlationId(贯穿请求与上报)、reqIdbucket(灰度/渠道)。

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.onerrorunhandledrejection,上报最小维度;
  • 为图片与第三方脚本加 onError 回退;
  • 在上报里补齐 release/route/correlationId

常见坑与正确写法

  • 只在 try/catch 里包 await 的表达式,别把整段业务逻辑都包住,降低误报面;
  • Promise.all 里任一失败会导致全失败,需要 allSettled 或降级;
  • 边界组件里上报时注意节流,避免“错误风暴”导致次生灾害;
  • 组件 useEffect 抛错不会被 Error Boundary 捕获,需要在异步回调里自行 try/catch 并上报。

如果你只能记住一件事:先把“可恢复”做起来(Error Boundary + 局部重试),再补观察维度。这样即便出错,用户也还有路可走,后续排查也有抓手。