文章

错误监控深度解析

错误监控深度解析

一句话概括

前端错误监控通过捕获 JS 运行时异常、Promise reject、资源加载失败、网络请求错误等全方位错误类型,结合 SourceMap 进行源码级定位和还原,最终将错误栈、用户行为、环境信息聚合上报,构建从”发生→定位→复现→修复”的完整闭环。

1. 背景与意义

1.1 “用户反馈”是效率最低的 Bug 发现方式

在引入错误监控之前,前端团队发现 Bug 的典型路径是这样的:

1
2
3
4
5
6
用户遇到 Bug → 用户感到困惑 → 用户刷新页面(数据丢失)→ 用户感到愤怒
  → 用户写邮件或提交工单(耗时 5-10 分钟)→ 客服转交开发
  → 开发追问"有没有报错?"→ 用户说"我不知道"
  → 开发说"你能截个图吗?"→ 用户截图
  → 开发看到截图 → 猜了半天原因 → 找不到复现路径
  → 开发加 console.log → 上线 → 等用户再次出现

这个流程中,最致命的环节是:用户说”我不知道”。没有错误栈、没有环境信息、没有用户操作路径,开发者只能碰运气。

1.2 错误监控的经济价值

引入系统化错误监控后,Bug 的 MTTR(Mean Time to Resolve,平均修复时间)从几天甚至几周缩短到几十分钟

环节无监控有监控
问题发现用户主动报告 + 数小时/数天自动化告警 + 实时
复现问题猜测用户操作 + 尝试复现(30分钟-数小时)直接看错误栈 + SourceMap 还原(几分钟)
定位代码全局搜索关键词(10-30分钟)SourceMap 直接定位到源码文件和行号(秒级)
修复上线正常开发流程必要时直接热修复

1.3 错误监控的核心挑战

前端错误监控有四个独有的技术难点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
挑战1:源码混淆
  生产环境的 JS 是 minified 的(具体变量名被替换,代码被压缩到一行)
  看到的错误栈:at e(anonymous_3c@https://cdn.example.com/app.a1b2c3d4.js:1:12345)
  完全不知道对应开发环境的哪行代码
  
挑战2:跨域脚本
  CDN 上的第三方脚本出错时,浏览器默认不提供错误详情
  error.message = "Script error."(完全没用的信息)

挑战3:环境差异
  同一份代码在不同浏览器、不同设备、不同网络下表现完全不同
  用户说"页面白屏"→ 开发说"我这里好的,你清一下缓存" → 根本不是缓存问题

挑战4:错误瞬间即逝
  - onerror 捕获到的错误可能在任何时候发生
  - Unhandled Promise Rejection 可能来自第三方 SDK
  - 资源加载失败在低网速时频繁发生

2. 概念与定义

2.1 前端错误的四大类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 类型1: JS 运行时异常
// 捕获方式: window.onerror / window.addEventListener('error')
const error = new TypeError("Cannot read properties of undefined (reading 'name')");
// 包含: message, source, lineno, colno, error.stack

// 类型2: 未处理的 Promise 拒绝
// 捕获方式: window.onunhandledrejection
const promiseError = new Promise((_, reject) => {
  reject(new Error('API 请求失败'));
});
// 包含: promise, reason (通常是 Error 对象或 string)

// 类型3: 资源加载错误
// 捕获方式: window.addEventListener('error', ...) (注意是在捕获阶段)
// 图片加载失败: <img src="broken.jpg">
// 脚本加载失败: <script src="broken.js">
// CSS 加载失败: <link href="broken.css">
// 包含: target (出错的元素), src/href

// 类型4: HTTP 请求错误
// 捕获方式: 劫持 XMLHttpRequest / fetch
// 状态码 4xx / 5xx
// 网络错误 (断网、超时)

2.2 错误对象的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 标准的 Error 对象
{
  name: 'TypeError',           // 错误类型
  message: 'Cannot read...',   // 错误信息
  stack: `                    // 错误栈
    TypeError: Cannot read property 'name' of undefined
      at Object.getUserName (app.js:10:15)
      at HTMLButtonElement.onClick (app.js:25:5)
  `
}

// 通过 SourceMap 还原后的 stack
{ 
  source: 'app.js',             // 源文件名
  line: 10,                     // 源码行号
  column: 15,                   // 源码列号
  functionName: 'getUserName',  // 函数名
  original: 'app.tsx',          // TypeScript 源码文件
  originalLine: 12,             // TS 源码行号
  originalColumn: 8             // TS 源码列号
}

2.3 SourceMap 的工作原理

SourceMap 是一个 JSON 格式的映射文件,记录压缩/转译后的代码与源码之间的对应关系:

1
2
3
4
5
6
7
8
{
  "version": 3,
  "file": "app.a1b2c3d4.js",
  "sources": ["app.tsx", "utils.ts", "components/Header.tsx"],
  "sourcesContent": ["...原始源码...", "..."],
  "names": ["getUserName", "onClick", "render", ...],
  "mappings": "AAAA;AACA;AACA;...;qJAAqJ;..."
}

mappings 的 VLQ 编码原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mappings = "AAAA;AACA;AACA"

解码规则:
每个字母 = 5-bit VLQ 编码的 base64 字符

A=0, B=1, C=2, ..., Z=25
a=26, b=27, ..., z=51
0=52, 1=53, ..., 9=61
+=62, /=63

AAAA 解码:
A(0) A(0) A(0) A(0)
→ 代表:generatedLine: +0, generatedColumn: +0, sourceIndex: +0, originalLine: +0

映射关系:
压缩代码位置                  源码位置
line 1, col 0      ────→     app.tsx, line 1, col 0
line 2, col 0      ────→     app.tsx, line 2, col 0
...

3. 最小示例

3.1 完整的错误采集 SDK

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
// error-monitor.js
// 一个完整的前端错误监控 SDK 实现

(function(global) {
  'use strict';

  class ErrorMonitor {
    constructor(options = {}) {
      this.options = {
        endpoint: options.endpoint || '/api/error',  // 上报地址
        sampleRate: options.sampleRate || 1.0,        // 采样率
        maxBreadcrumbs: options.maxBreadcrumbs || 50, // 用户行为栈最大长度
        enabled: options.enabled !== false,
        appVersion: options.appVersion || '1.0.0',
        environment: options.environment || 'production',
      };

      this.breadcrumbs = [];          // 用户行为栈
      this.lastEventId = 0;           // 错误 ID 计数器
      this.requestQueue = [];         // 错误上报队列
      this.isReporting = false;
      this.sessionId = this.generateId('sess');

      if (this.options.enabled) {
        this.init();
      }
    }

    init() {
      // 1. 捕获 JS 运行时错误
      this.setupJSErrorHandler();

      // 2. 捕获未处理的 Promise 拒绝
      this.setupPromiseErrorHandler();

      // 3. 捕获资源加载错误
      this.setupResourceErrorHandler();

      // 4. 记录用户行为
      this.setupBreadcrumbs();

      // 5. 网络请求劫持
      this.setupNetworkInterceptor();

      // 6. 性能数据监听
      this.setupPerformanceObserver();

      // 7. 页面离开时强制上报
      this.setupFlushOnUnload();

      console.log('[ErrorMonitor] 初始化完成');
    }

    // ─── JS 运行时错误 ───
    setupJSErrorHandler() {
      // onerror 可以捕获大部分运行时错误
      global.onerror = (message, source, lineno, colno, error) => {
        // 过滤掉 "Script error."
        if (message === 'Script error.' || !source) {
          this.handleCrossOriginError();
          return true;
        }

        this.captureException(error || new Error(String(message)), {
          type: 'runtime',
          source,
          lineno,
          colno,
        });
        return true; // 阻止浏览器默认行为(如有)
      };

      // 使用 addEventListener 作为补充(可以捕获更多场景)
      global.addEventListener('error', (event) => {
        // 只处理 JS 错误,资源加载错误在单独的处理中
        if (event instanceof ErrorEvent) {
          // 已经被 onerror 处理了
          return;
        }
      }, true);
    }

    // ─── Promise 拒绝 ───
    setupPromiseErrorHandler() {
      global.addEventListener('unhandledrejection', (event) => {
        let error = event.reason;
        
        // 处理非 Error 类型的拒绝原因
        if (!(error instanceof Error)) {
          const reason = typeof error === 'string' ? error : JSON.stringify(error);
          error = new Error(`Unhandled Promise Rejection: ${reason}`);
          error.originalReason = event.reason;
        }

        this.captureException(error, {
          type: 'promise',
          subType: 'unhandledrejection'
        });

        // 防止默认处理
        event.preventDefault();
      });
    }

    // ─── 资源加载错误 ───
    setupResourceErrorHandler() {
      global.addEventListener('error', (event) => {
        // 检查是否是资源加载错误(target 是 DOM 元素)
        const target = event.target;
        if (target && (target instanceof HTMLScriptElement ||
                       target instanceof HTMLLinkElement ||
                       target instanceof HTMLImageElement ||
                       target instanceof HTMLVideoElement ||
                       target instanceof HTMLAudioElement)) {
          
          const resourceType = this.getResourceType(target);
          const sourceUrl = target.src || target.href;

          this.captureMessage(`Resource load failed: ${resourceType}`, {
            type: 'resource',
            level: 'error',
            subType: resourceType,
            url: sourceUrl,
            outerHTML: target.outerHTML?.substring(0, 200),
          });
        }
      }, true); // 必须在捕获阶段(冒泡阶段资源错误不会到达 window)
    }

    // ─── 用户行为记录 ───
    setupBreadcrumbs() {
      // 点击事件
      document.addEventListener('click', (event) => {
        const target = event.target;
        this.addBreadcrumb({
          type: 'user',
          category: 'click',
          message: this.getElementIdentifier(target),
          data: {
            tagName: target.tagName,
            id: target.id,
            className: target.className?.substring(0, 100),
            text: target.textContent?.substring(0, 50),
          }
        });
      }, true);

      // 导航变化(SPA 路由变化)
      let lastUrl = location.href;
      const observer = new MutationObserver(() => {
        const currentUrl = location.href;
        if (currentUrl !== lastUrl) {
          this.addBreadcrumb({
            type: 'navigation',
            category: 'route',
            message: `Route: ${lastUrl}${currentUrl}`,
            data: { from: lastUrl, to: currentUrl }
          });
          lastUrl = currentUrl;
        }
      });

      // 监听 pushState 和 replaceState
      const wrapHistoryMethod = (method) => {
        const original = global.history[method];
        return function() {
          this.addBreadcrumb({
            type: 'navigation',
            category: method === 'pushState' ? 'pushState' : 'replaceState',
            message: `History ${method}: ${arguments[2] || ''}`,
          });
          return original.apply(this, arguments);
        }.bind(this);
      };
      
      history.pushState = wrapHistoryMethod('pushState');
      history.replaceState = wrapHistoryMethod('replaceState');

      // XHR 和 Fetch 请求记录
      // (在 setupNetworkInterceptor 中实现)
    }

    // ─── 网络请求劫持 ───
    setupNetworkInterceptor() {
      // 劫持 XMLHttpRequest
      const OriginalXHR = global.XMLHttpRequest;
      const self = this;

      global.XMLHttpRequest = function() {
        const xhr = new OriginalXHR();
        const startTime = performance.now();
        let requestInfo = { method: '', url: '' };

        const originalOpen = xhr.open;
        xhr.open = function(method, url) {
          requestInfo.method = method;
          requestInfo.url = url;
          return originalOpen.apply(this, arguments);
        };

        const originalSend = xhr.send;
        xhr.send = function(body) {
          xhr.addEventListener('loadend', function() {
            const duration = performance.now() - startTime;
            if (xhr.status >= 400) {
              self.captureMessage(`HTTP ${xhr.status}: ${requestInfo.method} ${requestInfo.url}`, {
                type: 'http',
                level: xhr.status >= 500 ? 'error' : 'warning',
                subType: 'xhr',
                data: {
                  method: requestInfo.method,
                  url: requestInfo.url,
                  status: xhr.status,
                  statusText: xhr.statusText,
                  duration: Math.round(duration),
                  responseSubstring: xhr.responseText?.substring(0, 500),
                }
              });
            } else {
              self.addBreadcrumb({
                type: 'http',
                category: 'xhr',
                message: `${requestInfo.method} ${requestInfo.url}`,
                data: { status: xhr.status, duration }
              });
            }
          });
          return originalSend.apply(this, arguments);
        };

        return xhr;
      };
    }

    // ─── 核心错误上报 ───
    captureException(error, extra = {}) {
      const eventId = this.generateId('err');
      const timestamp = Date.now();

      const errorEvent = {
        eventId,
        timestamp,
        sessionId: this.sessionId,
        level: extra.level || 'error',
        type: extra.type || 'runtime',
        
        // 错误信息
        message: error.message,
        name: error.name,
        stack: error.stack,
        
        // 位置信息(SourceMap 还原用)
        fileName: extra.source || error.fileName,
        lineNumber: extra.lineno || error.lineNumber,
        columnNumber: extra.colno || error.columnNumber,
        
        // 附加信息
        extra,
        
        // 用户行为栈(快照)
        breadcrumbs: [...this.breadcrumbs],
        
        // 环境信息
        environment: this.options.environment,
        appVersion: this.options.appVersion,
        release: this.options.appVersion,
        
        // 浏览器环境
        userAgent: navigator.userAgent,
        url: location.href,
        referrer: document.referrer,
        
        // 设备信息
        screen: `${screen.width}x${screen.height}`,
        viewport: `${window.innerWidth}x${window.innerHeight}`,
        language: navigator.language,
        platform: navigator.platform,
        
        // 网络信息
        connection: navigator.connection?.effectiveType || 'unknown',
        online: navigator.onLine,
        
        // 时间信息
        timestamp,
        performance: {
          memory: performance.memory?.usedJSHeapSize,
          navigationType: performance.getEntriesByType('navigation')[0]?.type,
        }
      };

      // 采样控制
      if (Math.random() > this.options.sampleRate) {
        return eventId;
      }

      this.enqueue(errorEvent);
      return eventId;
    }

    captureMessage(message, extra = {}) {
      const error = new Error(message);
      this.captureException(error, {
        ...extra,
        level: extra.level || 'info',
      });
    }

    // ─── 上报队列 ───
    enqueue(event) {
      this.requestQueue.push(event);
      
      // 立即触发上报
      if (!this.isReporting) {
        this.flush();
      }
    }

    async flush() {
      if (this.requestQueue.length === 0) return;
      
      this.isReporting = true;
      const batch = this.requestQueue.splice(0, 10); // 每次最多上报 10 条

      try {
        const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
        
        if (navigator.sendBeacon) {
          navigator.sendBeacon(this.options.endpoint, blob);
        } else {
          await fetch(this.options.endpoint, {
            method: 'POST',
            body: blob,
            keepalive: true,
            headers: { 'Content-Type': 'application/json' }
          });
        }
      } catch (e) {
        // 上报失败,将错误重新入队
        this.requestQueue.unshift(...batch);
        console.warn('[ErrorMonitor] 上报失败:', e);
      } finally {
        this.isReporting = false;
        
        if (this.requestQueue.length > 0) {
          // 继续上报
          setTimeout(() => this.flush(), 1000);
        }
      }
    }

    setupFlushOnUnload() {
      global.addEventListener('beforeunload', () => {
        if (this.requestQueue.length > 0) {
          navigator.sendBeacon(this.options.endpoint, 
            new Blob([JSON.stringify(this.requestQueue)], { type: 'application/json' }));
        }
      });
    }

    // ─── 工具方法 ───
    addBreadcrumb(breadcrumb) {
      breadcrumb.timestamp = Date.now();
      this.breadcrumbs.push(breadcrumb);
      
      // 限制最大长度
      if (this.breadcrumbs.length > this.options.maxBreadcrumbs) {
        this.breadcrumbs.shift();
      }
    }

    getElementIdentifier(element) {
      if (!element || !element.tagName) return 'unknown';
      
      let selector = element.tagName.toLowerCase();
      if (element.id) selector += `#${element.id}`;
      if (element.className && typeof element.className === 'string') {
        selector += `.${element.className.trim().split(/\s+/).join('.')}`;
      }
      return selector;
    }

    getResourceType(element) {
      if (element instanceof HTMLScriptElement) return 'script';
      if (element instanceof HTMLLinkElement) return 'stylesheet';
      if (element instanceof HTMLImageElement) return 'image';
      if (element instanceof HTMLVideoElement) return 'video';
      if (element instanceof HTMLAudioElement) return 'audio';
      return 'unknown';
    }

    handleCrossOriginError() {
      // "Script error." 的处理方案
      // 需要在脚本的响应头中设置:
      // Access-Control-Allow-Origin: *
      // 并在 <script> 标签上添加 crossorigin="anonymous"
    }

    generateId(prefix) {
      return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
    }
  }

  // 暴露全局
  global.ErrorMonitor = ErrorMonitor;

  // 自动初始化
  new ErrorMonitor({
    endpoint: '/api/error-monitor',
    sampleRate: 1.0,
    environment: 'production',
    appVersion: '__APP_VERSION__'
  });

})(window);

3.2 服务端错误接收端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// error-server.js
// Express 错误受理服务

const express = require('express');
const app = express();

app.use(express.json({ limit: '1mb' }));

// 错误数据存储(生产环境使用数据库)
const errorDB = [];
const errorCount = new Map(); // 错误聚合 key → count

app.post('/api/error-monitor', (req, res) => {
  const batch = Array.isArray(req.body) ? req.body : [req.body];
  
  batch.forEach(errorEvent => {
    // 1. 存储原始记录
    errorDB.push(errorEvent);
    
    // 2. 错误聚合(按 message + 文件路径聚合)
    const key = `${errorEvent.name}:${errorEvent.message}@${errorEvent.fileName || 'unknown'}`;
    errorCount.set(key, (errorCount.get(key) || 0) + 1);
    
    // 3. 报警规则检查
    const count = errorCount.get(key);
    if (count === 1 || count === 10 || count === 100 || count % 1000 === 0) {
      triggerAlert(errorEvent, count);
    }
  });

  res.status(200).json({ success: true });
});

function triggerAlert(errorEvent, count) {
  console.error(`[ALERT] 错误 "${errorEvent.message}" 已发生 ${count} 次`);
  // 发送到 Slack / 钉钉 / 邮件
}

// SourceMap 上传端点
app.post('/api/sourcemap/upload', (req, res) => {
  // 接收 SourceMap 文件,关联 appVersion
  const { version, mapContent } = req.body;
  storeSourceMap(version, mapContent);
  res.json({ success: true });
});

app.listen(8000, () => {
  console.log('Error Monitor Server running on :8000');
});

4. 核心知识点拆解

4.1 JavaScript 异常捕获的完整方案

4.1.1 onerror vs addEventListener(‘error’)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 两种方式的区别

// 方式1: window.onerror(单次赋值)
window.onerror = function(message, source, lineno, colno, error) {
  console.log('onerror:', message);
  return true; // 阻止浏览器默认错误处理
};
// ⚠️ 只能有一个 handler
// ⚠️ 对于资源加载错误,获取不到 error 对象(浏览器兼容问题)

// 方式2: window.addEventListener(推荐)
window.addEventListener('error', function(event) {
  // 参数是一个 ErrorEvent 对象
  console.log('ErrorEvent:', {
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    error: event.error, // Error 对象
  });
});
// ✅ 可以有多个 handler
// ✅ 可以获得完整的 Error 对象
// ✅ 支持捕获阶段监听资源加载错误

// 最佳实践:两者都使用
window.onerror = function() { /* ... */ };
window.addEventListener('error', function() { /* ... */ });

4.1.2 try-catch 的局限性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// try-catch 只能捕获同步代码中的异常

// ✅ 同步代码:可以捕获
try {
  const data = JSON.parse('invalid json');
} catch (e) {
  console.log('JSON 解析错误:', e.message);
}

// ❌ 异步代码:不能捕获
try {
  setTimeout(() => {
    throw new Error('异步错误');
  }, 100);
} catch (e) {
  // 这里的 catch 永远不会执行 ⚠️
  // 错误在 setTimeout 回调中抛出,不在 try 的作用域链中
}

// ✅ 异步代码的正确处理方式
try {
  await asyncFunction(); // async/await 可以捕获 Promise 的 reject
} catch (e) {
  console.log('异步错误:', e);
}

// 或者
asyncFunction().catch(e => {
  console.log('异步错误:', e);
});

// setInterval/setTimeout 回调必须在其内部 try-catch
setTimeout(() => {
  try {
    dangerousOperation();
  } catch (e) {
    ErrorMonitor.captureException(e);
  }
}, 100);

4.2 SourceMap 还原深度解析

4.2.1 如何用 SourceMap 还原错误栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// sourcemap-reverse.js
// 使用 SourceMap 库将压缩代码的错误栈还原为源码错误栈

const SourceMapConsumer = require('source-map').SourceMapConsumer;
const fs = require('fs');
const path = require('path');

class StackTraceResolver {
  constructor(sourceMapDir) {
    this.sourceMaps = new Map(); // 文件名 → SourceMapConsumer
    
    // 延迟加载 SourceMap
    this.sourceMapDir = sourceMapDir;
  }

  // 加载某个 js 文件的 SourceMap
  async loadSourceMap(filename) {
    if (this.sourceMaps.has(filename)) return;
    
    // 文件名命名约定:app.a1b2c3d4.js → app.a1b2c3d4.js.map
    const mapPath = path.join(this.sourceMapDir, `${filename}.map`);
    
    if (!fs.existsSync(mapPath)) {
      console.warn(`SourceMap 未找到: ${mapPath}`);
      return;
    }

    const mapContent = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
    const consumer = await new SourceMapConsumer(mapContent);
    this.sourceMaps.set(filename, consumer);
  }

  // 还原单个栈帧
  resolveFrame(frame) {
    // frame 格式:at functionName (filename:line:column)
    const regex = /at\s+(?:\w+\s+)?\(?(.+):(\d+):(\d+)\)?/;
    const match = frame.match(regex);
    
    if (!match) return frame; // 无法解析

    const [, filename, line, column] = match;
    const fileBasename = path.basename(filename);
    
    // 查找对应的 SourceMap
    const consumer = this.findConsumer(fileBasename);
    if (!consumer) return frame;

    // 还原
    const original = consumer.originalPositionFor({
      line: parseInt(line),
      column: parseInt(column)
    });

    if (!original.source) return frame; // 无法还原

    // 获取原始源码行
    const sourceContent = consumer.sourceContentFor(original.source);
    const sourceLines = sourceContent.split('\n');
    const contextLine = sourceLines[original.line - 1] || '';
    
    return {
      original: `${original.source}:${original.line}:${original.column}`,
      functionName: original.name || '(anonymous)',
      context: contextLine.trim(),
      compiled: frame
    };
  }

  // 还原完整的错误栈
  async resolveStackTrace(stack, jsFilename) {
    // 先尝试加载对应 SourceMap
    await this.loadSourceMap(jsFilename);

    const frames = stack.split('\n').filter(line => line.includes('at '));
    
    return frames.map(frame => this.resolveFrame(frame));
  }

  findConsumer(basename) {
    // 文件名匹配逻辑:可能因为 cache buster 而有额外后缀
    for (const [key, consumer] of this.sourceMaps) {
      if (basename.startsWith(key) || key.startsWith(basename)) {
        return consumer;
      }
    }
    return null;
  }
}

// 使用示例
async function processError(errorEvent) {
  const resolver = new StackTraceResolver('/path/to/sourcemaps');
  
  // 从 error stack 中提取 js 文件名
  const jsFilename = errorEvent.fileName?.split('/').pop();
  
  if (errorEvent.stack && jsFilename) {
    const resolvedStack = await resolver.resolveStackTrace(
      errorEvent.stack,
      jsFilename
    );
    
    console.log('原始错误栈:', errorEvent.stack);
    console.log('还原后的错误栈:');
    resolvedStack.forEach(frame => {
      if (typeof frame === 'object') {
        console.log(`  at ${frame.functionName} (${frame.original})`);
        console.log(`    → ${frame.context}`);
      }
    });
  }
}

4.2.2 生产环境 SourceMap 的安全处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// SourceMap 在浏览器中的安全策略

// ❌ 不安全:SourceMap 随生产文件一起部署
// 浏览器的 DevTools 可以直接下载 SourceMap
// 任何人都可以看到源码

// ✅ 安全方案1:不部署 SourceMap 到 CDN
// 只在内部监控系统中保存 SourceMap
// 只在上报错误时由监控系统解析

// ✅ 安全方案2:限制 SourceMap 的访问
// nginx 配置
location ~* \.map$ {
  deny all;
  return 404;
}

// ✅ 安全方案3:使用内部 SourceMap 服务
// 监控服务收到错误后,调用内部 API 解析 SourceMap
// 不暴露给前端

// ✅ 最安全的方案:上传构建产物时的 SourceMap 映射
// 在 CI/CD 流程中
step: {
  name: 'Upload SourceMaps to Monitor',
  command: `
    curl -X POST https://monitor.internal/api/sourcemap/upload \
      -H "Authorization: Bearer $INTERNAL_TOKEN" \
      -F "version=$APP_VERSION" \
      -F "map=@dist/js/app.${HASH}.js.map"
  `
}

4.3 错误聚合与去重策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// 错误去重:同一类错误不应重复报警
// 需要使用"指纹"(Fingerprint)来聚合同类错误

function generateFingerprint(errorEvent) {
  // 策略:使用错误类型的组合作为指纹
  
  // 1. 按错误类型分类
  const type = errorEvent.type; // 'runtime' | 'promise' | 'resource' | 'http'
  
  // 2. 提取关键信息
  let key = '';
  
  if (errorEvent.stack) {
    // 取错误栈的前 3 帧(通常可以唯一标识一个 Bug)
    const frames = errorEvent.stack
      .split('\n')
      .filter(line => line.includes('at '))
      .slice(0, 3)
      .map(line => line.replace(/\d+/g, '<N>')) // 替换数字,忽略具体行号
      .join('|');
    key = `${type}:${errorEvent.name}:${frames}`;
  } else {
    // 没有 stack 时使用 message
    key = `${type}:${errorEvent.message}`;
  }
  
  // 3. 对 key 做 hash
  return hashString(key);
}

function hashString(str) {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = ((hash << 5) - hash) + char;
    hash |= 0; // 转换为 32-bit integer
  }
  return Math.abs(hash).toString(16);
}

// 服务端的聚合逻辑
// 同一指纹的错误在 xx 分钟内只告警一次
class ErrorAggregator {
  constructor() {
    this.alerts = new Map(); // fingerprint → { count, lastAlert }
    this.WINDOW_MS = 5 * 60 * 1000; // 5 分钟窗口
  }

  shouldAlert(errorEvent) {
    const fingerprint = generateFingerprint(errorEvent);
    const now = Date.now();
    
    const existing = this.alerts.get(fingerprint);
    
    if (!existing) {
      // 新错误,立即告警
      this.alerts.set(fingerprint, { 
        count: 1, 
        firstSeen: now,
        lastAlert: now 
      });
      return true;
    }
    
    existing.count++;
    
    // 检查距上次告警是否超过窗口期
    if (now - existing.lastAlert > this.WINDOW_MS) {
      existing.lastAlert = now;
      // 再次告警,附带累计次数
      return { alert: true, count: existing.count };
    }
    
    return false; // 在窗口期内,不重复告警
  }
}

4.4 跨域 Script 错误的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Chapter 4.4: 跨域错误处理
// "Script error." 的 4 个解决步骤

// 场景:CDN 上的第三方脚本出错时
// 浏览器默认不暴露错误详情(出于安全考虑)

// 第一步:CDN 上配置 CORS 头
// CDN 服务器的响应头中应包含:
// Access-Control-Allow-Origin: *

// 第二步:在 <script> 标签上添加 crossorigin 属性
<script src="https://cdn.example.com/lib.min.js" crossorigin="anonymous"></script>

// 第三步:确保服务端正确返回 CORS 头
// nginx 配置
location ~* \.js$ {
  add_header Access-Control-Allow-Origin *;
  add_header Access-Control-Allow-Methods GET;
  add_header Access-Control-Allow-Headers 'Content-Type';
}

// 第四步:验证效果
// 加上 crossorigin="anonymous" 后
// window.onerror 可以获取到完整的错误信息和错误栈
window.onerror = function(message, source, lineno, colno, error) {
  // ✅ 原来: "Script error." 
  // ✅ 现在: "Cannot read property 'foo' of undefined"
  // ✅ 伴随完整的错误栈和行列号
  console.log(message, error.stack);
};

// 如果使用第三方 CDN 脚本无法控制响应头
// 考虑以下方案:

// 方案A:自托管脚本
// 将脚本下载到自己的 CDN 上

// 方案B:使用 try-catch 包裹关键第三方调用
try {
  someThirdPartyLib.doSomething();
} catch (e) {
  // 至少可以获取到包装后的错误信息
  ErrorMonitor.captureException(e);
}

// 方案C:使用 importScripts 的 Worker
// 在 Web Worker 中运行的第三方脚本不会影响主线程

5. 实战案例

案例一:集成 React Error Boundary + 错误监控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';

interface ErrorBoundaryProps {
  children: ReactNode;
  fallback?: ReactNode;
  onError?: (error: Error, errorInfo: ErrorInfo) => void;
  componentName?: string;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    // 上报错误到监控系统
    const errorReport = {
      type: 'react_error_boundary',
      component: this.props.componentName || 'Unknown',
      error: {
        name: error.name,
        message: error.message,
        stack: error.stack,
      },
      componentStack: errorInfo.componentStack,
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent,
    };

    // 上报
    this.reportError(errorReport);

    // 调用外部 onError 回调
    this.props.onError?.(error, errorInfo);
  }

  async reportError(errorReport: any) {
    try {
      await fetch('/api/error-monitor', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(errorReport),
      });
    } catch (e) {
      // 错误上报本身失败,使用 sendBeacon 兜底
      const blob = new Blob([JSON.stringify(errorReport)], { type: 'application/json' });
      navigator.sendBeacon?.('/api/error-monitor', blob);
    }
  }

  render() {
    if (this.state.hasError) {
      // 自定义错误 UI
      if (this.props.fallback) {
        return this.props.fallback;
      }

      // 默认错误界面
      return (
        <div style={{
          padding: '20px',
          margin: '10px 0',
          border: '1px solid #ff4444',
          borderRadius: '8px',
          background: '#fff0f0'
        }}>
          <h3 style={{ color: '#cc0000', margin: '0 0 10px' }}>
            组件渲染出错
          </h3>
          <details>
            <summary style={{ cursor: 'pointer', color: '#666' }}>
              查看错误详情
            </summary>
            <pre style={{
              marginTop: '10px',
              padding: '10px',
              background: '#f5f5f5',
              borderRadius: '4px',
              overflow: 'auto',
              fontSize: '12px',
              maxHeight: '200px'
            }}>
              {this.state.error?.stack}
            </pre>
          </details>
          <button
            onClick={() => {
              this.setState({ hasError: false, error: null });
              window.location.reload();
            }}
            style={{
              marginTop: '10px',
              padding: '8px 16px',
              background: '#0070f3',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer'
            }}>
            重新加载
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// 使用示例
function App() {
  return (
    <ErrorBoundary
      componentName="App"
      onError={(error, errorInfo) => {
        console.error('App 崩溃:', error);
      }}
    >
      <ErrorBoundary componentName="Header" fallback={<FallbackHeader />}>
        <Header />
      </ErrorBoundary>
      
      <ErrorBoundary componentName="MainContent">
        <MainContent />
      </ErrorBoundary>
      
      <ErrorBoundary componentName="Footer" fallback={null}>
        <Footer />
      </ErrorBoundary>
    </ErrorBoundary>
  );
}

function FallbackHeader() {
  return (
    <header style={{ padding: '20px', background: '#f0f0f0', textAlign: 'center' }}>
      ⚠️ 导航栏加载失败
    </header>
  );
}

案例二:生产环境 SourceMap 自动化流水线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# .github/workflows/upload-sourcemaps.yml
# GitHub Actions: 构建后自动上传 SourceMap

name: Upload Sourcemaps
on:
  push:
    branches: [main, release/**]

jobs:
  build-and-upload:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      
      # 1. 构建
      - run: npm ci
      - run: npm run build
        env:
          NODE_ENV: production
      
      # 2. 上传 SourceMap 到监控服务(内部网络)
      - name: Upload SourceMaps
        run: |
          # 遍历所有 .js.map 文件
          for mapfile in dist/static/js/*.map; do
            # 提取对应的 js 文件名
            jsfile="${mapfile%.map}"
            version=$(node -e "console.log(require('./package.json').version)")
            
            # 上传到内部 SourceMap 服务
            curl -X POST https://monitor-internal.example.com/api/v1/sourcemaps \
              -H "Authorization: Bearer ${{ secrets.MONITOR_API_TOKEN }}" \
              -H "Content-Type: multipart/form-data" \
              -F "version=${{ github.sha }}" \
              -F "release=$version" \
              -F "map=@$mapfile" \
              -F "js=@$jsfile"
              
            echo "已上传: $jsfile"
          done
      
      # 3. 删除 SourceMap 后再上传到 CDN
      - name: Remove SourceMaps before deploying
        run: rm -rf dist/static/js/*.map
      
      # 4. 部署到 CDN
      - name: Deploy to CDN
        run: |
          # 正常部署到 CDN 的步骤...

案例三:网络请求错误的自动重试与降级

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// retry-with-reporting.js
// 对失败的请求进行自动重试 + 错误报告

class ResilientFetcher {
  constructor(options = {}) {
    this.options = {
      maxRetries: 3,
      baseDelay: 1000,     // 初始延迟 1s
      maxDelay: 10000,     // 最大延迟 10s
      timeout: 30000,      // 请求超时 30s
      ...options
    };
  }

  async fetch(url, options = {}) {
    let lastError = null;
    const startTime = performance.now();

    for (let attempt = 0; attempt <= this.options.maxRetries; attempt++) {
      try {
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), this.options.timeout);

        const response = await fetch(url, {
          ...options,
          signal: controller.signal,
        });

        clearTimeout(timeoutId);

        // 429 Too Many Requests / 503 Service Unavailable 可以重试
        if (response.status === 429 || response.status === 503) {
          if (attempt < this.options.maxRetries) {
            await this.wait(attempt);
            continue;
          }
        }

        // 其他 4xx 错误不重试
        if (!response.ok && response.status < 500) {
          this.reportError(url, options, response.status, attempt);
          return response; // 返回给调用方处理
        }

        if (!response.ok) {
          // 5xx 错误重试
          if (attempt < this.options.maxRetries) {
            await this.wait(attempt);
            continue;
          }
        }

        return response;

      } catch (error) {
        lastError = error;
        
        // 网络错误、超时可以重试
        if (error.name === 'AbortError' || 
            error.name === 'TypeError' ||  // 网络断开
            error.message === 'Failed to fetch') {
          if (attempt < this.options.maxRetries) {
            await this.wait(attempt);
            continue;
          }
        }

        // 其他错误直接上报
        break;
      }
    }

    // 所有重试都失败,上报错误
    const duration = performance.now() - startTime;
    this.reportError(url, options, 0, this.options.maxRetries, {
      lastError: lastError?.message,
      totalDuration: Math.round(duration),
    });

    throw lastError || new Error(`Request failed after ${this.options.maxRetries} retries`);
  }

  async wait(attempt) {
    // 指数退避 + 随机抖动
    const delay = Math.min(
      this.options.baseDelay * Math.pow(2, attempt),
      this.options.maxDelay
    );
    const jitter = delay * 0.5 * Math.random();
    return new Promise(resolve => setTimeout(resolve, delay + jitter));
  }

  reportError(url, options, status, retries, extra = {}) {
    const errorReport = {
      type: 'http',
      level: 'error',
      subType: 'fetch',
      message: `HTTP ${status || 'NETWORK_ERROR'}: ${options.method || 'GET'} ${url}`,
      data: {
        url,
        method: options.method || 'GET',
        status,
        retries,
        ...extra,
        timestamp: Date.now(),
      }
    };

    // 使用 sendBeacon 上报(不阻塞主线程)
    const blob = new Blob([JSON.stringify(errorReport)], { type: 'application/json' });
    navigator.sendBeacon?.('/api/error-monitor', blob);
  }
}

// 使用示例
const api = new ResilientFetcher({
  maxRetries: 2,
  baseDelay: 500,
});

async function getUser(id) {
  try {
    const response = await api.fetch(`/api/users/${id}`, {
      method: 'GET',
      headers: { 'Accept': 'application/json' },
    });
    
    if (!response.ok) {
      // 4xx 错误由调用方处理
      const error = await response.json();
      showToast(error.message || '请求失败');
      return null;
    }
    
    return await response.json();
  } catch (error) {
    // 网络问题重试失败
    showError('网络连接异常,请稍后重试');
    return null;
  }
}

6. 底层原理

6.1 浏览器错误事件的捕获机制

在浏览器标准中,错误捕获有两条不同的路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
DOM 事件模型(捕获 → 目标 → 冒泡):

           window
          /      \
    document      ...
      /    
    HTMLBodyElement
      /
    <div> ← 点击事件目标

错误事件的传播:
  1. JS 运行时错误 → ErrorEvent
     - 在 window 上触发(不冒泡)
     - 可以通过 window.onerror 或 addEventListener('error') 在 window 上捕获
     - 不经过捕获/冒泡阶段

  2. 资源加载错误 → Event(不是 ErrorEvent!)
     - 在目标元素上触发
     - 冒泡到 document,最终到 window
     - 但是!如果在 window 上监听,需要设置 capture: true
     - 普通 window.addEventListener('error') 在冒泡阶段监听不到资源错误
     - 必须用 window.addEventListener('error', handler, true) 捕获阶段

  3. Promise 拒绝 → PromiseRejectionEvent
     - 在 window 上触发
     - 需要通过 unhandledrejection 事件监听
     - 可以通过 event.preventDefault() 阻止浏览器控制台输出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<!-- 验证实验 -->
<!DOCTYPE html>
<html>
<head><title>Error Event Test</title></head>
<body>
  <!-- 资源加载错误 -->
  <img src="non-existent.jpg" id="brokenImg">
  
  <script>
    // 验证 1:冒泡阶段监听
    window.addEventListener('error', function(event) {
      console.log('冒泡阶段:', event.target.tagName);
      // ❌ 对于 broken.jpg,这个 handler 可能不会被触发
      // 因为资源错误事件在捕获阶段就被捕获了
    });

    // 验证 2:捕获阶段监听
    window.addEventListener('error', function(event) {
      console.log('捕获阶段:', {
        tagName: event.target.tagName,
        src: event.target.src,
        isErrorEvent: event instanceof ErrorEvent,
        isEvent: event instanceof Event,
      });
    }, true);  // capture: true 是关键!

    // 验证 3:JS 运行时错误
    setTimeout(() => {
      nonExistentFunction(); // ReferenceError
    }, 500);

    window.onerror = function(msg, source, line, col, error) {
      console.log('window.onerror:', {
        message: msg,
        source: source,
        line: line,
        col: col,
        error: error?.stack
      });
    };
  </script>
</body>
</html>

6.2 V8 引擎中的错误栈生成

当 V8 执行代码时遇到 throw 语句,它会回溯调用栈来生成错误栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// V8 源码简化版:错误栈的生成
// src/v8/src/builtins/builtins-error.cc

// 当执行 throw new Error() 时
// V8 会捕获当前的调用栈帧

void GenerateErrorStackTrace(Isolate* isolate, Handle<JSObject> error) {
    // 1. 获取当前的 JavaScript 调用栈
    StackFrameIterator it(isolate);
    
    // 2. 遍历所有帧,跳过内部帧
    std::string stack_trace;
    int frame_count = 0;
    
    for (; !it.done(); it.Advance()) {
        StackFrame* frame = it.frame();
        
        // 跳过 V8 内部函数帧(如 Promise 内部的 then 实现)
        if (frame->is_internal()) continue;
        
        // 3. 每帧获取:函数名、文件名、行号、列号
        JavaScriptFrame* js_frame = JavaScriptFrame::cast(frame);
        
        // 行号和列号是通过源码的 "position" 推断的
        // V8 内部维护了每个函数的源码位置映射
        int position = js_frame->position();
        int line = Script::GetLineNumber(script, position);
        int column = Script::GetColumnNumber(script, position);
        
        // 4. 拼接成标准的错误栈格式
        // "at FunctionName (file:line:column)"
        stack_trace += "    at " + function_name + " (";
        stack_trace += script_name + ":" + std::to_string(line);
        stack_trace += ":" + std::to_string(column) + ")\n";
        
        frame_count++;
        if (frame_count >= 10) break; // 最多显示 10 帧
    }
    
    // 5. 设置 error.stack 属性
    // stack 实际上是一个 getter,在首次访问时生成
    error->SetAccessor(
        isolate->factory()->stack_string(),
        GetStackTraceString);
}

6.3 错误上报的可靠性保障

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 为什么使用 sendBeacon?
// 在页面关闭/刷新时,XMLHttpRequest 和 fetch 可能被取消
// sendBeacon 是唯一保证能发送成功的方法

// 浏览器内部 sendBeacon 的实现机制:
// 1. 创建一个很小的 HTTP POST 请求
// 2. 优先级高于普通网络请求
// 3. 不会被页面卸载打断
// 4. 不关心响应结果(fire and forget)
// 5. 最大数据量 64KB(Blob 限制)

// 兜底策略:多重上报方法
function guaranteedReport(data) {
  // 第 1 层:sendBeacon(最佳)
  if (navigator.sendBeacon) {
    const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
    if (navigator.sendBeacon('/api/error', blob)) {
      return true; // 成功入队
    }
  }

  // 第 2 层:fetch keepalive
  try {
    fetch('/api/error', {
      method: 'POST',
      body: JSON.stringify(data),
      keepalive: true, // 告诉浏览器在页面卸载后继续发送
      headers: { 'Content-Type': 'application/json' }
    });
    return true;
  } catch (e) {
    // 继续
  }

  // 第 3 层:Image ping(最小的兜底)
  try {
    const img = new Image();
    img.src = `/api/error?data=${encodeURIComponent(JSON.stringify(data))}`;
    return true;
  } catch (e) {
    return false;
  }
}

6.4 堆快照与内存泄漏检测

在生产环境中,错误监控还可以关联堆快照来诊断内存泄漏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// 定期采样内存使用量
class MemoryMonitor {
  constructor(options = {}) {
    this.options = {
      sampleInterval: options.sampleInterval || 60000, // 每分钟采样一次
      warnThreshold: options.warnThreshold || 100,      // 内存增长超过 100MB 时告警
      ...options
    };

    this.samples = [];
    this.lastHeap = null;
    this.start();
  }

  start() {
    if (!performance.memory) {
      console.warn('[MemoryMonitor] performance.memory 不可用(仅 Chrome)');
      return;
    }

    setInterval(() => {
      const heap = performance.memory;
      const sample = {
        timestamp: Date.now(),
        usedHeap: heap.usedJSHeapSize,
        totalHeap: heap.totalJSHeapSize,
        heapLimit: heap.jsHeapSizeLimit,
      };

      this.samples.push(sample);

      // 限制采样点数量
      if (this.samples.length > 100) {
        this.samples.shift();
      }

      // 检测内存增长
      if (this.lastHeap) {
        const growth = sample.usedHeap - this.lastHeap.usedHeap;
        if (growth > this.options.warnThreshold * 1024 * 1024) {
          this.reportMemoryLeak(growth, sample);
        }
      }

      this.lastHeap = sample;

      // 记录到 breadcrumbs
      this.addMemoryBreadcrumb(sample);
    }, this.options.sampleInterval);
  }

  reportMemoryLeak(growth, currentSample) {
    const errorReport = {
      type: 'memory',
      level: 'warning',
      message: `内存持续增长: 过去 ${this.options.sampleInterval / 1000}s 增长了 ${this.formatBytes(growth)}`,
      data: {
        current: this.formatBytes(currentSample.usedHeap),
        growth: this.formatBytes(growth),
        totalHeap: this.formatBytes(currentSample.totalHeap),
        samples: this.samples.slice(-10), // 最近 10 个采样点
      }
    };

    navigator.sendBeacon?.('/api/error-monitor', 
      new Blob([JSON.stringify(errorReport)], { type: 'application/json' }));
  }

  formatBytes(bytes) {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
  }
}

7. 高频面试题解析

面试题 1:window.onerror 和 window.addEventListener(‘error’) 的区别是什么?为什么资源加载错误必须在捕获阶段监听?

答案

核心区别

1
2
3
4
5
6
7
8
9
10
11
12
13
// window.onerror
// - 只能设置一个 handler(后续的会覆盖前面的)
// - 只能捕获 JS 运行时错误
// - 参数是 (message, source, lineno, colno, error) 格式
// - 接收的是原始的字符串信息,不是 Event 对象
// - 返回 true 可以阻止浏览器默认的错误处理

// window.addEventListener('error')
// - 可以设置多个 handler
// - 可以同时捕获 JS 错误和资源加载错误
//   但资源错误需要设置 capture: true
// - 接收的是 ErrorEvent / Event 对象
// - 可以通过 event.preventDefault() 阻止默认行为

为什么资源加载错误必须在捕获阶段监听?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DOM 事件模型:
                   捕获阶段                 冒泡阶段
window     ──────────────────→           ←──────────────────
document        │                              │
html            │                              │
body            │                              │
img             │                              │
(broken.jpg)    ▼                              │
            事件到达目标元素                    │
            
资源加载错误的事件特点:
1. Event 对象 → not ErrorEvent
2. 目标元素(img/script/link)上触发
3. ⚠️ 资源错误事件不冒泡到 window!
4. ❌ window.addEventListener('error') 默认(冒泡阶段)收不到
5. ✅ window.addEventListener('error', handler, true) 捕获阶段可以收到
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 正确做法
// 捕获 JS 运行时错误
window.addEventListener('error', (event) => {
  if (event instanceof ErrorEvent) {
    // JS 运行时错误
    reportJSError(event);
  }
});

// 在捕获阶段捕获资源加载错误
window.addEventListener('error', (event) => {
  if (event.target instanceof HTMLElement && 
      !(event instanceof ErrorEvent)) {
    // 资源加载错误
    reportResourceError(event.target);
  }
}, true); // 注意 capture: true

// 或者将两者合并
window.addEventListener('error', (event) => {
  if (event instanceof ErrorEvent) {
    reportJSError(event);
  } else if (event.target instanceof HTMLElement) {
    reportResourceError(event.target);
  }
}, true); // 使用 capture: true 捕获所有错误类型

面试题 2:SourceMap 中的 VLQ(Variable Length Quantity)编码是如何工作的?请简述 mappings 字段的编码原理。

答案

VLQ 编码的 4 个核心概念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. VLQ 基于 Base64 编码
// 每个 VLQ 字符对应 6-bit(因为 Base64 有 64 个字符,2^6=64)
// Base64 字符集:A-Z(0-25), a-z(26-51), 0-9(52-61), +(62), /(63)

// 2. 连续编码:大数字用多个字符表示
// 每个字符的低 5-bit 是数据位,高 1-bit 是连续位
// 连续位 = 1 → 后面还有更多字符
// 连续位 = 0 → 这是最后一个字符

// 3. 符号编码:数字可以是正数或负数
// 正数:乘以 2(左移一位)
// 负数:乘以 2 再减 1
// 例如:3 → 6 (0b110), -3 → 5 (0b101)

// 4. 相对偏移编码
// 位置信息存储的不是绝对值,而是相对上一个位置的偏移
// 这样可以大幅压缩数据量

手动解码示例

1
2
3
4
5
6
7
8
9
10
11
// mappings 中的一段: "AAgBC"

A = 0 (000000)  → 连续位=0, 数据=0 → 完整值 = 0
A = 0 (000000)  → 连续位=0, 数据=0 → 完整值 = 0
g = 32 (100000) → 连续位=1, 数据=0 → 继续读取
B = 1 (000001)  → 连续位=0, 数据=1 → 完整值 = (32 & 0x1F) | (1 << 5) = 0 | 32 = 32 → 解码为 16(正数)

C = 2 (000010)  → 连续位=0, 数据=2 → 完整值 = 2 → 解码为 1(正数)

// 所以 "AAgBC" 解码为:段 = [0, 0, 16, 1]
// 含义:生成的列偏移 0,源文件索引 0,源文件行偏移 16,源文件列偏移 1

解码函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function decodeVLQ(encoded) {
  const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
  const VLQ_BASE_SHIFT = 5; // 每字符 5 位数据
  const VLQ_BASE = 1 << VLQ_BASE_SHIFT; // 32
  const VLQ_CONTINUATION_BIT = VLQ_BASE; // 第 6 位是连续位

  let result = [];
  let value = 0;
  let shift = 0;

  for (const char of encoded) {
    const segment = BASE64_CHARS.indexOf(char);
    
    // 提取数据位
    const digit = segment & (VLQ_BASE - 1);
    value += digit << shift;
    shift += VLQ_BASE_SHIFT;
    
    // 检查连续位
    if (segment & VLQ_CONTINUATION_BIT) {
      continue; // 还有更多字符
    }
    
    // 解码符号位
    result.push(value & 1 ? -(value >> 1) : value >> 1);
    value = 0;
    shift = 0;
  }

  return result;
}

// 使用
console.log(decodeVLQ('AAgBC')); // [0, 0, 16, 1]
// 实际上,SourceMap 的 segment 有 4 个字段:
// [生成列偏移, 源文件索引, 源文件行偏移, 源文件列偏移]
// 所以这表示:
// - 当前生成的列: 0
// - 源文件索引: 0(第 0 个源文件)
// - 源文件行: 16
// - 源文件列: 1

面试题 3:如何设计一个可靠的、不丢失错误的上报系统?需要考虑哪些边界情况?

答案

一个可靠的错误上报系统需要考虑 5 个关键边界情况:

1. 页面关闭/刷新时的上报

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 情景:用户正在浏览页面,触发了错误
// 恰好在此时用户关闭了标签页
// fetch/XHR 请求会被浏览器取消

// 解决方案:
// - 使用 navigator.sendBeacon(最佳)
// - 使用 fetch 的 keepalive 选项(次优)
// - 使用 Image ping 兜底

window.addEventListener('beforeunload', () => {
  // 此时普通 fetch 无法完成
  // 但 sendBeacon 可以
  navigator.sendBeacon('/api/error', 
    new Blob([JSON.stringify(errorQueue)], { type: 'application/json' }));
});

2. 上报接口本身失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 情景:错误上报接口挂了
// 用户客户端网络断开

// 解决方案:
// - 本地缓存 + 延迟重试
// - IndexedDB 持久化存储
// - 限制重试次数(避免无限重试加重网络负担)

class ReliableReporter {
  constructor() {
    this.queue = [];
    this.maxRetries = 3;
    this.MAX_QUEUE_SIZE = 100;
    this.retryDelays = [1000, 5000, 30000]; // 重试间隔递增
  }

  async report(event) {
    this.queue.push(event);
    
    // 限制队列大小(防止内存溢出)
    if (this.queue.length > this.MAX_QUEUE_SIZE) {
      this.queue.shift();
    }

    await this.trySend();
  }

  async trySend(retryCount = 0) {
    if (this.queue.length === 0) return;

    const batch = [...this.queue];
    
    try {
      const response = await fetch('/api/error', {
        method: 'POST',
        body: JSON.stringify(batch),
        headers: { 'Content-Type': 'application/json' }
      });
      
      if (response.ok) {
        // 成功:清空已发送的队列
        this.queue = this.queue.slice(batch.length);
      }
    } catch (e) {
      // 失败:重试
      if (retryCount < this.maxRetries) {
        await new Promise(resolve => 
          setTimeout(resolve, this.retryDelays[retryCount]));
        return this.trySend(retryCount + 1);
      }
      // 重试耗尽,丢弃最旧的错误
      this.queue.shift();
    }
  }
}

3. 错误风暴(Error Storm)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 情景:一个错误在短时间内爆发(如 API 全线不可用)
// 每分钟产生数万条错误

// 解决方案:
// - 速率限制(Rate Limiting)
// - 采样率调整
// - 聚合合并

class RateLimitedReporter {
  constructor() {
    this.errorCount = 0;
    this.windowStart = Date.now();
    this.maxPerWindow = 100;  // 每分钟最多 100 条
    this.windowMs = 60000;    // 1 分钟窗口
  }

  shouldSample(error) {
    const now = Date.now();
    
    // 重置窗口
    if (now - this.windowStart > this.windowMs) {
      this.errorCount = 0;
      this.windowStart = now;
    }

    this.errorCount++;

    // 策略1:前 10 条全部上报(获取最完整的错误信息)
    if (this.errorCount <= 10) return true;

    // 策略2:10-100 条按 10% 采样
    if (this.errorCount <= 100) return Math.random() < 0.1;

    // 策略3:超过 100 条按 1% 采样
    return Math.random() < 0.01;
  }
}

4. 循环错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 情景:错误上报代码本身出错了
// 捕获错误 → 上报 → 上报抛异常 → 再次被捕获 → 无限递归

// 解决方案:加入断路器标记

let isReporting = false;

function safeReport(error) {
  // 断路器:防止上报逻辑递归导致栈溢出
  if (isReporting) return;
  
  isReporting = true;
  try {
    // 正常上报逻辑
    fetch('/api/error', {
      method: 'POST',
      body: JSON.stringify(error)
    });
  } catch (reportError) {
    // 上报失败,什么都不做
    // 千万不要再次调用 safeReport!
    console.warn('Error reporting failed:', reportError);
  } finally {
    isReporting = false;
  }
}

5. 隐私合规

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 情景:错误中包含用户的个人信息
// 如:URL 中的 user id、API 响应中的用户数据

// 解决方案:上报前进行数据清洗

function sanitizeError(error) {
  const sanitized = {
    ...error,
    // 移除 URL 中的查询参数(可能包含敏感信息)
    url: error.url ? error.url.replace(/\?.*/, '') : undefined,
    // 移除 response body 中的用户数据
    responseBody: undefined,
    // 脱敏处理
    message: error.message?.replace(/[\w.-]+@[\w.-]+/g, '***@***'), // 邮箱
    stack: error.stack?.replace(/\/(users\/)\w+/g, '/$1***'),       // 文件路径
  };
  return sanitized;
}

8. 总结与扩展

核心要点回顾

  1. 四大错误类型:JS 运行时异常、Promise 拒绝、资源加载失败、HTTP 请求错误
  2. 三层捕获方案:try-catch(代码块级)+ window.onerror(全局级)+ addEventListener(资源级)
  3. SourceMap 还原:VLQ 编码保存位置映射,内部监控服务保存 .map 文件进行源码还原
  4. 可靠上报:sendBeacon + fetch keepalive + 本地缓存重试,同时注意限流和防循环
  5. 环境信息采集:UserAgent、网络状态、设备信息、用户行为栈(breadcrumbs)

值得继续深挖的方向

  • Sentry / OpenTelemetry:开源错误监控平台的架构设计
  • Performance Observer 错误类型error entry type 的标准化
  • Error.cause:ES2022 链式错误的新特性
  • 前端日志标准化:OpenTelemetry 的前端日志规范
  • RUM + Error Monitoring 的联动:性能问题如何导致错误

思考题

  1. 如果你的错误监控系统中收到大量 “Script error.”,如何确定这是由哪个第三方脚本引起的?
  2. 在一个 SPA 应用中,History API 的变化如何影响错误栈中的 fileName 信息?SourceMap 还原时如何应对?
  3. 假如你的监控系统在白屏时无法上报错误(因为页面 JS 根本没加载),如何设计一个独立的、不依赖 JS 的错误捕获方案?

参考资源:

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量