引言
前端应用日益复杂,线上错误不可避免。成熟的 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的优势
- 原生支持 keepalive:当keepalive:ture时允许请求在页面卸载后继续发送,而不会因为页面销毁而被取消。反观传统XHR无法做到页面关闭后可靠发送
- Promise 原生支持,代码更简洁,避免了回调炼狱
- fetch支持流式读取,适合处理大型响应体。而XHR只能一次性读取全部
- 更加好的上手体验,并且fetch的请求和响应对象更加标准化,易于拓展
指数退避
优势
- 避免重试风暴
- 减少无效的网络和 CPU 消耗
ts
if (attempt < this.config.maxRetryTimes) { // 判断是否超过重试次数
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
await this.fetchWithRetryAndCleanup(url, payload, batch, attempt + 1);
}
注意:退避不会堵塞主进程