陌上人如玉
公子世无双

8.6 未捕获错误的监控

8.6 未捕获错误的监控

一句话:前端错误有三类来源——React 渲染错误(用 ErrorBoundary)、全局 JS 运行时错误(window.onerror / error 事件)、未处理的 Promise 拒绝(unhandledrejection);三者都要接上,并做「去重、采样、源码映射、上下文补充、可靠上报」。

一、三类错误来源

来源 例子 捕获方式
React 渲染错误 组件里对 undefined 取属性 Error Boundary(componentDidCatch)
全局 JS 错误 事件处理里 throw、语法/运行时错误 window.onerror / window.addEventListener('error')
未处理的 Promise Promise.reject()、async 里 throw 未 catch window.addEventListener('unhandledrejection')
资源加载失败 图片/脚本/样式 404 window.addEventListener('error', handler, true)(捕获阶段)
接口错误 4xx/5xx 请求层拦截器(见 7.1)
⭐ 关键认知:
· ErrorBoundary 只覆盖「渲染错误」(见 8.3)
· 事件处理、异步、资源加载的错误必须用「全局监听」
· 接口错误应该在「请求层」统一上报(带上接口与状态码上下文)
→ 完整的监控是「四者叠加」,不是「选一个」

二、Error Boundary 上报

class ErrorBoundary extends Component<Props, State> {
  state = { error: null as Error | null };

  static getDerivedStateFromError(error: Error) {
    return { error };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    // ⭐ 这里能拿到「组件栈」——定位问题最关键的上下文
    reportError(error, {
      type: 'react-render',
      componentStack: info.componentStack,
    });
  }

  render() {
    return this.state.error ? this.props.fallback : this.props.children;
  }
}
⚠️ ErrorBoundary 上报的三个注意点:
① `info.componentStack` 是【定位 React 错误的核心】——它告诉你「哪条组件链抛的错」
② 要「去重」——StrictMode 下会捕获两次(错误对象相同)
③ 要「带上当前路由/用户/版本」等上下文(便于复现)

三、全局 JS 错误

// monitor/global-error.ts
export function setupGlobalErrorHandlers() {
  // ① 全局运行时错误(含未捕获的同步错误)
  window.addEventListener('error', (event) => {
    // ⚠️ 资源加载失败也会触发这里(event.target 不是 window)
    const target = event.target as HTMLElement | null;
    if (target && target !== (window as unknown as HTMLElement) && (target as any).tagName) {
      reportError(new Error(`资源加载失败: ${(target as any).src || (target as any).href}`), {
        type: 'resource',
        tagName: (target as any).tagName,
        url: (target as any).src || (target as any).href,
      });
      return;
    }

    reportError(event.error ?? new Error(event.message), {
      type: 'js-runtime',
      filename: event.filename,
      lineno: event.lineno,
      colno: event.colno,
    });
  }, true);                       // ⭐ 用捕获阶段,才能拿到「资源加载失败」

  // ② 未处理的 Promise 拒绝
  window.addEventListener('unhandledrejection', (event) => {
    const reason = event.reason;
    const error = reason instanceof Error ? reason : new Error(String(reason));

    reportError(error, { type: 'unhandled-rejection' });
    // ⚠️ 是否调用 event.preventDefault() 取决于「是否要让控制台也报错」
  });

  // ③ (可选)把 React 的「已捕获但仍需上报」的错误也接上
}
// ⚠️ window.onerror 与 addEventListener('error') 的区别
window.onerror = (message, source, lineno, colno, error) => { /* ... */ };
// · 只能有一个(后者会覆盖前者)
// · 拿不到「资源加载失败」(因为没有 error 参数)

window.addEventListener('error', handler, true);
// · 可以有多个(不会被覆盖)
// · 用 true(捕获阶段)能拿到「资源加载失败」事件
// ✅ 推荐用 addEventListener
// ✅ 在应用入口挂上(并且只挂一次)
// main.tsx
setupGlobalErrorHandlers();

createRoot(root).render(
  <StrictMode>
    <ErrorBoundary onError={reportError}>
      <App />
    </StrictMode>
  </StrictMode>
);

四、上报策略

1) 上下文(最重要的部分)

interface ErrorContext {
  type: 'react-render' | 'js-runtime' | 'unhandled-rejection' | 'resource' | 'api';
  componentStack?: string;
  filename?: string;
  lineno?: number;
  colno?: number;
  // ⭐ 业务上下文(对排查至关重要)
  url?: string;
  route?: string;
  userId?: string;
  appVersion?: string;
  userAgent?: string;
  release?: string;
  // ⭐ 自定义(如最近的操作、接口请求 ID)
  breadcrumbs?: Array<{ time: number; action: string; detail?: unknown }>;
}
// ✅ 用「面包屑」记录「用户做了什么」(错误发生前的操作序列极有价值)
const breadcrumbs: Breadcrumb[] = [];

export function addBreadcrumb(action: string, detail?: unknown) {
  breadcrumbs.push({ time: Date.now(), action, detail });
  if (breadcrumbs.length > 50) breadcrumbs.shift();      // ⭐ 只保留最近 50 条
}

// 在关键操作里埋点
addBreadcrumb('navigate', { to: location.pathname });
addBreadcrumb('api', { url, method, status });
addBreadcrumb('click', { selector: 'submit-order' });

2) 去重与限流

const reported = new Map<string, number>();
const DEDUPE_WINDOW = 5000;                 // 5 秒内相同错误只报一次
const MAX_PER_MINUTE = 20;                  // 每分钟最多上报 20 条

let minuteCount = 0;
let minuteStart = Date.now();

export function reportError(error: Error, context: ErrorContext) {
  // ① 去重(用「消息 + 栈的前 N 行」做指纹)
  const fingerprint = `${error.name}:${error.message}:${(error.stack ?? '').split('\n')[1] ?? ''}`;
  const last = reported.get(fingerprint);
  if (last && Date.now() - last < DEDUPE_WINDOW) return;
  reported.set(fingerprint, Date.now());

  // ② 限流(防止「循环报错」把监控打爆)
  if (Date.now() - minuteStart > 60_000) {
    minuteStart = Date.now();
    minuteCount = 0;
  }
  if (++minuteCount > MAX_PER_MINUTE) return;

  // ③ 上报(见下)
  send(error, context);
}

3) 可靠上报

function send(error: Error, context: ErrorContext) {
  const payload = {
    name: error.name,
    message: error.message,
    stack: error.stack,
    ...context,
    breadcrumbs: [...breadcrumbs],
    timestamp: Date.now(),
    userAgent: navigator.userAgent,
  };

  const body = JSON.stringify(payload);

  // ⭐ 页面关闭时也能可靠发送
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/monitor/error', new Blob([body], { type: 'application/json' }));
    return;
  }

  // 兜底:fetch + keepalive(页面卸载时也能发出)
  fetch('/api/monitor/error', {
    method: 'POST',
    body,
    headers: { 'Content-Type': 'application/json' },
    keepalive: true,
  }).catch(() => { /* 上报失败静默 */ });
}
⭐ 为什么用 sendBeacon / keepalive?
   因为错误常发生在「页面即将卸载」时(用户关页面、跳转),
   普通的 fetch 会被「中断」→ 错误丢失。
   `sendBeacon` 由浏览器「尽力发送」(不阻塞卸载);
   `fetch(..., { keepalive: true })` 也能在页面卸载时完成请求(有体积限制,约 64KB)。

4) 源码映射(sourcemap)

问题:生产环境的 stack 是「压缩后的代码位置」
   at n.a (https://cdn.example.com/assets/index-4f3a.js:1:23456)
   → 完全无法定位

解法(三种):
① 把 sourcemap 上传到监控平台(Sentry 等支持),由平台「反解」
   → 前端只需要上报「文件名 + 行列号」,平台做映射
② 把 sourcemap 部署到「只有监控服务器能访问」的私有路径
   ⚠️ 不要把 sourcemap 公开部署(会暴露源码)
③ 开发/测试环境用 development 构建(stack 本身就是可读的)

⚠️ 关键:上报时要带「构建版本 / 文件名哈希」,
   否则「不同版本的同名文件」无法正确映射。

五、监控平台的能力(以 Sentry 为例)

// ✅ 平台会帮你做「聚合、去重、sourcemap、性能、会话回放」等
import * as Sentry from '@sentry/react';

Sentry.init({
  dsn: 'https://xxx@o123.ingest.sentry.io/456',
  environment: import.meta.env.MODE,
  release: __APP_VERSION__,                       // ⭐ 版本号(用于 sourcemap 与回归判断)
  tracesSampleRate: 0.1,                          // 性能追踪采样率
  replaysSessionSampleRate: 0.01,
  replaysOnErrorSampleRate: 1.0,                  // ⭐ 出错时会话回放 100%(最有用)
  beforeSend(event) {
    // 过滤掉「已知噪音」(如浏览器扩展导致的错误)
    if (event.exception?.values?.[0]?.value?.includes('ResizeObserver loop')) return null;
    return event;
  },
  ignoreErrors: [
    'ResizeObserver loop limit exceeded',         // 常见噪音
    'Non-Error promise rejection captured',
    /Loading chunk \d+ failed/,                   // 发版导致的 chunk 404(可用自动刷新处理)
  ],
});

// 手动上报
Sentry.captureException(error, { extra: { context } });

// 接入 React(自动包 ErrorBoundary 并上报 componentStack)
<Sentry.ErrorBoundary fallback={<ErrorView />}>
  <App />
</Sentry.ErrorBoundary>;
⭐ 平台的核心价值(自己搭要写很多):
① 聚合(相同错误归为一条)与「影响用户数」
② sourcemap 自动反解(看到可读堆栈)
③ 「面包屑」与「会话回放」(复现问题)
④ 版本对比(「这个版本新增了哪些错误」)
⑤ 告警(错误量突增 → 通知到群)
⑥ 性能监控(慢接口、慢页面)

⚠️ 隐私与合规:
   上报前要「脱敏」——不要把 token、手机号、身份证、完整请求体原样上报。

六、常见坑

// ① 只接了 ErrorBoundary(事件处理/异步/资源的错误全丢)
// ② 只接 window.onerror(拿不到资源加载失败、拿不到 Promise 拒绝)
// ③ 用 onerror 而不是 addEventListener(被覆盖、拿不到资源错误)
window.onerror = ...;                             // ⚠️ 后面又被别的库覆盖
window.addEventListener('error', fn, true);       // ✅

// ④ 资源错误没在「捕获阶段」监听(拿不到事件)
window.addEventListener('error', fn);             // ⚠️ 缺少 true
window.addEventListener('error', fn, true);       // ✅

// ⑤ 不判断「资源错误 vs JS 错误」(把 <img> 404 当成 JS 崩溃)
// ⑥ 没有去重(循环报错把监控打爆)
// ⑦ 没有限流(错误风暴时上报请求本身变成 DDOS)
// ⑧ 上报用普通 fetch 且不带 keepalive(页面卸载时丢数据)
// ⑨ 没上传 sourcemap(线上堆栈完全不可读)
// ⑩ sourcemap 公开部署(泄露源码)——应该只给监控平台
// ⑪ 上报「未脱敏的敏感数据」(token、手机号、完整请求体)
// ⑫ 忽略了「第三方脚本 / 浏览器扩展」产生的噪音错误
// ⑬ 没有「版本号」(无法判断「是不是这次发版引入的」)
// ⑭ 把「用户主动取消的请求」也上报(AbortError 是噪音)
// ⑮ 在「错误处理器内部」再抛错(递归/死循环)
// ⑯ 没接「接口错误」(接口 4xx/5xx 是最常见的线上问题之一)
// ⑰ 没有「错误量监控与告警」(问题发生几天后才知道)
// ⑱ 在 SSR 中只接客户端监控(服务端错误要单独接)
// ⑭ 的写法:过滤掉「取消请求」与「已处理的错误」
function shouldReport(error: Error, context: ErrorContext) {
  if (error.name === 'AbortError') return false;              // 主动取消
  if (error.message.includes('Loading chunk')) return false;  // 由「自动刷新」处理
  if (error.message.includes('ResizeObserver loop')) return false;   // 浏览器噪音
  if (/extension:\/\//.test(error.stack ?? '')) return false; // 浏览器扩展
  return true;
}

七、面试延伸

  1. 「前端错误监控要接哪些?」
    四类叠加:① React 渲染错误——用 Error Boundary(componentDidCatch 里上报,带 componentStack);② 全局 JS 运行时错误——用 window.addEventListener('error', fn, true)(捕获阶段,能同时处理「JS 错误」与「资源加载失败」);③ 未处理的 Promise 拒绝——用 unhandledrejection 事件;④ 接口错误——在「请求层拦截器」里统一上报(带接口路径、方法、状态码)。此外最好用 Sentry 这类平台来获得「聚合、sourcemap 反解、面包屑、会话回放」等能力。

  2. 「window.onerror 和 addEventListener('error') 有什么区别?」
    四个差异:① 覆盖——window.onerror 只能有一个(会被后设置的覆盖),addEventListener 可以有多个;② 资源错误——window.onerror 拿不到资源加载失败;addEventListener 用「捕获阶段(true)」能拿到(此时 event.target 是那个 <img>/<script>);③ 参数——onerror 直接给 (message, source, lineno, colno, error),addEventListener 给一个「ErrorEvent」对象;④ 推荐——用 addEventListener('error', fn, true)(更完整、不会被覆盖)。

  3. 「怎么区分「JS 错误」和「资源加载失败」?」
    在 error 事件里判断「事件目标」:如果 event.target 是 window(或不存在 tagName),说明是「JS 运行时错误」(用 event.error 或 event.message);如果 event.target 是具体的 DOM 元素(有 tagName,如 IMG/SCRIPT/LINK),说明是「资源加载失败」(可以从 target.src/target.href 拿到失败的 URL)。注意必须用「捕获阶段」(第三个参数 true),否则资源错误事件不会冒泡到 window。

  4. 「为什么要用 sendBeacon?」
    因为错误常常发生在「页面即将卸载」的时刻(用户关闭页面、点击跳转)——此时用普通的 fetch/XHR 上报,请求会被浏览器中断,导致「最需要的那批错误」反而丢失。navigator.sendBeacon(url, data) 由浏览器「尽力发送」(不阻塞卸载流程),更适合这种场景;它不可用时可以用 fetch(url, { keepalive: true }) 作为兜底(注意体积限制约 64KB)。

  5. 「sourcemap 在生产环境怎么处理?」
    三个方案:① 上传到监控平台(Sentry 等支持 sourcemaps 上传),平台在展示时自动「反解」堆栈——前端只需上报文件名与行列号,这是主流做法;② 把 sourcemap 部署到「只有监控服务器能访问」的私有地址(前端不上传,由服务端拉取反解);③ 开发/测试环境用 development 构建(堆栈本身可读)。核心原则:不要公开部署 sourcemap(会暴露源码与注释),但一定要保留它用于排查;同时上报时必须带构建版本/文件名哈希,否则多版本同名文件无法正确映射。

  6. 「上报错误要注意什么?」
    五件事:① 上下文——带上「路由、用户 ID、应用版本、UA、componentStack、面包屑(最近的操作序列)」,这对复现至关重要;② 去重——用「错误名 + 消息 + 栈首行」做指纹,短时间内相同错误只报一次;③ 限流——限制「每分钟上报条数」(防止「循环报错」把监控打爆,甚至把上报接口变成 DDoS 源);④ 可靠上报——用 sendBeacon/keepalive(页面卸载也能发出);⑤ 脱敏——不要把 token、手机号、身份证、完整请求体原样上报(合规风险)。

  7. 「哪些错误应该被过滤掉?」
    四类噪音:① 主动取消的请求(AbortError / CanceledError)——这是我们自己的行为;② 发版导致的 chunk 加载失败(Loading chunk N failed)——通常由「自动刷新」处理(见 6.8);③ 浏览器/规范已知的噪音——如 ResizeObserver loop limit exceeded(Chrome 的常见告警,实际无害);④ 浏览器扩展注入的错误——堆栈里出现 extension://、chrome-extension:// 或明确来自扩展脚本的错误。不过滤会造成「告警疲劳」,导致真正的问题被淹没。

  8. 「怎么让上报的错误『能定位到代码』?」
    三个关键:① sourcemap 反解(见上)——这是「从压缩堆栈到源码」的唯一途径;② componentStack——React 错误特有的「组件链」信息,能定位到「哪个组件」而不是「哪一行 JS」;③ 面包屑 + 会话回放——记录「错误发生前用户做了什么」(点击、路由、接口),这对「无法稳定复现」的问题至关重要。此外要带「版本号」,否则无法判断「是不是这次发版引入的」。

一句话速记

错误监控要「四者叠加」:ErrorBoundary(渲染错误,上报 componentStack) + addEventListener('error', fn, true)(全局 JS 错误 + 资源加载失败,靠 event.target 区分) + unhandledrejection(未处理 Promise) + 请求层拦截器(接口错误);上报要做到「带上下文(路由/用户/版本/面包屑)、去重(指纹)、限流(防风暴)、sendBeacon/keepalive 可靠发送、脱敏」;sourcemap 一定要留给监控平台反解(但别公开部署),并带上版本号;过滤噪音(AbortError、chunk 加载失败、ResizeObserver loop、扩展注入)避免告警疲劳;生产实践直接用 Sentry(聚合 + sourcemap + 面包屑 + 会话回放 + 告警)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 8.6 未捕获错误的监控

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫