从零打造生产级前端错误监控 SDK:架构设计与 Vue3 实践

引言

前端应用日益复杂,线上错误不可避免。成熟的 SaaS 监控服务(如 Sentry)功能强大,但存在数据安全、成本、定制化等限制。自研错误监控系统成为许多团队的选择。本文将手把手分享剖析一个生产级 Vue3 错误监控 SDK 的设计思路与核心实现,涵盖错误捕获、去重、上报、离线持久化、网络感知等关键模块,并剖析其中的技术难点与解决方案。

技术点

1. 错误捕获相关

  • window.addEventListener('error', handler, true)
    捕获阶段监听 JS 运行时错误和资源加载错误(如图片、脚本)。通过 event.target 区分错误类型。
  • window.addEventListener('unhandledrejection', handler)
    捕获未处理的 Promise 拒绝错误。
  • Vue 专属钩子
    app.config.errorHandler:捕获 Vue 组件内错误,可获取组件实例和生命周期信息。
    router.onError:捕获 Vue Router 路由跳转过程中的同步错误。

2. 环境信息采集

  • navigator.connection (或 mozConnection)
    获取网络类型(effectiveType)、下行速度(downlink)、RTT。
  • navigator.deviceMemory 和 navigator.hardwareConcurrency
    获取设备内存(GB)和 CPU 核心数。
  • navigator.userAgent 解析
    通过正则或 ua-parser-js 识别操作系统和浏览器。
  • window.screen
    获取屏幕分辨率。
  • navigator.language
    获取用户语言偏好。

3. 去重与指纹生成

  • 错误指纹算法
    组合错误类型、消息、堆栈第一行、文件名、行号、Vue 组件名等生成唯一字符串。
  • Map 数据结构
    存储指纹与最后上报时间的映射,实现时间窗口内的去重。

4. 可靠上报与网络策略

  • 队列与批量发送
    内存数组暂存错误,达到阈值或定时触发发送。
  • 并发锁(isFlushing 标志)
    防止多个 flush 同时操作队列。
  • fetch + keepalive
    发送 POST 请求,keepalive 允许页面关闭后继续发送。
  • navigator.sendBeacon
    页面卸载时使用,异步、不阻塞、高可靠性。
  • 重试机制与指数退避
    失败后等待 1000 * attempt 毫秒再重试,最多 3 次。
  • 网络状态监听
    online / offline 事件,动态启停定时器,离线时停止无效轮询。

5. 离线缓存

  • IndexedDB
    存储失败队列,异步、大容量。使用 keyPath: 'id' 作为主键。
  • 容量控制
    当存储条目超过上限(如 200)时,删除最旧的 1/4 数据(按 timestamp 排序)。
  • 精确删除
    维护 offlineReportIds Set,仅当服务器返回 200 时才删除对应离线记录。

6. 性能与资源管理

  • 定时器动态启停

    队列为空时自动停止 setInterval,有数据时启动,减少空闲开销。

  • destroy 方法

    移除所有全局事件监听、清除定时器、清空队列,避免内存泄漏。

  • 采样控制

    Math.random() > sampleRate 实现按比例上报,降低后端压力。

7. Vue3 集成技术

  • 插件模式

    提供 install 和 uninstall 方法,支持 app.use() 安装。

  • 全局属性注入

    app.config.globalProperties.$errorMonitor 暴露实例。

  • 依赖注入(Provide/Inject)

    通过 app.provide 提供实例,useErrorMonitor 函数供组合式 API 使用。

8. 异步与 Promise 处理

  • async/await 与 Promise

    处理 IndexedDB、fetch、重试等待等异步操作,确保时序正确。

  • setTimeout 包装成 Promise

    实现可等待的延迟,用于指数退避。

9. 数据序列化与传输

  • JSON.stringify

    将错误报告批量序列化为字符串。

  • Blob / FormData(可选)

    sendBeacon 支持发送多种数据格式。

10. 日志与调试

  • console.warn / console.error
    在关键节点输出日志,便于开发调试。

11. vite服务端验证

  • 引入自定义插件
    通过自定义vite插件开启express服务端接收数据,用于验证

整体架构

整个错误监控采用模块化设计,分为以下核心部分:

  • EnvironmentCollector:采集网络类型、设备内存、操作系统等环境快照。
  • DedupManager:基于错误指纹的短时去重,防止高频重复错误淹没后端。
  • OfflineStorage:封装 IndexedDB,实现离线数据的增删查及容量控制。
  • Reporter:核心上报器,管理内存队列、定时刷新、重试机制、网络状态感知。
  • VueErrorMonitor:整合上述模块,暴露插件安装方法和手动上报 API,并集成 Vue 特定的错误钩子。

接下来将逐一剖析 EnvironmentCollector、DedupManager、OfflineStorage、Reporter、VueErrorMonitor 五个模块的设计与实现,并展示它们如何协同构建一个可靠、高效的前端错误上报系统。

EnvironmentCollector:环境快照采集

在错误发生时,自动捕获用户当前的网络类型、设备内存、操作系统、浏览器等上下文信息,并作为错误报告的一部分持久化或上报,帮助开发者快速定位特定环境下的问题。

优点:简单可靠,实时性最强,性能开销极小(只是读取几个浏览器 API)。

ts 复制代码
// 定义环境信息类型
export interface Environment {
  network: {
    effectiveType ?: string,
    downlink?: number
    rtt?: number
    onLine: boolean
  },
  device: {
    memory?: number
    cpuCores?: number
    language: string
    screenWidth: number
    screenHeight: number
  }
  os: string
  browser: string
  userAgent: string
  timestamp: number
};

class EnvironmentCollector {
  static collect(): Environment {
    const network = this.getNetworkInfo()
    const device = this.getDeviceInfo()
    const ua = this.parseUA(navigator.userAgent)
    return {
      network,
      device,
      os: ua.os,
      browser: ua.browser,
      userAgent: navigator.userAgent,
      timestamp: Date.now(),
    }
  }

  private static getNetworkInfo() {
    let conn = (navigator as any).connection || (navigator as any).mozConnection;

    // console.log('getNetworkInfo', conn);
    return {
      effectiveType: conn?.effectiveType,
      downlink: conn?.downlink,
      rtt: conn?.rtt,
      onLine: navigator.onLine,
    }
  }

  private static getDeviceInfo() {
    const memory = (navigator as any).deviceMemory || (navigator as any).memory?.jsHeapSizeLimit
    return {
      memory: memory ? Math.round(memory) : undefined,
      cpuCores: navigator.hardwareConcurrency,
      language: navigator.language,
      screenWidth: window.screen.width,
      screenHeight: window.screen.height,
    }
  }

  private static parseUA(ua: string) {
    let os = 'Unknown'
    let browser = 'Unknown'
    if (/Windows NT/i.test(ua)) os = 'Windows'
    if (/Mac OS X/i.test(ua)) os = 'macOS'
    else if (/Android/i.test(ua)) os = 'Android'
    else if (/iPhone|iPad|iPod/i.test(ua)) os = 'iOS'
    if (/Edg/i.test(ua)) browser = 'Edge'
    else if (/Chrome/i.test(ua)) browser = 'Chrome'
    else if (/Safari/i.test(ua)) browser = 'Safari'
    else if (/Firefox/i.test(ua)) browser = 'Firefox'
    return { os, browser }
  }
}
  • 网络信息:通过 navigator.connection 获取 effectiveType、downlink、rtt,这些 API 在 Chrome 中支持良好;降级提供 navigator.onLine。

  • 设备信息:navigator.deviceMemory(单位 GB)、hardwareConcurrency(CPU 核心数)、window.screen 分辨率、navigator.language。

  • 操作系统/浏览器:从 UserAgent 正则匹配,虽然简单但覆盖主流环境。

  • 同步采集:collect 方法同步返回,在 VueErrorMonitor 构造函数中调用一次并缓存,后续所有错误复用同一份快照,避免重复采集。

如果上面采集的环境字段无法满足,可以自定义

DedupManager:短时去重引擎

防止同一错误在极短时间内(如循环中)反复上报,避免后端被请求风暴淹没,同时保证不同时段的相同错误仍能上报。

ts 复制代码
// 错误信息类型
export interface ErrorReport {
  id: string
  type: ErrorType
  message: string
  filename?: string
  lineno?: number
  colno?: number
  stack?: string
  url: string
  timestamp: number
  environment: Environment
  vueComponentName?: string  // Vue 组件名
  routerInfo?: { fullPath: string; name?: string } // 路由信息
  extra?: any
}

let getErrorFingerprint = (err: Partial<ErrorReport>) => {
  let { type, message, stack, filename, lineno, vueComponentName } = err;
  let stackLine = stack?.split('\n')[0] || ''
  return `${type}|${message}|${stackLine}|${filename || ''}|${lineno || ''}|${vueComponentName || ''}`
}

// 去重管理器
class DedupManager {
  private windowMs: number;
  private fingerprints: Map<string, number> = new Map();

  constructor(windowMs: number) {
    this.windowMs = windowMs;
  }

  shouldReport(error: Partial<ErrorReport>): boolean {
    let fp = getErrorFingerprint(error);

    let now = Date.now();
    let lastTime = this.fingerprints.get(fp);

    if (lastTime && now - lastTime < this.windowMs) {
      return false;
    }

    this.fingerprints.set(fp, now);

    // 清理过期指纹
    for (let [key, time] of this.fingerprints.entries()) {
      if (now - time > this.windowMs * 2) this.fingerprints.delete(key);
    }

    return true;
  }
}
  • 错误指纹:由 getErrorFingerprint 函数生成,组合了 type、message、堆栈第一行、filename、lineno、vueComponentName。堆栈第一行通常是最直接的出错位置,足以区分不同错误。

  • 存储结构:Map<fingerprint, lastReportTime>,O(1) 存取。

  • 窗口配置:默认 dedupWindowMs = 5000,窗口期内相同指纹返回 false,丢弃上报。

  • 自动清理:每次检查时顺便删除超过窗口两倍的旧指纹,防止 Map 无限膨胀。

OfflineStorage:离线持久化层

当网络不可靠或服务端故障时,将上报失败的错误持久化到 IndexedDB,待网络恢复后重新发送,并提供容量控制避免本地存储爆满。

ts 复制代码
// 离线缓存
class OfflineStorage {
  private dbName = 'VueErrorMonitorDB';
  private storeName = 'failedReports';
  private db: IDBDatabase | null = null;
  private initPromise: Promise<void>;
  private offlineMaxStoreNum: number = 200;

  constructor(storeNum: number) {
    this.offlineMaxStoreNum = storeNum;
    this.initPromise = this.initDB();
  }

  private initDB():Promise<void> {
    return new Promise((resovle, reject) => {
      let request = indexedDB.open(this.dbName, 1);
      request.onupgradeneeded = (event) => {
        let db = (event.target as IDBOpenDBRequest).result;

        // 判断仓库是否存在
        if (!db.objectStoreNames.contains(this.storeName)) {
          // 为数据库创建对象存储
          db.createObjectStore(this.storeName, { keyPath: 'id' });
        }
      }
      
      request.onsuccess = () => {
        this.db = request.result;
        resovle();
      }

      request.onerror = () =>  reject(request.error);
    })
  }

  // 增
  async save(report: ErrorReport): Promise<void> {
    await this.initPromise;
    
    if(!this.db) return;
    
    let all = await this.getAll();

    if(all.length >= this.offlineMaxStoreNum) {
      // 删除 1 / offlineMaxStoreNum 数量
      let toDelete = all.slice(0, Math.ceil(this.offlineMaxStoreNum / 4));

      for (let old of toDelete) {
        await this.remove(old.id);
      }
    }
    let transaction = this.db.transaction([this.storeName], 'readwrite');
    let store = transaction.objectStore(this.storeName);
    
    await new Promise<void>((resolve, reject) => {
      const request = store.put(report); // 进行新增和更新
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async getAll(): Promise<ErrorReport[]> {
    await this.initPromise;

    if (!this.db) return [];

    return new Promise((resolve) => {
      if (!this.db) return resolve([]);

      // 创建事务
      let transaction = this.db.transaction([this.storeName], 'readonly');
      let store = transaction.objectStore(this.storeName); // 获取对象仓库
      let request = store.getAll();
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => resolve([]);
    });
  }

  // 删
  async remove(reportId: string): Promise<void> {
    await this.initPromise;

    if (!this.db) return;
    let transaction = this.db.transaction([this.storeName], 'readwrite');
    let store = transaction.objectStore(this.storeName);
    
    await new Promise<void>((resolve, reject) => {
      const request = store.delete(reportId);
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  // 批量删
  async removeBatch(reports: ErrorReport[]): Promise<void> {
    for (let report of reports) {
      await this.remove(report.id);
    }
  }

  // 全删
  async clear(): Promise<void> {
    await this.initPromise

    if (!this.db) return;
    let transaction = this.db.transaction([this.storeName], 'readwrite');
    let store = transaction.objectStore(this.storeName);
    
    store.clear();
  }
}
  • IndexedDB 选型:容量大(>50MB)、异步、支持索引。使用 keyPath: 'id' 作为主键,id 由时间戳+随机数生成,天然有序。

  • 容量控制:当存储条数达到 offlineMaxStoreNum(默认 200)时,删除最旧的 1/4 数据(all.slice(0, 1/4)),防止无限增长。

  • 异步初始化:initDB 返回 Promise,所有方法先 await this.initPromise 确保数据库连接就绪。

注意:indexedDB的CURD都是异步操作,所以这里都加Promise

Reporter:上报调度核心

管理内存队列、控制发送时机、实现重试与指数退避、监听网络状态、协调离线存储、处理页面关闭时的可靠发送。

ts 复制代码
// 定义错误联合类型 
export type ErrorType = 'js' | 'promise' | 'resource' | 'vue' | 'router';

export interface MonitorConfig {
  reportUrl: string
  appId: string
  offlineMaxStoreNum?: number
  dedupWindowMs?: number
  maxQueueSize?: number
  flushInterval?: number
  maxRetryTimes?: number
  enableResourceError?: boolean
  enableVueError?: boolean
  enableRouterError?: boolean
  sampleRate?: number
  router?: Router
};

// 上报器
class Reporter {
  private queue: ErrorReport[] = []; // 错误队列
  private config: Required<MonitorConfig>;
  private timer: number | null = null;
  private offlineStorage: OfflineStorage;
  private offlineReportIds: Set<string> = new Set(); // 记录哪些报告是从离线存储恢复的
  private isFlushing: boolean = false; // 并发锁标志
  private isOnline: boolean = navigator.onLine; // 记录当前网络状态
  private boundOnline: () => void;
  private boundOffline: () => void;
  private boundBeforeUnload: () => void;
  private boundVisibilityChange: () => void;

  constructor(config: MonitorConfig) {
    this.config = {
      dedupWindowMs: 5000,
      maxQueueSize: 20,
      flushInterval: 3000,
      maxRetryTimes: 3,
      enableResourceError: true,
      enableVueError: true,
      enableRouterError: false,
      sampleRate: 1,
      ...config
    } as Required<MonitorConfig>;
    this.offlineStorage = new OfflineStorage(this.config.offlineMaxStoreNum);
    this.boundOnline = this.handleOnline.bind(this);
    this.boundOffline = this.handleOffline.bind(this);
    this.boundBeforeUnload = this.handleBeforeUnload.bind(this);
    this.boundVisibilityChange = this.handleVisibilityChange.bind(this);
    this.startTimer();
    this.bindUnload();
    this.restoreOffline();
    this.bindNetworkEvents();
  }

  add (error: ErrorReport): void {
    // 控制采样比
    if (Math.random() > this.config.sampleRate) return;
    
    // 限制内存队列大小,超出则丢弃最旧的
    if(this.queue.length >= this.config.maxQueueSize) {
      this.queue.shift();
    }

    let wasEmpty = this.queue.length === 0;

    this.queue.push(error);

    if (wasEmpty && this.isOnline) {
      this.startTimer(); // 启动定时器
    }
    
    // 达到一半就尝试发送
    if (this.queue.length >= this.config.maxQueueSize / 2) {
      this.flush();
    }
  }

  private handleOnline = async () => {
    this.isOnline = true;
    console.warn('[ErrorMonitor] 网络已恢复,尝试重发离线数据');
    await this.restoreOffline();
    
    if (this.queue.length > 0) {
      this.startTimer();
    }
  };

   private handleOffline = () => {
    this.isOnline = false;
    console.warn('[ErrorMonitor] 网络断开,停止定时器');
    this.stopTimer();
  }

  private bindNetworkEvents() {
    window.addEventListener('online', this.boundOnline);
    window.addEventListener('offline', this.boundOffline);
  }

  private handleBeforeUnload = () => {
    if (this.queue.length) this.flush(true);
  }

  private handleVisibilityChange = () => {
    if (document.visibilityState === 'hidden') this.flush(true);
  }

  private bindUnload() {
    // 监听页面关闭
    window.addEventListener('beforeunload', this.boundBeforeUnload);

    // 监听内容是否可见或被隐藏
    window.addEventListener('visibilitychange', this.boundVisibilityChange)
  }

  private startTimer() {
    if (this.timer) return;
    if (!this.isOnline) return; // 仅当在线时才启动定时器
    if (this.queue.length === 0) return;

    this.timer = window.setInterval(() => this.flush(), this.config.flushInterval);
  }

  private stopTimer() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }

  private async restoreOffline() {
    let offlineReports = await this.offlineStorage.getAll();

    if (offlineReports.length) {
      for (let report of offlineReports) {
        this.offlineReportIds.add(report.id); // 记录id
        this.queue.push(report);
      }
      
      await this.drain(); // 主动排空队列
    }
  }

  // 持续发送队列中的所有数据,直到队列为空
  private async drain() {
    await this.flush(); // 发送一批并等待完成
  }

  async flush(immediate = false) {
    // 如果已经在刷新中,直接返回
    if (this.isFlushing) return;
    this.isFlushing = true;

    try {
      if (this.queue.length === 0) return;
      while (this.queue.length > 0) {
        let batch = this.queue.splice(0, this.config.maxQueueSize);
        await this.sendBatch(batch, immediate);
      }
    } finally {
      this.isFlushing = false;

      // 队列已空,停止定时器
      if (this.queue.length === 0) {
        this.stopTimer();
      }
    }
  }

  private async sendBatch(batch: ErrorReport[], immediate: boolean) {
    let payload = JSON.stringify(batch);
    let url = this.config.reportUrl;

    if (immediate && navigator.sendBeacon) {
      let success = navigator.sendBeacon(url, payload);

      if (!success) { // 用fetch 再发一遍
        await this.fetchWithRetryAndCleanup(url, payload, batch, 1);
      }

      return;
    }

    await this.fetchWithRetryAndCleanup(url, payload, batch, 1);
  }

  private async fetchWithRetryAndCleanup(
    url: string,
    payload: string,
    batch: ErrorReport[],
    attempt: number
  ) {
    try {
      let response = await fetch(url, {
        method: 'POST',
        body: payload,
        headers: {
          'Content-Type': 'application/json'
        },
        keepalive: true
      });

      if (response.ok) {// 对于不能再离线缓存中的数据删除会报错吗?
        await this.removeOfflineIfPresent(batch);
      } else {
        throw new Error(`HTTP ${response.status}`);
      }
    } catch(err){
      console.warn('[ErrorMonitor] 上报失败,重试', attempt);
      if (attempt < this.config.maxRetryTimes) { // 判断是否超过重试次数
        await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
        await this.fetchWithRetryAndCleanup(url, payload, batch, attempt + 1);
      } else { // 离线缓存
        for (let report of batch) {
          await this.offlineStorage.save(report);
          this.offlineReportIds.add(report.id); // 标记为离线存储中的报告
        }
      }
    }
  }

  // 删除 batch 中哪些真正存在于离线存储中的报告
  private async removeOfflineIfPresent(batch: ErrorReport[]) {
    let toDeleteIds = batch
      .filter(report => this.offlineReportIds.has(report.id))
      .map(report => report.id);
    
    if(toDeleteIds.length === 0) return;

    for (const id of toDeleteIds) {
      await this.offlineStorage.remove(id);
      this.offlineReportIds.delete(id);
    }
  }

  destroy() {
    this.stopTimer();
    window.removeEventListener('online', this.boundOnline);
    window.removeEventListener('offline', this.boundOffline);
    window.removeEventListener('beforeunload', this.boundBeforeUnload);
    window.removeEventListener('visibilitychange', this.boundVisibilityChange);
    this.queue = [];
  }
}

关键方法分析

add(error):入队与达标触发发送

ts 复制代码
  add (error: ErrorReport): void {
    // 控制采样比
    if (Math.random() > this.config.sampleRate) return;
    
    // 限制内存队列大小,超出则丢弃最旧的
    if(this.queue.length >= this.config.maxQueueSize) {
      this.queue.shift();
    }

    let wasEmpty = this.queue.length === 0;

    this.queue.push(error);
		
		// 队列从空变为非空
    if (wasEmpty && this.isOnline) {
      this.startTimer(); // 启动定时器
    }
    
    // 达到一半就尝试发送
    if (this.queue.length >= this.config.maxQueueSize / 2) {
      this.flush();
    }
  }
  • 采样:sampleRate 控制上报比例。

  • 定时器启停:队列为空时定时器自动停止;有新错误且队列之前为空时启动。

  • 阈值发送:队列长度达到 maxQueueSize/2(默认 10)触发 flush,应对高频错误。

flush(immediate):并发控制与循环发送

ts 复制代码
  async flush(immediate = false) {
    // 如果已经在刷新中,直接返回
    if (this.isFlushing) return;
    this.isFlushing = true;

    try {
      if (this.queue.length === 0) return;
      while (this.queue.length > 0) {
        let batch = this.queue.splice(0, this.config.maxQueueSize);
        await this.sendBatch(batch, immediate);
      }
    } finally {
      this.isFlushing = false;

      // 队列已空,停止定时器
      if (this.queue.length === 0) {
        this.stopTimer();
      }
    }
  }
  • 互斥锁:isFlushing 防止多个 flush 同时执行。

  • 循环清空:一次 flush 会持续发送直到队列为空,这保证了 drain 只需调用一次即可清空所有离线数据。

  • 定时器停止:队列空后停止定时器,避免空闲轮询。

sendBatch(batch, immediate):选择发送方式

ts 复制代码
  private async sendBatch(batch: ErrorReport[], immediate: boolean) {
    let payload = JSON.stringify(batch);
    let url = this.config.reportUrl;

    if (immediate && navigator.sendBeacon) {
      let success = navigator.sendBeacon(url, payload);

      if (!success) { // 用fetch 再发一遍
        await this.fetchWithRetryAndCleanup(url, payload, batch, 1);
      }

      return;
    }

    await this.fetchWithRetryAndCleanup(url, payload, batch, 1);
  }
  • 立即模式-页面关闭场景优先使用 navigator.sendBeacon,它不阻塞页面且浏览器保证尽力发送。

  • 正常模式使用 fetch + keepalive: true,允许页面关闭后继续发送。

fetchWithRetryAndCleanup:重试与离线回退

ts 复制代码
  private async fetchWithRetryAndCleanup(
    url: string,
    payload: string,
    batch: ErrorReport[],
    attempt: number
  ) {
    try {
      let response = await fetch(url, {
        method: 'POST',
        body: payload,
        headers: {
          'Content-Type': 'application/json'
        },
        keepalive: true
      });

      if (response.ok) {// 对于不能再离线缓存中的数据删除会报错吗?
        await this.removeOfflineIfPresent(batch);
      } else {
        throw new Error(`HTTP ${response.status}`);
      }
    } catch(err){
      console.warn('[ErrorMonitor] 上报失败,重试', attempt);
      if (attempt < this.config.maxRetryTimes) { // 判断是否超过重试次数
        await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
        await this.fetchWithRetryAndCleanup(url, payload, batch, attempt + 1);
      } else { // 离线缓存
        for (let report of batch) {
          await this.offlineStorage.save(report);
          this.offlineReportIds.add(report.id); // 标记为离线存储中的报告
        }
      }
    }
  }
  • 指数退避:重试延迟为 1000 * attempt ms,最多 maxRetryTimes 次。
  • 离线回退:重试耗尽后将报告存入 IndexedDB,并记录 offlineReportIds。
  • 成功清理:response.ok 时调用 removeOfflineIfPresent 只删除真正存在于离线存储中的记录。

网络状态感知与定时器动态管理

ts 复制代码
private handleOnline = async () => {
  this.isOnline = true;
  await this.restoreOffline();   // 读取离线数据并发送
  if (this.queue.length > 0) this.startTimer();
};
private handleOffline = () => {
  this.isOnline = false;
  this.stopTimer();
};
private startTimer() {
  if (this.timer) return;
  if (!this.isOnline) return;
  if (this.queue.length === 0) return;
  this.timer = setInterval(() => this.flush(), this.config.flushInterval);
}
  • 离线停止:offline 事件清除定时器,避免无效轮询。

  • 在线恢复:online 事件先调用 restoreOffline 加载离线数据,再启动定时器。

页面关闭的最后保障

ts 复制代码
private handleBeforeUnload = () => { if (this.queue.length) this.flush(true); };
private handleVisibilityChange = () => { if (document.visibilityState === 'hidden') this.flush(true); };
  • 在 beforeunload 和页面隐藏时强制调用 flush(true),使用 Beacon 发送所有剩余数据,绕过锁和批次限制,是防止数据丢失的最后防线。

注意:这里有一个未解决的问题:当已经在flushing的时候页面关闭后 再调用this.flush(true)是生效

destroy:资源清理

ts 复制代码
destroy() {
  this.stopTimer();
  window.removeEventListener('online', this.boundOnline);
  window.removeEventListener('offline', this.boundOffline);
  window.removeEventListener('beforeunload', this.boundBeforeUnload);
  window.removeEventListener('visibilitychange', this.boundVisibilityChange);
  this.queue = [];
}

VueErrorMonitor:框架集成与入口

整合上述所有模块,提供 Vue 插件安装方式,自动配置 Vue 特定的错误钩子(errorHandler、router.onError),暴露手动上报 API,并提供组合式函数 useErrorMonitor。

ts 复制代码
function parseStackInfo(error: Error) {
    if (!error || !error.stack) return null;

    // 取堆栈第二行(第一行是错误消息,第二行通常是调用位置)
    const stackLines = error.stack.split('\n');
    // 跳过第一行 "Error: ...",找到第一个包含文件路径的行
    let targetLine = null;
    for (let i = 1; i < stackLines.length; i++) {
        const line = stackLines[i];
        // 匹配包含 .js/.ts/.vue 等扩展名和行号的行
        if (line.match(/\.(js|ts|vue|mjs|cjs|jsx|tsx):\d+:\d+/)) {
            targetLine = line;
            break;
        }
    }
    if (!targetLine) return null;

    // 兼容多种格式:
    // Chrome:   at methodName (http://localhost:3000/file.vue:10:5)
    // Firefox:  methodName@http://localhost:3000/file.vue:10:5
    // Safari:   methodName@http://localhost:3000/file.vue:10:5
    // 简化:提取 协议://...文件路径:行号:列号
    const match = targetLine.match(/(?:https?|file):\/\/[^\s)]+\.(?:js|ts|vue|mjs|cjs|jsx|tsx):(\d+):(\d+)/);
    if (match) {
        // 同时提取完整的文件 URL(不含行号列号)
        const urlMatch = targetLine.match(/(https?|file):\/\/[^\s)]+\.(?:js|ts|vue|mjs|cjs|jsx|tsx)/);
        return {
            fileName: urlMatch ? urlMatch[0] : 'unknown',
            line: parseInt(match[1], 10),
            column: parseInt(match[2], 10),
        };
    }

    // 后备:使用更宽松的匹配(针对非标准环境)
    const fallbackMatch = targetLine.match(/[^\s]+:(\d+):(\d+)/);
    if (fallbackMatch) {
        return {
            fileName: fallbackMatch[0].replace(/:\d+:\d+$/, ''),
            line: parseInt(fallbackMatch[1], 10),
            column: parseInt(fallbackMatch[2], 10),
        };
    }

    return null;
}
export class VueErrorMonitor {
  private reporter: Reporter;
  private dedup: DedupManager;
  private config: MonitorConfig;
  private router?: Router;
  private errorHandler: (event: ErrorEvent) => void;
  private rejectionHandler: (event: PromiseRejectionEvent) => void;

  constructor(config: MonitorConfig) {
    this.config = config;
    this.reporter = new Reporter(config);
    this.dedup = new DedupManager(config.dedupWindowMs || 5000);
    this.router = config.router;
    this.errorHandler = this.handleError.bind(this);
    this.rejectionHandler = this.handleRejection.bind(this);
    this.initGlobalCapture();
  }

  private handleError(event: ErrorEvent) { 
    const target = event.target as HTMLElement;
      
    if (target && (target as any).src) {
      if (!this.config.enableResourceError) return;
      this.capture({
        type: 'resource',
        message: `Failed to load: ${(target as any).src}`,
        filename: (target as any).src,
      });
    } else {
      this.capture({
        type: 'js',
        message: event.message,
        filename: event.filename,
        colno: event.colno,
        lineno: event.lineno,
        stack: event.error?.stack
      })
    }
  }
  private handleRejection(event: PromiseRejectionEvent) { 
    let reason = event.reason;
      this.capture({
        type: 'promise',
        message: reason?.message || String(reason),
        stack: reason?.stack
      });
  }

  private initGlobalCapture() {
    // js 运行时错误 && 资源加载错误
    window.addEventListener('error', this.errorHandler, true);

    window.addEventListener('unhandledrejection', this.rejectionHandler)
  }

  private getCurrentEnvironment(): Environment {
    return EnvironmentCollector.collect();
  }

  // 捕获 vue 组件错误
  captureVueError(err: unknown, instance: any, info: string) {
    const componentName = instance?.$options?.name || instance?.$options?.__name || 'Anonymous';
    let parsedInfo = null;

    if (err instanceof Error) {
      parsedInfo = parseStackInfo(err)
    }

    this.capture({
      type: 'vue',
      message: err instanceof Error ? err.message :  String(err),
      stack: err instanceof Error ? err.stack : undefined,
      vueComponentName: componentName,
      lineno: parsedInfo?.line,
      colno: parsedInfo?.column,
      extra: { info }
    });
  }

  // 捕获路由错误
  captureRouterError(err: unknown, router: Router) {
    this.capture({
      type: 'router',
      message: err instanceof Error ? err.message : String(err),
      stack: err instanceof Error ? err.stack : undefined,
      routerInfo: {
        fullPath: router.currentRoute.value.fullPath,
        name: router.currentRoute.value.name as string | undefined,
      },
    })
  }

  private async capture(err: Partial<ErrorReport>) {
    if (!this.dedup.shouldReport(err)) return;

    let environment = this.getCurrentEnvironment();
    console.log('点这里了', environment);
    const report: ErrorReport = {
      id: generateId(),
      type: err.type!,
      message: err.message || 'Unknown error',
      filename: err.filename,
      lineno: err.lineno,
      colno: err.colno,
      stack: err.stack,
      url: location.href,
      timestamp: Date.now(),
      environment,
      vueComponentName: err.vueComponentName,
      routerInfo: err.routerInfo || (this.router ? {
        fullPath: this.router.currentRoute.value.fullPath,
        name: this.router.currentRoute.value.name as string | undefined,
      } : undefined),
    }

    this.reporter.add(report);
  }

  // 手动上报自定义错误
  public reportCustom(message: string, extra?: Record<string, any>) {
    this.capture({
      type: 'js',
      message,
      extra
    })
  }

  destroy() {
    if (this.errorHandler) {
      window.removeEventListener('error', this.errorHandler, true);
    }
    if (this.rejectionHandler) {
      window.removeEventListener('unhandledrejection', this.rejectionHandler);
    }
    this.reporter.destroy();
  }
}

// vue3 插件
export default {
  install(app: App, options: MonitorConfig) {
    const monitor = new VueErrorMonitor(options);

    app.config.globalProperties.$errorMonitor = monitor;

    // 捕获 vue 组件错误
    if (options.enableVueError !== false) {
      // 全局错误处理
      app.config.errorHandler = (err, instance, info) => {
        monitor.captureVueError(err, instance, info);
        console.error('[Vue Error]', err, info);
      }
    }

    // 捕获路由错误
    if (options.enableRouterError && options.router) {
      let router = options.router;
      router.onError((error) => {
        monitor.captureRouterError(error, router);
        console.error('[Vue Router Error]', error, router);
      });
    }

    app.provide('errorMonitor', monitor);
  },
  uninstall(app: App) {
    let monitor = app.config.globalProperties.$errorMonitor;

    if (monitor) {
      monitor.destroy();
    }
    delete app.config.globalProperties.$errorMonitor;
  }
}

export function useErrorMonitor() {
  let monitor = inject<VueErrorMonitor>('errorMonitor')
  
  if (!monitor) {
    throw new Error('useErrorMonitor must be used after installing the plugin')
  }

  return monitor
}

const router = createRouter();
createApp(App)
  .use(router)
  .use(ErrorMonitor, {
    reportUrl: '/api/report-error',
    appId: 'kinghiee',
    offlineMaxStoreNum: 30,
    dedupWindowMs: 1000,
    flushInterval: 3000,
    maxRetryTimes: 3,
    enableResourceError: true,
    enableVueError: true,      // 捕获 Vue 组件错误
    enableRouterError: true,   // 捕获路由错误
    sampleRate: 1.0,
    router,  
  }).mount('#app')

关键方法分析

全局错误捕获

ts 复制代码
private initGlobalCapture() {
  window.addEventListener('error', this.errorHandler, true);
  window.addEventListener('unhandledrejection', this.rejectionHandler);
}
private handleError(event: ErrorEvent) {
  const target = event.target as HTMLElement;
  if (target && (target as any).src) {
    // 资源加载错误(图片、脚本等)
    this.capture({ type: 'resource', message: `Failed to load: ${(target as any).src}`, filename: (target as any).src });
  } else {
    // JS 运行时错误
    this.capture({ type: 'js', message: event.message, filename: event.filename, colno: event.colno, lineno: event.lineno, stack: event.error?.stack });
  }
}
private handleRejection(event: PromiseRejectionEvent) {
  this.capture({ type: 'promise', message: event.reason?.message || String(event.reason), stack: event.reason?.stack });
}
  • 使用捕获阶段(true)确保资源错误能被监听到。

  • 通过 event.target.src 区分资源错误和 JS 错误。

Vue 组件错误捕获

ts 复制代码
captureVueError(err: unknown, instance: any, info: string) {
  const componentName = instance?.$options?.name || instance?.$options?.__name || 'Anonymous';
  let parsedInfo = null;
  if (err instanceof Error) {
    parsedInfo = parseStackInfo(err);   // 从堆栈中提取行列号
  }
  this.capture({
    type: 'vue',
    message: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack : undefined,
    vueComponentName: componentName,
    lineno: parsedInfo?.line,
    colno: parsedInfo?.column,
    extra: { info }
  });
}
  • 组件名:从 instance.$options.name 或 __name 获取。

  • 堆栈解析:利用 parseStackInfo 函数从错误堆栈中提取文件名、行号、列号,弥补 Vue 错误处理器不提供行列号的缺陷。

  • parseStackInfo 支持 Chrome、Firefox、Safari 的堆栈格式,提取第一个包含 .js/.ts/.vue 的行,解析出行列号。

路由错误捕获

ts 复制代码
captureRouterError(err: unknown, router: Router) {
  this.capture({
    type: 'router',
    message: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack : undefined,
    routerInfo: { fullPath: router.currentRoute.value.fullPath, name: router.currentRoute.value.name },
  });
}
// 在插件中注册
router.onError((error) => monitor.captureRouterError(error, router));

环境快照获取

ts 复制代码
  private getCurrentEnvironment(): Environment {
    return EnvironmentCollector.collect();
  }
private async capture(err: Partial<ErrorReport>) {
	let environment = this.getCurrentEnvironment();
  const report = { ...err, environment ... };
  //...
  this.reporter.add(report);
}

插件化与资源清理

ts 复制代码
export default {
  install(app: App, options: MonitorConfig) {
    const monitor = new VueErrorMonitor(options);
    app.config.globalProperties.$errorMonitor = monitor;
    app.provide('errorMonitor', monitor);
    // 设置 errorHandler 和 router.onError
  },
  uninstall(app: App) {
    const monitor = app.config.globalProperties.$errorMonitor;
    monitor?.destroy();
    delete app.config.globalProperties.$errorMonitor;
  }
};
export function useErrorMonitor() { return inject('errorMonitor'); }

后端错误收集服务与 Vite 插件集成分析

一个轻量级的错误收集服务端,用于接收前端上报的错误数据并存储到本地 JSON 文件中,同时以 Vite 插件的形式挂载到开发服务器上,方便开发调试。

整体流程

ts 复制代码
前端 SDK → POST /api/report-error → Express 中间件 → 读取/写入 errors.json → 响应

实现

ts 复制代码
import express from 'express';
import type { Request, Response } from 'express';
import fs from 'fs/promises';
import path from 'path';
import type { ViteDevServer } from 'vite';
import type { ErrorReport } from '@src/utils/monitor'

const app = express();
const LOG_FILE = path.resolve(__dirname, 'errors.json');

// 解析 JSON 请求体
app.use(express.json());

// 错误上报接口
app.post('/api/report-error', async (req: Request<{}, {}, ErrorReport[]>, res: Response) => {
  try {

    // 读取现有错误(若文件不存在则初始化空数组)
    let errors = [];
    try {
      const data = await fs.readFile(LOG_FILE, 'utf8');
      errors = JSON.parse(data);
    } catch (err) {
      // 文件不存在或内容非法,使用空数组
    }

    errors.push(...req.body);
    await fs.writeFile(LOG_FILE, JSON.stringify(errors, null, 2));

    res.status(200).json({ success: true });
  } catch (err) {
    console.error('写入错误日志失败:', err);
    res.status(500).json({ success: false, error: '服务器内部错误' });
  }
});

// 可选:查看所有错误(开发调试用)
app.get('/api/errors', async (req: Request<{}, {}, ErrorReport[]>, res: Response) => {
  try {
    const data = await fs.readFile(LOG_FILE, 'utf8');
    res.json(JSON.parse(data));
  } catch {
    res.json([]);
  }
});

export default function expressPlugin() {
  return {
    name: 'error-report-plugin',
    configureServer(server: ViteDevServer) {
      // 将 Express 应用挂载到 Vite 的中间件链上
      server.middlewares.use(app);
      console.log('✅ 错误上报服务已挂载,监听 /api/report-error');
    },
  }
};

关键方法分析

错误上报接口 POST /api/report-error

ts 复制代码
app.post('/api/report-error', async (req: Request<{}, {}, ErrorReport[]>, res: Response) => {
  try {
    let errors = [];
    try {
      const data = await fs.readFile(LOG_FILE, 'utf8');
      errors = JSON.parse(data);
    } catch (err) {
      // 文件不存在或内容非法,使用空数组
    }
    errors.push(...req.body);
    await fs.writeFile(LOG_FILE, JSON.stringify(errors, null, 2));
    res.status(200).json({ success: true });
  } catch (err) {
    console.error('写入错误日志失败:', err);
    res.status(500).json({ success: false, error: '服务器内部错误' });
  }
});

Vite 插件集成

ts 复制代码
export default function expressPlugin() {
  return {
    name: 'error-report-plugin',
    configureServer(server: ViteDevServer) {
      server.middlewares.use(app);
      console.log('✅ 错误上报服务已挂载,监听 /api/report-error');
    },
  };
}

export default defineConfig({
  plugins: [
  		...
    expressPlugin()
  ],
  })
  • configureServer 钩子:在 Vite 开发服务器创建时调用。
  • server.middlewares.use(app):将 Express 应用作为 Connect 中间件挂载到 Vite 的中间件链上。这样所有请求(如 /api/report-error)都会被 Express 处理。
  • 无需单独启动 Express 服务器,与 Vite 共享同一个端口和进程。

效果


总体代码

ts 复制代码
/**
 * title: 前端错误上报监控
 */

import { inject, type App } from "vue";
import type { Router } from "vue-router";

// 定义错误联合类型 
export type ErrorType = 'js' | 'promise' | 'resource' | 'vue' | 'router';

// 定义环境信息类型
export interface Environment {
  network: {
    effectiveType ?: string,
    downlink?: number
    rtt?: number
    onLine: boolean
  },
  device: {
    memory?: number
    cpuCores?: number
    language: string
    screenWidth: number
    screenHeight: number
  }
  os: string
  browser: string
  userAgent: string
  timestamp: number
};

// 错误信息类型
export interface ErrorReport {
  id: string
  type: ErrorType
  message: string
  filename?: string
  lineno?: number
  colno?: number
  stack?: string
  url: string
  timestamp: number
  environment: Environment
  vueComponentName?: string  // Vue 组件名
  routerInfo?: { fullPath: string; name?: string } // 路由信息
  extra?: any
}

export interface MonitorConfig {
  reportUrl: string
  appId: string
  offlineMaxStoreNum?: number
  dedupWindowMs?: number
  maxQueueSize?: number
  flushInterval?: number
  maxRetryTimes?: number
  enableResourceError?: boolean
  enableVueError?: boolean
  enableRouterError?: boolean
  sampleRate?: number
  router?: Router
};

// 生成id
let generateId = (): string => {
  return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}

let getErrorFingerprint = (err: Partial<ErrorReport>) => {
  let { type, message, stack, filename, lineno, vueComponentName } = err;
  let stackLine = stack?.split('\n')[0] || ''
  return `${type}|${message}|${stackLine}|${filename || ''}|${lineno || ''}|${vueComponentName || ''}`
}

function parseStackInfo(error: Error) {
    if (!error || !error.stack) return null;

    // 取堆栈第二行(第一行是错误消息,第二行通常是调用位置)
    const stackLines = error.stack.split('\n');
    // 跳过第一行 "Error: ...",找到第一个包含文件路径的行
    let targetLine = null;
    for (let i = 1; i < stackLines.length; i++) {
        const line = stackLines[i];
        // 匹配包含 .js/.ts/.vue 等扩展名和行号的行
        if (line.match(/\.(js|ts|vue|mjs|cjs|jsx|tsx):\d+:\d+/)) {
            targetLine = line;
            break;
        }
    }
    if (!targetLine) return null;

    // 兼容多种格式:
    // Chrome:   at methodName (http://localhost:3000/file.vue:10:5)
    // Firefox:  methodName@http://localhost:3000/file.vue:10:5
    // Safari:   methodName@http://localhost:3000/file.vue:10:5
    // 简化:提取 协议://...文件路径:行号:列号
    const match = targetLine.match(/(?:https?|file):\/\/[^\s)]+\.(?:js|ts|vue|mjs|cjs|jsx|tsx):(\d+):(\d+)/);
    if (match) {
        // 同时提取完整的文件 URL(不含行号列号)
        const urlMatch = targetLine.match(/(https?|file):\/\/[^\s)]+\.(?:js|ts|vue|mjs|cjs|jsx|tsx)/);
        return {
            fileName: urlMatch ? urlMatch[0] : 'unknown',
            line: parseInt(match[1], 10),
            column: parseInt(match[2], 10),
        };
    }

    // 后备:使用更宽松的匹配(针对非标准环境)
    const fallbackMatch = targetLine.match(/[^\s]+:(\d+):(\d+)/);
    if (fallbackMatch) {
        return {
            fileName: fallbackMatch[0].replace(/:\d+:\d+$/, ''),
            line: parseInt(fallbackMatch[1], 10),
            column: parseInt(fallbackMatch[2], 10),
        };
    }

    return null;
}

// 环境信息采集
class EnvironmentCollector {
  static collect(): Environment {
    const network = this.getNetworkInfo()
    const device = this.getDeviceInfo()
    const ua = this.parseUA(navigator.userAgent)
    return {
      network,
      device,
      os: ua.os,
      browser: ua.browser,
      userAgent: navigator.userAgent,
      timestamp: Date.now(),
    }
  }

  private static getNetworkInfo() {
    let conn = (navigator as any).connection || (navigator as any).mozConnection;

    // console.log('getNetworkInfo', conn);
    return {
      effectiveType: conn?.effectiveType,
      downlink: conn?.downlink,
      rtt: conn?.rtt,
      onLine: navigator.onLine,
    }
  }

  private static getDeviceInfo() {
    const memory = (navigator as any).deviceMemory || (navigator as any).memory?.jsHeapSizeLimit
    return {
      memory: memory ? Math.round(memory) : undefined,
      cpuCores: navigator.hardwareConcurrency,
      language: navigator.language,
      screenWidth: window.screen.width,
      screenHeight: window.screen.height,
    }
  }

  private static parseUA(ua: string) {
    let os = 'Unknown'
    let browser = 'Unknown'
    if (/Windows NT/i.test(ua)) os = 'Windows'
    if (/Mac OS X/i.test(ua)) os = 'macOS'
    else if (/Android/i.test(ua)) os = 'Android'
    else if (/iPhone|iPad|iPod/i.test(ua)) os = 'iOS'
    if (/Edg/i.test(ua)) browser = 'Edge'
    else if (/Chrome/i.test(ua)) browser = 'Chrome'
    else if (/Safari/i.test(ua)) browser = 'Safari'
    else if (/Firefox/i.test(ua)) browser = 'Firefox'
    return { os, browser }
  }
}

// 离线缓存
class OfflineStorage {
  private dbName = 'VueErrorMonitorDB';
  private storeName = 'failedReports';
  private db: IDBDatabase | null = null;
  private initPromise: Promise<void>;
  private offlineMaxStoreNum: number = 200;

  constructor(storeNum: number) {
    this.offlineMaxStoreNum = storeNum;
    this.initPromise = this.initDB();
  }

  private initDB():Promise<void> {
    return new Promise((resovle, reject) => {
      let request = indexedDB.open(this.dbName, 1);
      request.onupgradeneeded = (event) => {
        let db = (event.target as IDBOpenDBRequest).result;

        // 判断仓库是否存在
        if (!db.objectStoreNames.contains(this.storeName)) {
          // 为数据库创建对象存储
          db.createObjectStore(this.storeName, { keyPath: 'id' });
        }
      }
      
      request.onsuccess = () => {
        this.db = request.result;
        resovle();
      }

      request.onerror = () =>  reject(request.error);
    })
  }

  // 增
  async save(report: ErrorReport): Promise<void> {
    await this.initPromise;
    
    if(!this.db) return;
    
    let all = await this.getAll();

    if(all.length >= this.offlineMaxStoreNum) {
      // 删除 1 / offlineMaxStoreNum 数量
      let toDelete = all.slice(0, Math.ceil(this.offlineMaxStoreNum / 4));

      for (let old of toDelete) {
        await this.remove(old.id);
      }
    }
    let transaction = this.db.transaction([this.storeName], 'readwrite');
    let store = transaction.objectStore(this.storeName);
    
    await new Promise<void>((resolve, reject) => {
      const request = store.put(report); // 进行新增和更新
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async getAll(): Promise<ErrorReport[]> {
    await this.initPromise;

    if (!this.db) return [];

    return new Promise((resolve) => {
      if (!this.db) return resolve([]);

      // 创建事务
      let transaction = this.db.transaction([this.storeName], 'readonly');
      let store = transaction.objectStore(this.storeName); // 获取对象仓库
      let request = store.getAll();
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => resolve([]);
    });
  }

  // 删
  async remove(reportId: string): Promise<void> {
    await this.initPromise;

    if (!this.db) return;
    let transaction = this.db.transaction([this.storeName], 'readwrite');
    let store = transaction.objectStore(this.storeName);
    
    await new Promise<void>((resolve, reject) => {
      const request = store.delete(reportId);
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  // 批量删
  async removeBatch(reports: ErrorReport[]): Promise<void> {
    for (let report of reports) {
      await this.remove(report.id);
    }
  }

  // 全删
  async clear(): Promise<void> {
    await this.initPromise

    if (!this.db) return;
    let transaction = this.db.transaction([this.storeName], 'readwrite');
    let store = transaction.objectStore(this.storeName);
    
    store.clear();
  }
}

// 去重管理器
class DedupManager {
  private windowMs: number;
  private fingerprints: Map<string, number> = new Map();

  constructor(windowMs: number) {
    this.windowMs = windowMs;
  }

  shouldReport(error: Partial<ErrorReport>): boolean {
    let fp = getErrorFingerprint(error);

    let now = Date.now();
    let lastTime = this.fingerprints.get(fp);

    if (lastTime && now - lastTime < this.windowMs) {
      return false;
    }

    this.fingerprints.set(fp, now);

    // 清理过期指纹
    for (let [key, time] of this.fingerprints.entries()) {
      if (now - time > this.windowMs * 2) this.fingerprints.delete(key);
    }

    return true;
  }
}

// 上报器
class Reporter {
  private queue: ErrorReport[] = []; // 错误队列
  private config: Required<MonitorConfig>;
  private timer: number | null = null;
  private offlineStorage: OfflineStorage;
  private offlineReportIds: Set<string> = new Set(); // 记录哪些报告是从离线存储恢复的
  private isFlushing: boolean = false; // 并发锁标志
  private isOnline: boolean = navigator.onLine; // 记录当前网络状态
  private boundOnline: () => void;
  private boundOffline: () => void;
  private boundBeforeUnload: () => void;
  private boundVisibilityChange: () => void;

  constructor(config: MonitorConfig) {
    this.config = {
      dedupWindowMs: 5000,
      maxQueueSize: 20,
      flushInterval: 3000,
      maxRetryTimes: 3,
      enableResourceError: true,
      enableVueError: true,
      enableRouterError: false,
      sampleRate: 1,
      ...config
    } as Required<MonitorConfig>;
    this.offlineStorage = new OfflineStorage(this.config.offlineMaxStoreNum);
    this.boundOnline = this.handleOnline.bind(this);
    this.boundOffline = this.handleOffline.bind(this);
    this.boundBeforeUnload = this.handleBeforeUnload.bind(this);
    this.boundVisibilityChange = this.handleVisibilityChange.bind(this);
    this.startTimer();
    this.bindUnload();
    this.restoreOffline();
    this.bindNetworkEvents();
  }

  add (error: ErrorReport): void {
    // 控制采样比
    if (Math.random() > this.config.sampleRate) return;
    
    // 限制内存队列大小,超出则丢弃最旧的
    if(this.queue.length >= this.config.maxQueueSize) {
      this.queue.shift();
    }

    let wasEmpty = this.queue.length === 0;

    this.queue.push(error);

    if (wasEmpty && this.isOnline) {
      this.startTimer(); // 启动定时器
    }
    
    // 达到一半就尝试发送
    if (this.queue.length >= this.config.maxQueueSize / 2) {
      this.flush();
    }
  }

  private handleOnline = async () => {
    this.isOnline = true;
    console.warn('[ErrorMonitor] 网络已恢复,尝试重发离线数据');
    await this.restoreOffline();
    
    if (this.queue.length > 0) {
      this.startTimer();
    }
  };

   private handleOffline = () => {
    this.isOnline = false;
    console.warn('[ErrorMonitor] 网络断开,停止定时器');
    this.stopTimer();
  }

  private bindNetworkEvents() {
    window.addEventListener('online', this.boundOnline);
    window.addEventListener('offline', this.boundOffline);
  }

  private handleBeforeUnload = () => {
    if (this.queue.length) this.flush(true);
  }

  private handleVisibilityChange = () => {
    if (document.visibilityState === 'hidden') this.flush(true);
  }

  private bindUnload() {
    // 监听页面关闭
    window.addEventListener('beforeunload', this.boundBeforeUnload);

    // 监听内容是否可见或被隐藏
    window.addEventListener('visibilitychange', this.boundVisibilityChange)
  }

  private startTimer() {
    if (this.timer) return;
    if (!this.isOnline) return; // 仅当在线时才启动定时器
    if (this.queue.length === 0) return;

    this.timer = window.setInterval(() => this.flush(), this.config.flushInterval);
  }

  private stopTimer() {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
  }

  private async restoreOffline() {
    let offlineReports = await this.offlineStorage.getAll();

    if (offlineReports.length) {
      for (let report of offlineReports) {
        this.offlineReportIds.add(report.id); // 记录id
        this.queue.push(report);
      }
      
      await this.drain(); // 主动排空队列
    }
  }

  // 持续发送队列中的所有数据,直到队列为空
  private async drain() {
    await this.flush(); // 发送一批并等待完成
  }

  async flush(immediate = false) {
    // 如果已经在刷新中,直接返回
    if (this.isFlushing) return;
    this.isFlushing = true;

    try {
      if (this.queue.length === 0) return;
      while (this.queue.length > 0) {
        let batch = this.queue.splice(0, this.config.maxQueueSize);
        await this.sendBatch(batch, immediate);
      }
    } finally {
      this.isFlushing = false;

      // 队列已空,停止定时器
      if (this.queue.length === 0) {
        this.stopTimer();
      }
    }
  }

  private async sendBatch(batch: ErrorReport[], immediate: boolean) {
    let payload = JSON.stringify(batch);
    let url = this.config.reportUrl;

    if (immediate && navigator.sendBeacon) {
      let success = navigator.sendBeacon(url, payload);

      if (!success) { // 用fetch 再发一遍
        await this.fetchWithRetryAndCleanup(url, payload, batch, 1);
      }

      return;
    }

    await this.fetchWithRetryAndCleanup(url, payload, batch, 1);
  }

  private async fetchWithRetryAndCleanup(
    url: string,
    payload: string,
    batch: ErrorReport[],
    attempt: number
  ) {
    try {
      let response = await fetch(url, {
        method: 'POST',
        body: payload,
        headers: {
          'Content-Type': 'application/json'
        },
        keepalive: true
      });

      if (response.ok) {// 对于不能再离线缓存中的数据删除会报错吗?
        await this.removeOfflineIfPresent(batch);
      } else {
        throw new Error(`HTTP ${response.status}`);
      }
    } catch(err){
      console.warn('[ErrorMonitor] 上报失败,重试', attempt);
      if (attempt < this.config.maxRetryTimes) { // 判断是否超过重试次数
        await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
        await this.fetchWithRetryAndCleanup(url, payload, batch, attempt + 1);
      } else { // 离线缓存
        for (let report of batch) {
          await this.offlineStorage.save(report);
          this.offlineReportIds.add(report.id); // 标记为离线存储中的报告
        }
      }
    }
  }

  // 删除 batch 中哪些真正存在于离线存储中的报告
  private async removeOfflineIfPresent(batch: ErrorReport[]) {
    let toDeleteIds = batch
      .filter(report => this.offlineReportIds.has(report.id))
      .map(report => report.id);
    
    if(toDeleteIds.length === 0) return;

    for (const id of toDeleteIds) {
      await this.offlineStorage.remove(id);
      this.offlineReportIds.delete(id);
    }
  }

  destroy() {
    this.stopTimer();
    window.removeEventListener('online', this.boundOnline);
    window.removeEventListener('offline', this.boundOffline);
    window.removeEventListener('beforeunload', this.boundBeforeUnload);
    window.removeEventListener('visibilitychange', this.boundVisibilityChange);
    this.queue = [];
  }
}

export class VueErrorMonitor {
  private reporter: Reporter;
  private dedup: DedupManager;
  private config: MonitorConfig;
  private router?: Router;
  private errorHandler: (event: ErrorEvent) => void;
  private rejectionHandler: (event: PromiseRejectionEvent) => void;

  constructor(config: MonitorConfig) {
    this.config = config;
    this.reporter = new Reporter(config);
    this.dedup = new DedupManager(config.dedupWindowMs || 5000);
    this.router = config.router;
    this.errorHandler = this.handleError.bind(this);
    this.rejectionHandler = this.handleRejection.bind(this);
    this.initGlobalCapture();
  }

  private handleError(event: ErrorEvent) { 
    const target = event.target as HTMLElement;
      
    if (target && (target as any).src) {
      if (!this.config.enableResourceError) return;
      this.capture({
        type: 'resource',
        message: `Failed to load: ${(target as any).src}`,
        filename: (target as any).src,
      });
    } else {
      this.capture({
        type: 'js',
        message: event.message,
        filename: event.filename,
        colno: event.colno,
        lineno: event.lineno,
        stack: event.error?.stack
      })
    }
  }
  private handleRejection(event: PromiseRejectionEvent) { 
    let reason = event.reason;
      this.capture({
        type: 'promise',
        message: reason?.message || String(reason),
        stack: reason?.stack
      });
  }

  private initGlobalCapture() {
    // js 运行时错误 && 资源加载错误
    window.addEventListener('error', this.errorHandler, true);

    window.addEventListener('unhandledrejection', this.rejectionHandler)
  }

  private getCurrentEnvironment(): Environment {
    return EnvironmentCollector.collect();
  }

  // 捕获 vue 组件错误
  captureVueError(err: unknown, instance: any, info: string) {
    const componentName = instance?.$options?.name || instance?.$options?.__name || 'Anonymous';
    let parsedInfo = null;

    if (err instanceof Error) {
      parsedInfo = parseStackInfo(err)
    }

    this.capture({
      type: 'vue',
      message: err instanceof Error ? err.message :  String(err),
      stack: err instanceof Error ? err.stack : undefined,
      vueComponentName: componentName,
      lineno: parsedInfo?.line,
      colno: parsedInfo?.column,
      extra: { info }
    });
  }

  // 捕获路由错误
  captureRouterError(err: unknown, router: Router) {
    this.capture({
      type: 'router',
      message: err instanceof Error ? err.message : String(err),
      stack: err instanceof Error ? err.stack : undefined,
      routerInfo: {
        fullPath: router.currentRoute.value.fullPath,
        name: router.currentRoute.value.name as string | undefined,
      },
    })
  }

  private async capture(err: Partial<ErrorReport>) {
    if (!this.dedup.shouldReport(err)) return;

    let environment = this.getCurrentEnvironment();
    console.log('点这里了', environment);
    const report: ErrorReport = {
      id: generateId(),
      type: err.type!,
      message: err.message || 'Unknown error',
      filename: err.filename,
      lineno: err.lineno,
      colno: err.colno,
      stack: err.stack,
      url: location.href,
      timestamp: Date.now(),
      environment,
      vueComponentName: err.vueComponentName,
      routerInfo: err.routerInfo || (this.router ? {
        fullPath: this.router.currentRoute.value.fullPath,
        name: this.router.currentRoute.value.name as string | undefined,
      } : undefined),
    }

    this.reporter.add(report);
  }

  // 手动上报自定义错误
  public reportCustom(message: string, extra?: Record<string, any>) {
    this.capture({
      type: 'js',
      message,
      extra
    })
  }

  destroy() {
    if (this.errorHandler) {
      window.removeEventListener('error', this.errorHandler, true);
    }
    if (this.rejectionHandler) {
      window.removeEventListener('unhandledrejection', this.rejectionHandler);
    }
    this.reporter.destroy();
  }
}

// vue3 插件
export default {
  install(app: App, options: MonitorConfig) {
    const monitor = new VueErrorMonitor(options);

    app.config.globalProperties.$errorMonitor = monitor;

    // 捕获 vue 组件错误
    if (options.enableVueError !== false) {
      // 全局错误处理
      app.config.errorHandler = (err, instance, info) => {
        monitor.captureVueError(err, instance, info);
        console.error('[Vue Error]', err, info);
      }
    }

    // 捕获路由错误
    if (options.enableRouterError && options.router) {
      let router = options.router;
      router.onError((error) => {
        monitor.captureRouterError(error, router);
        console.error('[Vue Router Error]', error, router);
      });
    }

    app.provide('errorMonitor', monitor);
  },
  uninstall(app: App) {
    let monitor = app.config.globalProperties.$errorMonitor;

    if (monitor) {
      monitor.destroy();
    }
    delete app.config.globalProperties.$errorMonitor;
  }
}

export function useErrorMonitor() {
  let monitor = inject<VueErrorMonitor>('errorMonitor')
  
  if (!monitor) {
    throw new Error('useErrorMonitor must be used after installing the plugin')
  }

  return monitor
}

技术方案选型

请求为什么使用fetch而不选择xhr?

fetch的优势

  1. 原生支持 keepalive:当keepalive:ture时允许请求在页面卸载后继续发送,而不会因为页面销毁而被取消。反观传统XHR无法做到页面关闭后可靠发送
  2. Promise 原生支持,代码更简洁,避免了回调炼狱
  3. fetch支持流式读取,适合处理大型响应体。而XHR只能一次性读取全部
  4. 更加好的上手体验,并且fetch的请求和响应对象更加标准化,易于拓展

指数退避

优势

  1. 避免重试风暴
  2. 减少无效的网络和 CPU 消耗
ts 复制代码
if (attempt < this.config.maxRetryTimes) { // 判断是否超过重试次数
        await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
        await this.fetchWithRetryAndCleanup(url, payload, batch, attempt + 1);
      } 

注意:退避不会堵塞主进程

相关推荐
小凡同志2 小时前
OpenSpec 手把手实战:从零跑通一个完整功能
前端·ai编程·claude
吴声子夜歌2 小时前
Vue3——元素样式绑定
前端·javascript·vue.js·es6
xingpanvip2 小时前
PHP+JS+CSS打造动态星盘计算器
javascript·css·php
San30.2 小时前
前端进阶:从浏览器渲染原理到网络请求全链路解析
前端·网络·网络请求·浏览器渲染机制
落魄江湖行2 小时前
基础篇八 Nuxt4 中间件进阶:请求拦截与权限校验
前端·typescript·nuxt4
chimooing2 小时前
Hermes与OpenClaw的技术碰撞:从JavaScript引擎优化到企业级数据采集的深度解析
开发语言·javascript·ecmascript
feng_you_ying_li2 小时前
C++11可变模板参数,包扩展,emplace系列和push系列的区别
前端·c++·算法
hashiqimiya2 小时前
npm查看依赖
前端·npm·node.js
guojb8242 小时前
当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球
vue.js·设计模式