前端错误监控与异常处理

Created on

线上出错永远是前端最头疼的事:用户反馈”页面白了”,你打开 F12 什么都没有;日志里有一行 Script error,无从下手。

建立一套完善的前端错误监控体系,可以让你在用户之前发现问题,并提供足够的上下文信息来快速定位根因。本文从错误分类、捕获方式、上报策略到 Source Map 还原,带你系统了解前端错误监控的全貌。

前端错误分类

1. JavaScript 运行时错误

// TypeError
null.toString(); // Cannot read properties of null

// ReferenceError
console.log(undeclared); // undeclared is not defined

// SyntaxError
JSON.parse("invalid"); // Unexpected token i

// RangeError
new Array(-1); // Invalid array length

2. Promise 未捕获异常

// 未 catch 的 Promise rejection
fetch("/api/data").then((res) => {
  throw new Error("处理失败"); // 没有 .catch(),变成 unhandledRejection
});

async function loadData() {
  const data = await fetchData(); // 可能抛出,但调用处没有 try/catch
}
loadData(); // unhandledRejection

3. 资源加载错误

<!-- 图片 404 -->
<img src="/not-found.png" />

<!-- 脚本加载失败 -->
<script src="https://cdn.example.com/lib.js"></script>

4. 跨域脚本错误(Script error)

当页面引入了来自其他域名的 JS,该脚本抛出错误时,出于安全原因,浏览器只会显示 Script error,不暴露具体信息。

5. 接口请求异常

HTTP 4xx/5xx、超时、网络断开等,通常在业务层捕获但容易遗漏统一记录。

错误捕获方式

try/catch:捕获同步错误

try {
  JSON.parse(rawData);
  processData(data);
} catch (error) {
  // error.name: 错误类型
  // error.message: 错误信息
  // error.stack: 调用栈
  reportError(error);
}

注意:try/catch 无法捕获异步错误(setTimeout 内的错误、Promise rejection)。

window.onerror:全局 JS 错误兜底

window.onerror = function (message, source, lineno, colno, error) {
  // message: 错误信息
  // source: 出错脚本 URL
  // lineno/colno: 行列号
  // error: Error 对象(含 stack)

  reportError({
    type: "js",
    message,
    source,
    position: `${lineno}:${colno}`,
    stack: error?.stack,
  });

  return true; // 返回 true 阻止错误向上传播(不显示在控制台)
};

addEventListener(‘error’):资源加载错误

window.onerror 无法捕获资源加载失败,需要用捕获阶段的事件监听:

window.addEventListener(
  "error",
  (event) => {
    const target = event.target;

    // 区分资源错误和 JS 错误
    if (
      target instanceof HTMLImageElement ||
      target instanceof HTMLScriptElement ||
      target instanceof HTMLLinkElement
    ) {
      reportError({
        type: "resource",
        tagName: target.tagName,
        src: target.src || target.href,
        message: `${target.tagName} 加载失败`,
      });
    }
  },
  true // ⚠️ 必须是捕获阶段
);

unhandledrejection:未处理的 Promise 异常

window.addEventListener("unhandledrejection", (event) => {
  // event.promise: 出问题的 Promise
  // event.reason: 拒绝原因(可能是 Error,也可能是字符串)

  const reason = event.reason;
  reportError({
    type: "promise",
    message: reason instanceof Error ? reason.message : String(reason),
    stack: reason instanceof Error ? reason.stack : undefined,
  });

  event.preventDefault(); // 阻止默认的控制台错误输出
});

跨域脚本:添加 crossorigin 属性

<!-- 允许跨域脚本暴露完整错误信息 -->
<script src="https://cdn.example.com/lib.js" crossorigin="anonymous"></script>

同时服务端需要返回 Access-Control-Allow-Origin: * 响应头。

封装统一的错误上报函数

const ErrorReporter = {
  // 错误队列(用于合并上报)
  queue: [],
  timer: null,

  // 上报单个错误
  report(error) {
    const errorInfo = {
      type: error.type || "unknown",
      message: error.message,
      stack: error.stack,
      // 用户和环境信息
      url: location.href,
      userAgent: navigator.userAgent,
      timestamp: Date.now(),
      // 用户 ID(如果已登录)
      userId: window.__USER_ID__ ?? "anonymous",
    };

    this.queue.push(errorInfo);
    this.flush();
  },

  // 批量上报(合并请求,减少接口压力)
  flush() {
    clearTimeout(this.timer);
    this.timer = setTimeout(() => {
      if (this.queue.length === 0) return;

      const errors = this.queue.splice(0); // 取出所有待上报数据

      // 优先用 sendBeacon(页面关闭时也能发送)
      if (navigator.sendBeacon) {
        navigator.sendBeacon("/api/errors", JSON.stringify(errors));
      } else {
        fetch("/api/errors", {
          method: "POST",
          body: JSON.stringify(errors),
          headers: { "Content-Type": "application/json" },
          keepalive: true, // 页面关闭后请求继续发送
        });
      }
    }, 1000); // 1秒内的错误合并一次上报
  },
};

为什么用 sendBeacon?

sendBeacon 是专为分析打点设计的 API,有两个关键优势:

  1. 页面关闭时依然能发出(XHR/fetch 在 unload 事件中经常被浏览器取消)
  2. 不阻塞页面卸载
// ❌ 页面关闭时可能发不出去
window.addEventListener("beforeunload", () => {
  fetch("/api/report", { method: "POST", body: data });
});

// ✅ 可靠发送
window.addEventListener("beforeunload", () => {
  navigator.sendBeacon("/api/report", data);
});

Source Map:还原压缩代码的真实位置

生产环境的 JS 经过压缩混淆,错误栈里的行列号是压缩后代码的位置,几乎无法定位。Source Map 能将压缩代码的位置映射回源码位置。

生成 Source Map

// vite.config.ts
export default defineConfig({
  build: {
    sourcemap: true, // 生成 .map 文件
    // 或 'hidden':生成 map 文件但不在 JS 里添加引用注释(推荐线上使用)
    // sourcemap: 'hidden',
  },
});

// webpack.config.js
module.exports = {
  devtool: "hidden-source-map", // 只生成 map 文件,不暴露给用户
};

安全提示:不要将 Source Map 文件部署到公开可访问的位置,否则源码会被任何人还原。推荐上传到监控平台(如 Sentry),或放在内网服务器。

服务端还原错误位置

// Node.js 服务端:使用 source-map 库还原位置
import { SourceMapConsumer } from "source-map";
import fs from "fs";

async function resolveError(errorInfo) {
  const { source, line, column } = errorInfo;

  // 找到对应的 .map 文件
  const mapFile = getMapFilePath(source);
  const rawSourceMap = JSON.parse(fs.readFileSync(mapFile, "utf-8"));

  await SourceMapConsumer.with(rawSourceMap, null, (consumer) => {
    const originalPosition = consumer.originalPositionFor({ line, column });

    console.log(`
      压缩后:${source}:${line}:${column}
      源码位置:${originalPosition.source}:${originalPosition.line}:${originalPosition.column}
      函数名:${originalPosition.name}
    `);
  });
}

推荐使用 Sentry

如果不想自建监控系统,Sentry 是最成熟的开源错误监控平台,几行代码接入,自动处理 Source Map 还原、错误聚合、告警通知。

npm install @sentry/react
// main.tsx
import * as Sentry from "@sentry/react";

Sentry.init({
  dsn: "https://[email protected]/xxx",
  environment: import.meta.env.MODE,
  // 采样率:只上报 10% 的正常请求(性能监控),错误始终上报
  tracesSampleRate: 0.1,
  // 关联 Source Map(需在 CI 中上传 map 文件到 Sentry)
  release: __APP_VERSION__,
});
// 主动捕获并上报错误(携带额外上下文)
try {
  await submitOrder(orderData);
} catch (error) {
  Sentry.withScope((scope) => {
    scope.setTag("feature", "checkout");
    scope.setExtra("orderData", orderData);
    Sentry.captureException(error);
  });
  showErrorToast("下单失败,请重试");
}

从零搭建简易监控 SDK

如果想自研,以下是一个功能完整的最小化监控 SDK:

class Monitor {
  constructor(options) {
    this.options = { endpoint: "/api/errors", sampleRate: 1, ...options };
    this.init();
  }

  init() {
    // JS 运行时错误
    window.onerror = (msg, src, line, col, error) => {
      this.capture({
        type: "js",
        message: msg,
        stack: error?.stack,
        source: `${src}:${line}:${col}`,
      });
    };

    // 未捕获的 Promise 异常
    window.addEventListener("unhandledrejection", (e) => {
      const reason = e.reason;
      this.capture({
        type: "promise",
        message: reason?.message ?? String(reason),
        stack: reason?.stack,
      });
    });

    // 资源加载失败
    window.addEventListener(
      "error",
      (e) => {
        const t = e.target;
        if (t instanceof HTMLElement && t !== window) {
          this.capture({
            type: "resource",
            tag: t.tagName,
            src: t.src || t.href,
          });
        }
      },
      true
    );
  }

  capture(error) {
    // 采样控制(减少上报量)
    if (Math.random() > this.options.sampleRate) return;

    const payload = {
      ...error,
      url: location.href,
      ts: Date.now(),
      ua: navigator.userAgent,
    };

    navigator.sendBeacon(this.options.endpoint, JSON.stringify(payload));
  }
}

// 初始化
new Monitor({
  endpoint: "https://monitor.example.com/collect",
  sampleRate: 0.5,
});

总结

一套完整的前端错误监控体系包含以下层次:

捕获层
├── try/catch       → 同步代码中主动捕获
├── window.onerror  → JS 运行时兜底
├── unhandledrejection → Promise 异常
└── addEventListener('error', ..., true) → 资源加载失败

上报层
├── 合并批量上报(减少请求次数)
├── sendBeacon(可靠发送)
└── 采样率控制(降低服务器压力)

分析层
├── Source Map 还原(定位真实源码位置)
├── 错误聚合(相同错误合并计数)
└── 告警通知(邮件/钉钉/Slack)

即使业务早期没有精力搭建完整的监控平台,至少要接入 Sentry 或类似工具做基础的错误收集——线上无感知的 bug 是最危险的 bug。