一、技术实现方案
1.1 错误监控架构
错误监控系统
├── 错误捕获层
│ ├── 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
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
<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
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
<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
// 注意:实际项目中应使用 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
<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错误,也能监听资源错误,需要区分处理。
解决方案
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。
解决方案
// 监听未处理的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: 智能错误聚合
创新点
- 根据错误特征自动聚合
- 统计错误发生频率
- 识别批量错误
实现
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: 错误影响面分析
创新点
- 统计错误影响的用户数
- 分析错误发生的页面分布
- 计算错误严重程度评分
实现
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'
}
}