返回笔记首页

17.2 前端错误监控系统

主题配置

一、技术实现方案

1.1 错误监控架构

plain
错误监控系统
  ├── 错误捕获层
  │   ├── window.onerror (JS运行时错误)
  │   ├── window.addEventListener('error') (资源加载错误)
  │   ├── window.addEventListener('unhandledrejection') (Promise错误)
  │   └── try-catch (手动捕获)
  │
  ├── 错误处理层
  │   ├── 错误分类
  │   ├── 错误去重
  │   ├── 错误聚合
  │   └── 错误过滤
  │
  ├── 错误分析层
  │   ├── SourceMap解析
  │   ├── 错误堆栈分析
  │   ├── 错误影响评估
  │   └── 错误趋势分析
  │
  └── 错误上报层
      ├── 实时上报
      ├── 批量上报
      └── 离线缓存

1.2 技术栈

  • 错误捕获: window.onerror, addEventListener
  • Promise错误: unhandledrejection
  • SourceMap: source-map库
  • 数据上报: Beacon API / fetch

二、JS 错误捕获

2.1 全局错误监控器

error-monitor.js

javascript
export class ErrorMonitor {
  constructor(options = {}) {
    this.errors = []
    this.maxErrors = options.maxErrors || 100
    this.reportUrl = options.reportUrl
    this.enableConsole = options.enableConsole !== false

    this.init()
  }

  init() {
    this.captureJSError()
    this.capturePromiseError()
    this.captureResourceError()
    this.captureVueError()
  }

  // 捕获JS运行时错误
  captureJSError() {
    window.onerror = (message, source, lineno, colno, error) => {
      const errorInfo = {
        type: 'jsError',
        message: message,
        source: source,
        lineno: lineno,
        colno: colno,
        stack: error?.stack,
        timestamp: Date.now(),
        userAgent: navigator.userAgent,
        url: window.location.href
      }

      this.handleError(errorInfo)

      // 返回true阻止默认错误提示
      return true
    }
  }

  // 捕获Promise未处理的rejection
  capturePromiseError() {
    window.addEventListener('unhandledrejection', (event) => {
      const errorInfo = {
        type: 'promiseError',
        message: event.reason?.message || event.reason,
        stack: event.reason?.stack,
        timestamp: Date.now(),
        userAgent: navigator.userAgent,
        url: window.location.href
      }

      this.handleError(errorInfo)

      // 阻止默认行为
      event.preventDefault()
    })
  }

  // 捕获资源加载错误
  captureResourceError() {
    window.addEventListener('error', (event) => {
      // 区分JS错误和资源错误
      if (event.target !== window) {
        const target = event.target || event.srcElement

        const errorInfo = {
          type: 'resourceError',
          message: `${target.tagName} load error`,
          source: target.src || target.href,
          tagName: target.tagName,
          timestamp: Date.now(),
          userAgent: navigator.userAgent,
          url: window.location.href
        }

        this.handleError(errorInfo)
      }
    }, true) // 使用捕获阶段
  }

  // 捕获Vue错误
  captureVueError() {
    // 这个方法需要在Vue应用初始化时调用
    this.vueErrorHandler = (err, vm, info) => {
      const errorInfo = {
        type: 'vueError',
        message: err.message,
        stack: err.stack,
        componentName: vm?.$options?.name || 'Anonymous',
        propsData: vm?.$options?.propsData,
        info: info,
        timestamp: Date.now(),
        userAgent: navigator.userAgent,
        url: window.location.href
      }

      this.handleError(errorInfo)
    }
  }

  // 处理错误
  handleError(errorInfo) {
    // 添加唯一ID
    errorInfo.id = this.generateId()

    // 错误去重
    if (this.isDuplicate(errorInfo)) {
      console.log('Duplicate error, ignored')
      return
    }

    // 添加到错误列表
    this.errors.unshift(errorInfo)

    // 限制错误数量
    if (this.errors.length > this.maxErrors) {
      this.errors.pop()
    }

    // 控制台输出
    if (this.enableConsole) {
      console.error('Error captured:', errorInfo)
    }

    // 上报错误
    this.reportError(errorInfo)
  }

  // 错误去重
  isDuplicate(errorInfo) {
    // 检查最近10个错误
    const recentErrors = this.errors.slice(0, 10)

    return recentErrors.some(error => {
      return (
        error.type === errorInfo.type &&
        error.message === errorInfo.message &&
        error.source === errorInfo.source &&
        error.lineno === errorInfo.lineno &&
        error.colno === errorInfo.colno
      )
    })
  }

  // 上报错误
  reportError(errorInfo) {
    if (!this.reportUrl) return

    // 使用Beacon API上报(页面关闭时也能发送)
    if (navigator.sendBeacon) {
      const data = JSON.stringify(errorInfo)
      navigator.sendBeacon(this.reportUrl, data)
    } else {
      // 降级到fetch
      fetch(this.reportUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(errorInfo),
        keepalive: true
      }).catch(err => {
        console.error('Error report failed:', err)
      })
    }
  }

  // 手动上报错误
  captureError(error, extra = {}) {
    const errorInfo = {
      type: 'manualError',
      message: error.message || String(error),
      stack: error.stack,
      extra: extra,
      timestamp: Date.now(),
      userAgent: navigator.userAgent,
      url: window.location.href
    }

    this.handleError(errorInfo)
  }

  // 获取所有错误
  getErrors() {
    return this.errors
  }

  // 获取错误统计
  getStatistics() {
    const stats = {
      total: this.errors.length,
      byType: {},
      bySource: {}
    }

    this.errors.forEach(error => {
      // 按类型统计
      stats.byType[error.type] = (stats.byType[error.type] || 0) + 1

      // 按来源统计
      if (error.source) {
        const source = this.getShortSource(error.source)
        stats.bySource[source] = (stats.bySource[source] || 0) + 1
      }
    })

    return stats
  }

  // 获取简短的文件名
  getShortSource(source) {
    if (!source) return 'unknown'
    return source.split('/').pop() || source
  }

  // 清空错误
  clear() {
    this.errors = []
  }

  // 生成ID
  generateId() {
    return `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
  }

  // 获取Vue错误处理器
  getVueErrorHandler() {
    return this.vueErrorHandler
  }
}

2.2 错误监控组件

ErrorMonitor.vue

vue
<script setup>
import { ref, computed, onMounted, getCurrentInstance } from 'vue'
import { ErrorMonitor } from './error-monitor.js'

const errorMonitor = ref(null)
const errors = ref([])
const filterType = ref('all')
const autoRefresh = ref(true)
let refreshTimer = null

// 错误统计
const statistics = computed(() => {
  if (!errorMonitor.value) return null
  return errorMonitor.value.getStatistics()
})

// 过滤后的错误
const filteredErrors = computed(() => {
  if (filterType.value === 'all') {
    return errors.value
  }
  return errors.value.filter(err => err.type === filterType.value)
})

// 初始化监控
const initMonitor = () => {
  errorMonitor.value = new ErrorMonitor({
    maxErrors: 100,
    reportUrl: '/api/errors',
    enableConsole: true
  })

  // 设置Vue错误处理器
  const app = getCurrentInstance()
  if (app) {
    app.appContext.config.errorHandler = errorMonitor.value.getVueErrorHandler()
  }

  loadErrors()

  if (autoRefresh.value) {
    startAutoRefresh()
  }
}

// 加载错误数据
const loadErrors = () => {
  if (errorMonitor.value) {
    errors.value = errorMonitor.value.getErrors()
  }
}

// 自动刷新
const startAutoRefresh = () => {
  refreshTimer = setInterval(() => {
    loadErrors()
  }, 2000)
}

const stopAutoRefresh = () => {
  if (refreshTimer) {
    clearInterval(refreshTimer)
    refreshTimer = null
  }
}

// 模拟各种错误
const simulateJSError = () => {
  // 制造一个引用错误
  try {
    undefinedVariable.test()
  } catch (e) {
    throw e
  }
}

const simulatePromiseError = () => {
  // 制造一个未捕获的Promise错误
  Promise.reject(new Error('这是一个未处理的Promise错误'))
}

const simulateResourceError = () => {
  // 加载一个不存在的图片
  const img = document.createElement('img')
  img.src = 'https://example.com/not-exist-image.jpg'
  document.body.appendChild(img)

  setTimeout(() => {
    document.body.removeChild(img)
  }, 1000)
}

const simulateVueError = () => {
  // 触发Vue组件错误
  throw new Error('这是一个Vue组件错误')
}

// 格式化时间
const formatTime = (timestamp) => {
  return new Date(timestamp).toLocaleTimeString()
}

// 获取错误类型颜色
const getErrorTypeColor = (type) => {
  const colors = {
    jsError: '#f56c6c',
    promiseError: '#e6a23c',
    resourceError: '#909399',
    vueError: '#f56c6c',
    manualError: '#409eff'
  }
  return colors[type] || '#c0c4cc'
}

// 获取错误类型文本
const getErrorTypeText = (type) => {
  const texts = {
    jsError: 'JS错误',
    promiseError: 'Promise错误',
    resourceError: '资源错误',
    vueError: 'Vue错误',
    manualError: '手动上报'
  }
  return texts[type] || type
}

onMounted(() => {
  initMonitor()
})
</script>

<template>
  <div class="error-monitor">
    <div class="monitor-header">
      <h2>错误监控系统</h2>
      <div class="header-actions">
        <button @click="simulateJSError" class="error-btn js">
          模拟JS错误
        </button>
        <button @click="simulatePromiseError" class="error-btn promise">
          模拟Promise错误
        </button>
        <button @click="simulateResourceError" class="error-btn resource">
          模拟资源错误
        </button>
        <button @click="simulateVueError" class="error-btn vue">
          模拟Vue错误
        </button>
      </div>
    </div>

    <!-- 统计卡片 -->
    <div v-if="statistics" class="statistics-section">
      <div class="stat-card total">
        <div class="stat-icon">🔴</div>
        <div class="stat-content">
          <div class="stat-label">总错误数</div>
          <div class="stat-value">{{ statistics.total }}</div>
        </div>
      </div>

      <div class="type-stats">
        <div
          v-for="(count, type) in statistics.byType"
          :key="type"
          class="type-stat-item"
        >
          <div class="type-name" :style="{ color: getErrorTypeColor(type) }">
            {{ getErrorTypeText(type) }}
          </div>
          <div class="type-count">{{ count }}</div>
        </div>
      </div>
    </div>

    <!-- 过滤器 -->
    <div class="filter-section">
      <div class="filter-tabs">
        <button
          :class="{ active: filterType === 'all' }"
          @click="filterType = 'all'"
        >
          全部 ({{ errors.length }})
        </button>
        <button
          :class="{ active: filterType === 'jsError' }"
          @click="filterType = 'jsError'"
        >
          JS错误
        </button>
        <button
          :class="{ active: filterType === 'promiseError' }"
          @click="filterType = 'promiseError'"
        >
          Promise错误
        </button>
        <button
          :class="{ active: filterType === 'resourceError' }"
          @click="filterType = 'resourceError'"
        >
          资源错误
        </button>
        <button
          :class="{ active: filterType === 'vueError' }"
          @click="filterType = 'vueError'"
        >
          Vue错误
        </button>
      </div>
    </div>

    <!-- 错误列表 -->
    <div class="error-list">
      <div
        v-for="error in filteredErrors"
        :key="error.id"
        class="error-item"
      >
        <div class="error-header">
          <span
            class="error-type-badge"
            :style="{
              background: getErrorTypeColor(error.type) + '20',
              color: getErrorTypeColor(error.type)
            }"
          >
            {{ getErrorTypeText(error.type) }}
          </span>
          <span class="error-time">{{ formatTime(error.timestamp) }}</span>
        </div>

        <div class="error-message">{{ error.message }}</div>

        <div v-if="error.source" class="error-source">
          文件: {{ error.source }}
          <span v-if="error.lineno">
            (行: {{ error.lineno }}, 列: {{ error.colno }})
          </span>
        </div>

        <div v-if="error.stack" class="error-stack">
          <details>
            <summary>查看堆栈信息</summary>
            <pre>{{ error.stack }}</pre>
          </details>
        </div>

        <div v-if="error.componentName" class="error-component">
          组件: {{ error.componentName }}
        </div>
      </div>

      <div v-if="filteredErrors.length === 0" class="empty-list">
        <div class="empty-icon">✅</div>
        <div class="empty-text">暂无错误记录</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.error-monitor {
  padding: 20px;
  background: #f5f7fa;
  min-height: 100vh;
}

.monitor-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
  padding: 20px;
  background: white;
  border-radius: 8px;
  flex-wrap: wrap;
  gap: 12px;
}

.monitor-header h2 {
  margin: 0;
  font-size: 24px;
  color: #303133;
}

.header-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.error-btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  color: white;
  transition: all 0.3s;
}

.error-btn.js {
  background: #f56c6c;
}

.error-btn.promise {
  background: #e6a23c;
}

.error-btn.resource {
  background: #909399;
}

.error-btn.vue {
  background: #f56c6c;
}

.error-btn:hover {
  opacity: 0.8;
  transform: translateY(-2px);
}

.statistics-section {
  display: grid;
  grid-template-columns: 300px 1fr;
  gap: 20px;
  margin-bottom: 24px;
}

.stat-card {
  padding: 24px;
  background: white;
  border-radius: 8px;
  display: flex;
  align-items: center;
  gap: 20px;
}

.stat-card.total {
  border-left: 4px solid #f56c6c;
}

.stat-icon {
  font-size: 48px;
}

.stat-label {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}

.stat-value {
  font-size: 36px;
  font-weight: 700;
  color: #303133;
}

.type-stats {
  background: white;
  border-radius: 8px;
  padding: 20px;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 16px;
}

.type-stat-item {
  padding: 16px;
  border: 2px solid #f0f0f0;
  border-radius: 8px;
  text-align: center;
}

.type-name {
  font-size: 14px;
  font-weight: 600;
  margin-bottom: 8px;
}

.type-count {
  font-size: 28px;
  font-weight: 700;
  color: #303133;
}

.filter-section {
  padding: 16px 20px;
  background: white;
  border-radius: 8px;
  margin-bottom: 16px;
}

.filter-tabs {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
}

.filter-tabs button {
  padding: 8px 20px;
  background: #f5f7fa;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  color: #606266;
  transition: all 0.3s;
}

.filter-tabs button.active {
  background: #f56c6c;
  color: white;
}

.error-list {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.error-item {
  padding: 20px;
  background: white;
  border-radius: 8px;
  border-left: 4px solid #f56c6c;
}

.error-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.error-type-badge {
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 700;
}

.error-time {
  font-size: 12px;
  color: #c0c4cc;
}

.error-message {
  font-size: 15px;
  color: #303133;
  font-weight: 600;
  margin-bottom: 12px;
  line-height: 1.6;
}

.error-source {
  font-size: 13px;
  color: #606266;
  margin-bottom: 8px;
  font-family: 'Courier New', monospace;
}

.error-component {
  font-size: 13px;
  color: #409eff;
  margin-bottom: 8px;
}

.error-stack {
  margin-top: 12px;
}

.error-stack summary {
  cursor: pointer;
  font-size: 13px;
  color: #409eff;
  user-select: none;
}

.error-stack pre {
  margin-top: 8px;
  padding: 12px;
  background: #f5f7fa;
  border-radius: 4px;
  font-size: 12px;
  line-height: 1.6;
  overflow-x: auto;
  color: #606266;
}

.empty-list {
  padding: 80px 20px;
  text-align: center;
  background: white;
  border-radius: 8px;
}

.empty-icon {
  font-size: 64px;
  margin-bottom: 16px;
}

.empty-text {
  font-size: 16px;
  color: #909399;
}
</style>

三、跨域脚本错误处理

3.1 跨域错误解决方案

cors-error-handler.js

javascript
export class CorsErrorHandler {
  constructor() {
    this.scriptUrls = new Set()
    this.init()
  }

  init() {
    this.interceptScriptLoad()
    this.setupCorsHeaders()
  }

  // 拦截脚本加载
  interceptScriptLoad() {
    const originalAppendChild = Node.prototype.appendChild
    const self = this

    Node.prototype.appendChild = function(child) {
      if (child.tagName === 'SCRIPT' && child.src) {
        // 记录脚本URL
        self.scriptUrls.add(child.src)

        // 添加crossorigin属性
        if (!child.crossOrigin) {
          child.crossOrigin = 'anonymous'
        }
      }

      return originalAppendChild.call(this, child)
    }
  }

  // 设置CORS头部
  setupCorsHeaders() {
    // 修改fetch请求
    const originalFetch = window.fetch

    window.fetch = function(...args) {
      const [url, options = {}] = args

      // 添加CORS头部
      if (!options.mode) {
        options.mode = 'cors'
      }

      if (!options.credentials) {
        options.credentials = 'same-origin'
      }

      return originalFetch.call(this, url, options)
    }
  }

  // 处理Script Error
  handleScriptError(error) {
    // 如果是Script error,尝试获取详细信息
    if (error.message === 'Script error.' || error.message === 'Script error') {
      return {
        ...error,
        message: '跨域脚本错误(详细信息被浏览器屏蔽)',
        tip: '请在script标签添加crossorigin="anonymous"属性,并确保服务器返回Access-Control-Allow-Origin头部',
        possibleSources: Array.from(this.scriptUrls)
      }
    }

    return error
  }

  // 检查是否为跨域错误
  isCorsError(error) {
    return (
      error.message === 'Script error.' ||
      error.message === 'Script error' ||
      (error.message && error.message.includes('CORS'))
    )
  }
}

3.2 跨域错误处理组件

CorsErrorHandler.vue

vue
<script setup>
import { ref, onMounted } from 'vue'
import { CorsErrorHandler } from './cors-error-handler.js'

const corsHandler = ref(null)
const corsErrors = ref([])
const solutions = ref([
  {
    title: '1. 添加crossorigin属性',
    code: '<script src="https://example.com/script.js" crossorigin="anonymous"></script>',
    desc: '在script标签上添加crossorigin属性允许浏览器获取详细错误信息'
  },
  {
    title: '2. 服务器配置CORS头部',
    code: 'Access-Control-Allow-Origin: *\nAccess-Control-Allow-Methods: GET',
    desc: '服务器需要返回正确的CORS响应头'
  },
  {
    title: '3. 动态添加脚本',
    code: `const script = document.createElement('script')
script.src = 'https://example.com/script.js'
script.crossOrigin = 'anonymous'
document.head.appendChild(script)`,
    desc: '通过JavaScript动态加载脚本时也要设置crossOrigin'
  }
])

const initHandler = () => {
  corsHandler.value = new CorsErrorHandler()
}

// 模拟跨域错误
const simulateCorsError = () => {
  // 加载一个跨域脚本(不带crossorigin)
  const script = document.createElement('script')
  script.src = 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js'
  // 故意不设置crossOrigin
  document.head.appendChild(script)

  corsErrors.value.push({
    id: Date.now(),
    message: 'Script error.',
    type: 'cors',
    url: script.src,
    timestamp: Date.now()
  })
}

const formatTime = (timestamp) => {
  return new Date(timestamp).toLocaleTimeString()
}

onMounted(() => {
  initHandler()
})
</script>

<template>
  <div class="cors-error-handler">
    <div class="handler-header">
      <h2>跨域脚本错误处理</h2>
      <button @click="simulateCorsError" class="simulate-btn">
        模拟跨域错误
      </button>
    </div>

    <!-- 说明 -->
    <div class="explanation-card">
      <h3>什么是Script error?</h3>
      <p>
        当JavaScript脚本从不同域加载且未正确配置CORS时,浏览器出于安全考虑会将错误信息隐藏,
        只显示"Script error."。这使得错误难以调试和定位。
      </p>
    </div>

    <!-- 解决方案 -->
    <div class="solutions-section">
      <h3>解决方案</h3>
      <div class="solution-list">
        <div
          v-for="(solution, index) in solutions"
          :key="index"
          class="solution-item"
        >
          <h4>{{ solution.title }}</h4>
          <pre class="code-block">{{ solution.code }}</pre>
          <p class="solution-desc">{{ solution.desc }}</p>
        </div>
      </div>
    </div>

    <!-- 检测到的跨域错误 -->
    <div v-if="corsErrors.length > 0" class="errors-section">
      <h3>检测到的跨域错误</h3>
      <div
        v-for="error in corsErrors"
        :key="error.id"
        class="error-card"
      >
        <div class="error-badge">跨域错误</div>
        <div class="error-message">{{ error.message }}</div>
        <div class="error-url">脚本URL: {{ error.url }}</div>
        <div class="error-time">{{ formatTime(error.timestamp) }}</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.cors-error-handler {
  padding: 20px;
  background: #f5f7fa;
  min-height: 100vh;
}

.handler-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
  padding: 20px;
  background: white;
  border-radius: 8px;
}

.handler-header h2 {
  margin: 0;
  font-size: 24px;
  color: #303133;
}

.simulate-btn {
  padding: 8px 20px;
  background: #e6a23c;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.explanation-card {
  padding: 24px;
  background: #ecf5ff;
  border-left: 4px solid #409eff;
  border-radius: 8px;
  margin-bottom: 24px;
}

.explanation-card h3 {
  margin: 0 0 12px 0;
  font-size: 18px;
  color: #409eff;
}

.explanation-card p {
  margin: 0;
  font-size: 14px;
  line-height: 1.8;
  color: #606266;
}

.solutions-section {
  padding: 24px;
  background: white;
  border-radius: 8px;
  margin-bottom: 24px;
}

.solutions-section h3 {
  margin: 0 0 20px 0;
  font-size: 18px;
  color: #303133;
}

.solution-list {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.solution-item {
  padding: 20px;
  background: #f5f7fa;
  border-radius: 8px;
}

.solution-item h4 {
  margin: 0 0 12px 0;
  font-size: 16px;
  color: #303133;
}

.code-block {
  padding: 16px;
  background: #282c34;
  color: #abb2bf;
  border-radius: 4px;
  font-size: 13px;
  line-height: 1.6;
  overflow-x: auto;
  margin: 12px 0;
}

.solution-desc {
  margin: 0;
  font-size: 13px;
  color: #606266;
  line-height: 1.6;
}

.errors-section {
  padding: 24px;
  background: white;
  border-radius: 8px;
}

.errors-section h3 {
  margin: 0 0 20px 0;
  font-size: 18px;
  color: #303133;
}

.error-card {
  padding: 16px;
  background: #fef0f0;
  border-left: 4px solid #f56c6c;
  border-radius: 4px;
  margin-bottom: 12px;
}

.error-badge {
  display: inline-block;
  padding: 4px 12px;
  background: #f56c6c;
  color: white;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 700;
  margin-bottom: 8px;
}

.error-message {
  font-size: 14px;
  color: #303133;
  font-weight: 600;
  margin-bottom: 8px;
}

.error-url {
  font-size: 12px;
  color: #606266;
  font-family: 'Courier New', monospace;
  margin-bottom: 4px;
}

.error-time {
  font-size: 12px;
  color: #c0c4cc;
}
</style>

四、SourceMap 错误定位

4.1 SourceMap解析器

sourcemap-parser.js

javascript
// 注意:实际项目中应使用 source-map 库
// npm install source-map

export class SourceMapParser {
  constructor() {
    this.sourceMaps = new Map()
  }

  // 加载SourceMap文件
  async loadSourceMap(jsUrl) {
    try {
      // 1. 从JS文件获取SourceMap URL
      const jsContent = await fetch(jsUrl).then(r => r.text())
      const sourceMapUrl = this.extractSourceMapUrl(jsContent, jsUrl)

      if (!sourceMapUrl) {
        console.warn('No sourcemap found for:', jsUrl)
        return null
      }

      // 2. 加载SourceMap文件
      const sourceMapContent = await fetch(sourceMapUrl).then(r => r.json())

      // 3. 缓存SourceMap
      this.sourceMaps.set(jsUrl, sourceMapContent)

      return sourceMapContent
    } catch (error) {
      console.error('Failed to load sourcemap:', error)
      return null
    }
  }

  // 提取SourceMap URL
  extractSourceMapUrl(jsContent, jsUrl) {
    // 查找 //# sourceMappingURL=xxx.js.map
    const match = jsContent.match(/\/\/# sourceMappingURL=(.+)/)

    if (match) {
      const mapFile = match[1].trim()

      // 处理相对路径
      if (mapFile.startsWith('http')) {
        return mapFile
      } else {
        const baseUrl = jsUrl.substring(0, jsUrl.lastIndexOf('/'))
        return `${baseUrl}/${mapFile}`
      }
    }

    // 尝试默认的.map文件
    return `${jsUrl}.map`
  }

  // 解析错误位置(简化版)
  async parseError(error) {
    if (!error.source || !error.lineno) {
      return error
    }

    // 加载对应的SourceMap
    let sourceMap = this.sourceMaps.get(error.source)

    if (!sourceMap) {
      sourceMap = await this.loadSourceMap(error.source)
    }

    if (!sourceMap) {
      return {
        ...error,
        originalError: null,
        reason: 'SourceMap not found'
      }
    }

    // 简化的映射逻辑(实际应使用source-map库)
    const originalPosition = this.findOriginalPosition(
      sourceMap,
      error.lineno,
      error.colno
    )

    return {
      ...error,
      originalSource: originalPosition.source,
      originalLine: originalPosition.line,
      originalColumn: originalPosition.column,
      originalCode: originalPosition.code
    }
  }

  // 查找原始位置(简化版)
  findOriginalPosition(sourceMap, line, column) {
    // 实际应使用 source-map 库的 SourceMapConsumer
    // 这里只是示例
    return {
      source: sourceMap.sources?.[0] || 'unknown',
      line: line,
      column: column,
      code: null
    }
  }

  // 实际项目中使用 source-map 库的示例
  async parseErrorWithLibrary(error) {
    try {
      // 需要先安装: npm install source-map
      const { SourceMapConsumer } = await import('source-map')

      const sourceMap = this.sourceMaps.get(error.source)
      if (!sourceMap) return error

      const consumer = await new SourceMapConsumer(sourceMap)

      const originalPosition = consumer.originalPositionFor({
        line: error.lineno,
        column: error.colno
      })

      consumer.destroy()

      return {
        ...error,
        originalSource: originalPosition.source,
        originalLine: originalPosition.line,
        originalColumn: originalPosition.column,
        originalName: originalPosition.name
      }
    } catch (err) {
      console.error('SourceMap parse error:', err)
      return error
    }
  }
}

4.2 SourceMap可视化组件

SourceMapViewer.vue

vue
<script setup>
import { ref } from 'vue'
import { SourceMapParser } from './sourcemap-parser.js'

const parser = ref(new SourceMapParser())
const sourceMapUrl = ref('')
const sourceMapContent = ref(null)
const parseResult = ref(null)
const loading = ref(false)

// 示例错误
const exampleError = ref({
  message: 'Cannot read property of undefined',
  source: 'https://example.com/app.min.js',
  lineno: 1,
  colno: 1234
})

// 加载SourceMap
const loadSourceMap = async () => {
  if (!sourceMapUrl.value) return

  loading.value = true

  try {
    const content = await parser.value.loadSourceMap(sourceMapUrl.value)
    sourceMapContent.value = content
  } catch (error) {
    console.error('Load failed:', error)
  } finally {
    loading.value = false
  }
}

// 解析错误
const parseError = async () => {
  loading.value = true

  try {
    const result = await parser.value.parseError(exampleError.value)
    parseResult.value = result
  } catch (error) {
    console.error('Parse failed:', error)
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <div class="sourcemap-viewer">
    <div class="viewer-header">
      <h2>SourceMap 错误定位</h2>
    </div>

    <!-- SourceMap说明 -->
    <div class="info-card">
      <h3>什么是SourceMap?</h3>
      <p>
        SourceMap是一个存储源代码与编译代码对应位置映射关系的文件。
        当JavaScript代码经过压缩、混淆或编译后,错误堆栈中的行号和列号指向的是编译后的代码,
        通过SourceMap可以将其映射回原始源代码位置,方便调试。
      </p>
    </div>

    <!-- 加载SourceMap -->
    <div class="section-card">
      <h3>1. 加载SourceMap文件</h3>
      <div class="input-group">
        <input
          v-model="sourceMapUrl"
          type="text"
          placeholder="输入JS文件URL (如: https://example.com/app.min.js)"
        />
        <button @click="loadSourceMap" :disabled="loading">
          {{ loading ? '加载中...' : '加载' }}
        </button>
      </div>

      <div v-if="sourceMapContent" class="sourcemap-info">
        <div class="info-item">
          <label>版本:</label>
          <span>{{ sourceMapContent.version }}</span>
        </div>
        <div class="info-item">
          <label>源文件数:</label>
          <span>{{ sourceMapContent.sources?.length || 0 }}</span>
        </div>
        <div class="info-item">
          <label>源文件列表:</label>
          <div class="source-list">
            <div v-for="(source, index) in sourceMapContent.sources" :key="index">
              {{ source }}
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- 错误解析 -->
    <div class="section-card">
      <h3>2. 解析错误位置</h3>

      <div class="error-input">
        <h4>编译后的错误信息</h4>
        <div class="input-row">
          <label>文件URL:</label>
          <input v-model="exampleError.source" type="text" />
        </div>
        <div class="input-row">
          <label>行号:</label>
          <input v-model.number="exampleError.lineno" type="number" />
        </div>
        <div class="input-row">
          <label>列号:</label>
          <input v-model.number="exampleError.colno" type="number" />
        </div>
        <button @click="parseError" :disabled="loading">
          解析位置
        </button>
      </div>

      <div v-if="parseResult" class="parse-result">
        <h4>原始代码位置</h4>
        <div class="result-item">
          <label>原始文件:</label>
          <span class="highlight">{{ parseResult.originalSource || '未找到' }}</span>
        </div>
        <div class="result-item">
          <label>原始行号:</label>
          <span class="highlight">{{ parseResult.originalLine || '-' }}</span>
        </div>
        <div class="result-item">
          <label>原始列号:</label>
          <span class="highlight">{{ parseResult.originalColumn || '-' }}</span>
        </div>
      </div>
    </div>

    <!-- 使用建议 -->
    <div class="tips-card">
      <h3>生产环境使用建议</h3>
      <ul>
        <li>不要将SourceMap文件部署到生产环境,以免泄露源代码</li>
        <li>将SourceMap文件上传到错误监控平台的私有存储</li>
        <li>通过构建工具(如webpack)配置只在错误上报时使用SourceMap</li>
        <li>使用Sentry等专业工具自动处理SourceMap映射</li>
      </ul>
    </div>
  </div>
</template>

<style scoped>
.sourcemap-viewer {
  padding: 20px;
  background: #f5f7fa;
  min-height: 100vh;
}

.viewer-header {
  padding: 20px;
  background: white;
  border-radius: 8px;
  margin-bottom: 24px;
}

.viewer-header h2 {
  margin: 0;
  font-size: 24px;
  color: #303133;
}

.info-card,
.section-card,
.tips-card {
  padding: 24px;
  background: white;
  border-radius: 8px;
  margin-bottom: 24px;
}

.info-card {
  background: #ecf5ff;
  border-left: 4px solid #409eff;
}

.info-card h3,
.section-card h3,
.tips-card h3 {
  margin: 0 0 16px 0;
  font-size: 18px;
  color: #303133;
}

.info-card p {
  margin: 0;
  font-size: 14px;
  line-height: 1.8;
  color: #606266;
}

.input-group {
  display: flex;
  gap: 12px;
}

.input-group input {
  flex: 1;
  padding: 10px 14px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  font-size: 14px;
  outline: none;
}

.input-group button {
  padding: 10px 24px;
  background: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.sourcemap-info {
  margin-top: 20px;
  padding: 16px;
  background: #f5f7fa;
  border-radius: 4px;
}

.info-item {
  margin-bottom: 12px;
}

.info-item label {
  font-weight: 600;
  color: #606266;
  margin-right: 8px;
}

.source-list {
  margin-top: 8px;
  padding-left: 20px;
  font-size: 13px;
  color: #909399;
  font-family: 'Courier New', monospace;
}

.error-input,
.parse-result {
  padding: 20px;
  background: #f5f7fa;
  border-radius: 8px;
}

.error-input h4,
.parse-result h4 {
  margin: 0 0 16px 0;
  font-size: 16px;
  color: #303133;
}

.input-row {
  display: grid;
  grid-template-columns: 100px 1fr;
  gap: 12px;
  margin-bottom: 12px;
  align-items: center;
}

.input-row label {
  font-size: 14px;
  color: #606266;
  font-weight: 600;
}

.input-row input {
  padding: 8px 12px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  font-size: 14px;
  outline: none;
}

.error-input button {
  margin-top: 12px;
  padding: 10px 24px;
  background: #67c23a;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.parse-result {
  margin-top: 20px;
  background: #f0f9ff;
  border-left: 4px solid #409eff;
}

.result-item {
  margin-bottom: 12px;
  font-size: 14px;
}

.result-item label {
  font-weight: 600;
  color: #606266;
  margin-right: 8px;
}

.result-item .highlight {
  color: #409eff;
  font-weight: 700;
  font-family: 'Courier New', monospace;
}

.tips-card {
  background: #fdf6ec;
  border-left: 4px solid #e6a23c;
}

.tips-card ul {
  margin: 0;
  padding-left: 24px;
}

.tips-card li {
  font-size: 14px;
  line-height: 2;
  color: #606266;
}
</style>

五、简历描述模板

前端错误监控系统开发 (2023.10 - 2024.02)

负责构建公司前端错误监控体系,实现全局错误捕获、Promise错误监控和SourceMap错误定位,日均捕获错误1000+条。

核心职责

  • 开发全局错误监控系统,捕获JS错误、Promise错误、资源加载错误和Vue框架错误
  • 实现错误去重和聚合算法,将相同错误合并,减少90%的重复上报
  • 解决跨域脚本错误(Script error)问题,通过配置crossorigin实现详细错误信息获取
  • 集成SourceMap解析功能,将压缩代码错误定位到源代码位置
  • 设计错误分级和告警机制,对严重错误实时推送钉钉通知
技术实现
  • 使用window.onerror和addEventListener('error')捕获不同类型错误
  • 监听unhandledrejection事件捕获未处理的Promise错误
  • 封装Vue errorHandler统一处理Vue组件错误
  • 使用Beacon API确保错误数据可靠上报
  • 集成source-map库实现SourceMap解析
项目成果
  • 错误捕获覆盖率达到98%,每日捕获1000+错误
  • 通过错误聚合,将重复错误减少90%,节省存储成本
  • SourceMap定位准确率95%,大幅提升问题排查效率
  • 线上Bug修复时间从2天缩短到4小时

六、SOP标准回答

面试问题: 如何实现完整的前端错误监控?

标准回答

"我实现的错误监控系统主要分为四层。

第一层是错误捕获。我使用了三种方式:window.onerror捕获JS运行时错误,能拿到错误信息、文件、行号、列号和堆栈;addEventListener('error', true)用捕获阶段监听资源加载错误,比如图片、脚本加载失败;unhandledrejection监听Promise里没有catch的错误。另外还集成了Vue的errorHandler处理组件错误。

第二层是错误处理。首先做去重,我对比错误类型、消息、文件、行号列号,如果完全相同就认为是重复错误,只保留一条。然后做分类,按jsError、promiseError、resourceError、vueError分类。还会添加上下文信息,比如用户UA、当前URL、时间戳等。

第三层是错误分析。对于压缩后的代码,我会用SourceMap将错误位置映射回源代码。具体是从JS文件的sourceMappingURL注释找到.map文件,下载后用source-map库的SourceMapConsumer.originalPositionFor方法,传入行号列号,就能得到原始文件名、原始行号和函数名。

第四层是错误上报。我用Beacon API上报,它的优点是即使页面关闭也能保证数据发送。如果浏览器不支持,降级到fetch with keepalive。上报时会做批量处理,积累10条或者30秒触发一次,减少请求次数。

比较特殊的是跨域脚本错误。浏览器出于安全只显示Script error,没有详细信息。解决方法是给script标签加crossorigin='anonymous'属性,同时让CDN服务器返回Access-Control-Allow-Origin头。这样就能拿到完整错误信息了。

最终效果是错误捕获率98%,通过错误聚合减少了90%重复上报,配合SourceMap定位,Bug修复时间从2天缩短到4小时。"


七、难点与亮点分析

难点1: 如何区分JS错误和资源错误?

问题场景: window.addEventListener('error')既能监听JS错误,也能监听资源错误,需要区分处理。

解决方案

javascript
window.addEventListener('error', (event) => {
  // 判断是否为资源错误
  const target = event.target || event.srcElement

  // 资源错误的target不是window
  if (target !== window) {
    // 这是资源加载错误
    handleResourceError({
      tagName: target.tagName,
      src: target.src || target.href,
      outerHTML: target.outerHTML.substring(0, 200)
    })
  } else {
    // 这是JS错误(但通常由window.onerror处理)
    handleJSError(event)
  }
}, true) // 使用捕获阶段
关键点
  • 使用捕获阶段(true)才能捕获资源错误
  • 通过event.target区分错误类型
  • 资源错误的target是具体的HTML元素

难点2: Promise错误的完整捕获

问题场景: async/await语法中的错误可能不会触发unhandledrejection。

解决方案

javascript
// 监听未处理的rejection
window.addEventListener('unhandledrejection', (event) => {
  handlePromiseError({
    message: event.reason?.message || String(event.reason),
    stack: event.reason?.stack,
    promise: event.promise
  })

  event.preventDefault()
})

// 监听已处理但后来又reject的情况
window.addEventListener('rejectionhandled', (event) => {
  console.log('Promise rejection handled later:', event.promise)
})

// 包装async函数自动捕获
function wrapAsync(fn) {
  return async function(...args) {
    try {
      return await fn.apply(this, args)
    } catch (error) {
      errorMonitor.captureError(error, {
        type: 'asyncError',
        function: fn.name
      })
      throw error
    }
  }
}

亮点1: 智能错误聚合

创新点

  • 根据错误特征自动聚合
  • 统计错误发生频率
  • 识别批量错误
实现
javascript
class ErrorAggregator {
  constructor() {
    this.groups = new Map()
  }

  // 生成错误指纹
  generateFingerprint(error) {
    const parts = [
      error.type,
      error.message,
      error.source,
      error.lineno,
      error.colno
    ]

    return parts.join('|')
  }

  // 聚合错误
  aggregate(error) {
    const fingerprint = this.generateFingerprint(error)

    if (!this.groups.has(fingerprint)) {
      this.groups.set(fingerprint, {
        fingerprint,
        firstError: error,
        count: 0,
        lastTime: 0,
        affectedUsers: new Set()
      })
    }

    const group = this.groups.get(fingerprint)
    group.count++
    group.lastTime = error.timestamp
    group.affectedUsers.add(error.userId)

    return group
  }

  // 获取高频错误
  getHighFrequencyErrors(threshold = 10) {
    return Array.from(this.groups.values())
      .filter(group => group.count >= threshold)
      .sort((a, b) => b.count - a.count)
  }
}

亮点2: 错误影响面分析

创新点

  • 统计错误影响的用户数
  • 分析错误发生的页面分布
  • 计算错误严重程度评分
实现
javascript
class ErrorImpactAnalyzer {
  analyzeImpact(errorGroup) {
    const impact = {
      affectedUsers: errorGroup.affectedUsers.size,
      occurrence: errorGroup.count,
      timeSpan: errorGroup.lastTime - errorGroup.firstError.timestamp,
      severity: 0
    }

    // 计算严重程度评分 (0-100)
    let score = 0

    // 影响用户数权重 40%
    score += Math.min(impact.affectedUsers / 100, 1) * 40

    // 发生频率权重 30%
    score += Math.min(impact.occurrence / 1000, 1) * 30

    // 错误类型权重 30%
    const typeScores = {
      jsError: 30,
      vueError: 25,
      promiseError: 20,
      resourceError: 10
    }
    score += typeScores[errorGroup.firstError.type] || 10

    impact.severity = Math.round(score)
    impact.level = this.getSeverityLevel(score)

    return impact
  }

  getSeverityLevel(score) {
    if (score >= 80) return 'critical'
    if (score >= 60) return 'high'
    if (score >= 40) return 'medium'
    return 'low'
  }
}