可观测性篇 | 错误监控
前言
上一章我们聊完了格式化与 Lint——保证代码风格一致。从这一章开始,我们进入可观测性这个新模块,聊一个对线上质量至关重要的话题:错误监控——知道页面”有没有出问题”。
你有没有遇到过这样的场景:用户在群里反馈”页面白屏了”、“按钮点了没反应”,你打开自己的电脑试了一下,一切正常。然后你问用户”能截个图吗?”、“打开控制台看看报什么错?“——用户发来一张手机截图,你盯着低分辨率的照片试图辨认错误信息。
这就是没有错误监控系统的日常。等用户来告诉你出了 bug,你已经输了。
一套成熟的前端错误监控系统,应该做到:错误自动捕获、堆栈自动还原、关键错误自动报警、同类错误自动聚合。这在面试中是一个非常加分的话题——它说明你不只关注”功能能不能用”,还关注”线上质量怎么保障”。
本章,我们从错误类型、捕获方式、堆栈还原、聚合去重、监控平台、报警策略六个维度,把前端错误监控的完整知识体系讲清楚。
诊断自测
Q1:window.onerror 和 window.addEventListener('error', ...) 有什么区别?哪个能捕获资源加载错误?
点击查看答案
window.onerror 是一个属性赋值式的全局错误处理器,只能捕获 JS 运行时错误,无法捕获资源加载错误(如图片、脚本加载失败)。window.addEventListener('error', handler, true) 通过在捕获阶段监听 error 事件,可以同时捕获 JS 运行时错误和资源加载错误。资源加载错误(如 <img> 的 src 404)产生的 error 事件不会冒泡,所以必须在捕获阶段(第三个参数为 true)才能监听到。
Q2:为什么跨域脚本的错误信息只显示 "Script error." 而没有具体的错误堆栈?怎么解决?
点击查看答案
这是浏览器的安全策略。当一个脚本从不同源加载时(比如 CDN),如果发生错误,浏览器不会把详细的错误信息暴露给宿主页面,而是统一报告 "Script error."——因为错误堆栈可能包含敏感信息。解决方法有两步:(1)给 <script> 标签加上 crossorigin="anonymous" 属性;(2)CDN 服务器的响应头需要包含 Access-Control-Allow-Origin。两个条件缺一不可。
Q3:线上代码是经过压缩和混淆的,错误堆栈里的行号列号完全对不上源码。怎么还原?
点击查看答案
通过 Source Map 还原。构建工具在打包时生成 .map 文件,记录了压缩代码和源码之间的映射关系。当捕获到错误堆栈后,在服务端使用 Source Map 解析库(如 source-map 包)根据压缩后的行号列号反查出源码的文件名、行号、列号和函数名。重要的是:Source Map 文件绝对不能部署到生产环境,否则任何人都能看到你的源码。它应该只存在于错误监控的后端服务中。
一、前端错误的四大类型
要做好错误监控,首先得知道前端到底有哪些类型的错误。
1.1 JS 运行时错误
这是最常见的前端错误,包括:
// TypeError:最常见
const obj = null;
obj.name; // TypeError: Cannot read properties of null (reading 'name')
// ReferenceError:使用未声明的变量
console.log(undeclaredVar); // ReferenceError: undeclaredVar is not defined
// RangeError:数值超出范围
new Array(-1); // RangeError: Invalid array length
// SyntaxError:语法错误(通常在编译阶段就被捕获,运行时较少见)
eval('const a ='); // SyntaxError: Unexpected end of input
// URIError:URI 处理函数参数错误
decodeURIComponent('%'); // URIError: URI malformed
在生产环境中,TypeError 占据了所有 JS 错误的绝大多数(通常超过 70%)。最典型的场景就是后端接口返回的数据结构和前端预期不一致,导致 Cannot read properties of undefined。
1.2 资源加载错误
<!-- 图片加载失败 -->
<img src="https://cdn.example.com/logo.png" />
<!-- 脚本加载失败 -->
<script src="https://cdn.example.com/app.js"></script>
<!-- 样式加载失败 -->
<link rel="stylesheet" href="https://cdn.example.com/style.css" />
资源加载失败不会触发 window.onerror,因为它产生的 error 事件不会冒泡到 window。这也是为什么很多”自以为做了错误监控”的系统实际上完全漏掉了资源加载错误。
资源加载失败可能导致:
- 页面样式错乱(CSS 加载失败)
- 页面功能异常(JS 加载失败)
- 内容缺失(图片、字体加载失败)
1.3 Promise 未捕获错误
// 场景一:忘了写 catch
fetch('/api/data')
.then(res => res.json())
.then(data => {
// 如果这里报错,没有 catch 来兜底
processData(data);
});
// 场景二:async/await 没有 try-catch
async function loadData() {
const res = await fetch('/api/data'); // 如果网络错误,直接抛出
const data = await res.json();
return data;
}
loadData(); // 调用时没有 catch,错误就"溜走了"
Promise 未捕获错误在现代前端应用中越来越常见,因为 async/await 让异步代码”看起来像同步”,很容易忘记加错误处理。如果不做全局的 unhandledrejection 监听,这类错误会完全”无声无息”地发生——不会触发 window.onerror,控制台虽然会打印警告,但生产环境谁看控制台呢?
1.4 接口错误(HTTP 请求错误)
// HTTP 状态码错误
fetch('/api/data').then(res => {
if (!res.ok) {
// 注意:fetch 只在网络错误时才 reject
// 4xx、5xx 不会 reject,需要手动检查
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res.json();
});
// 业务状态码错误
fetch('/api/data')
.then(res => res.json())
.then(data => {
if (data.code !== 0) {
// 接口返回了业务错误
throw new Error(`Business Error: ${data.code} - ${data.message}`);
}
});
接口错误需要区分几个层次:
| 错误层次 | 举例 | 监控方式 |
|---|---|---|
| 网络错误 | 断网、DNS 解析失败、超时 | fetch reject / XHR onerror |
| HTTP 错误 | 404、500、502、503 | 检查 response.status |
| 业务错误 | 接口返回 code: -1 | 检查响应体的业务状态码 |
在实践中,接口错误往往通过封装统一的请求层来监控,而不是靠全局 error 监听。
二、错误捕获方式全解
知道了有哪些错误类型,下一步是”怎么捕获它们”。
2.1 try-catch:局部的同步错误捕获
try {
const data = JSON.parse(invalidJson);
} catch (error) {
// error.name: 错误类型,如 'SyntaxError'
// error.message: 错误描述
// error.stack: 完整的堆栈信息
reportError(error);
}
局限性:
- 只能捕获同步代码中的错误
- 无法捕获异步回调中的错误(除非用 async/await)
- 需要手动在每个可能出错的地方加上 try-catch,不可能覆盖所有代码
// ❌ 捕获不到
try {
setTimeout(() => {
throw new Error('async error');
}, 0);
} catch (e) {
// 不会进入这里
}
// ✅ 使用 async/await 可以捕获
try {
const data = await fetchData();
} catch (e) {
reportError(e);
}
try-catch 的定位是”精确捕获已知可能出错的代码”,而不是”全局兜底”。
2.2 window.onerror:全局 JS 错误兜底
window.onerror = function(message, source, lineno, colno, error) {
// message: 错误信息字符串
// source: 出错脚本的 URL
// lineno: 出错行号
// colno: 出错列号
// error: Error 对象(包含完整堆栈)
reportError({
type: 'js_error',
message,
source,
lineno,
colno,
stack: error?.stack
});
// 返回 true 可以阻止浏览器默认的错误输出
return true;
};
注意事项:
- 跨域脚本 只会报
"Script error.",不给详细信息。解决方法:<script>加crossorigin="anonymous",服务器加Access-Control-Allow-Origin window.onerror是属性赋值,如果多处赋值会互相覆盖。需要注意初始化顺序- 无法捕获资源加载错误和 Promise 未捕获错误
2.3 addEventListener(‘error’):捕获资源加载错误
// 必须在捕获阶段监听(第三个参数为 true)
window.addEventListener('error', (event) => {
const target = event.target || event.srcElement;
// 区分 JS 错误和资源加载错误
if (target instanceof HTMLScriptElement ||
target instanceof HTMLLinkElement ||
target instanceof HTMLImageElement) {
// 这是资源加载错误
reportError({
type: 'resource_error',
tagName: target.tagName,
src: target.src || target.href,
outerHTML: target.outerHTML?.substring(0, 200)
});
}
// JS 运行时错误会同时触发这里和 window.onerror
// 为了避免重复上报,JS 错误统一用 window.onerror 处理
}, true); // ← 注意这个 true
为什么资源加载错误必须在捕获阶段监听?
因为资源加载错误的 error 事件不会冒泡。它只在产生错误的元素上触发,然后就停止了,不会冒泡到 window。但在捕获阶段,事件是从 window 往下传播的,所以可以在 window 的捕获阶段截获到。
这也是面试中一个非常经典的考点——“事件冒泡与捕获”在实际场景中的应用。
2.4 unhandledrejection:Promise 错误兜底
window.addEventListener('unhandledrejection', (event) => {
// event.reason 就是 reject 的值,通常是一个 Error 对象
const error = event.reason;
reportError({
type: 'promise_error',
message: error?.message || String(error),
stack: error?.stack,
// 有些情况下 reason 不是 Error 对象
// 比如 Promise.reject('something went wrong')
reason: typeof error === 'object' ? error.message : String(error)
});
// 可以阻止浏览器默认的 unhandled rejection 警告
event.preventDefault();
});
一个容易忽略的场景:
// 这个错误不会被 window.onerror 捕获
// 只有 unhandledrejection 能兜住它
async function init() {
const config = await loadConfig();
startApp(config);
}
init(); // 如果 loadConfig() reject 了,就是一个 unhandled rejection
2.5 框架级别的错误捕获
现代前端框架都提供了自己的错误捕获机制:
React:ErrorBoundary
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// error: 错误对象
// errorInfo.componentStack: React 组件堆栈
reportError({
type: 'react_error',
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack
});
}
render() {
if (this.state.hasError) {
return <FallbackUI />;
}
return this.props.children;
}
}
注意:ErrorBoundary 无法捕获以下几类错误:
- 事件处理器中的错误(需要自己 try-catch)
- 异步代码中的错误(setTimeout、Promise)
- 服务端渲染中的错误
- ErrorBoundary 自身的错误
Vue:errorHandler
app.config.errorHandler = (error, instance, info) => {
reportError({
type: 'vue_error',
message: error.message,
stack: error.stack,
componentName: instance?.$options?.name,
lifecycleHook: info
});
};
完整的错误捕获方案
一个生产级的错误监控,通常会同时使用以上所有方式,形成多层防线:
function initErrorMonitoring() {
// 第一层:全局 JS 错误
window.onerror = function(message, source, lineno, colno, error) {
report({ type: 'js', message, source, lineno, colno, stack: error?.stack });
return true;
};
// 第二层:资源加载错误
window.addEventListener('error', (event) => {
const target = event.target;
if (target !== window && (target.src || target.href)) {
report({ type: 'resource', tag: target.tagName, url: target.src || target.href });
}
}, true);
// 第三层:Promise 未捕获错误
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason;
report({ type: 'promise', message: error?.message, stack: error?.stack });
});
// 第四层:框架错误(React / Vue)
// ...根据使用的框架添加
}
三、Source Map 还原与安全
在生产环境中,代码通常经过压缩和混淆:
// 源码
function calculateTotalPrice(items, discount) {
return items.reduce((sum, item) => sum + item.price, 0) * (1 - discount);
}
// 压缩后
function a(b,c){return b.reduce((d,e)=>d+e.price,0)*(1-c)}
如果线上报了一个错误 TypeError: Cannot read properties of undefined (reading 'price') at a (app.js:1:42)——这个 a 函数在第 1 行第 42 列,你根本不知道是源码中的哪个函数。
Source Map 的工作原理
Source Map 是一个 JSON 文件,记录了压缩代码和源码之间的位置映射关系:
{
"version": 3,
"file": "app.min.js",
"sources": ["src/utils/price.js"],
"sourcesContent": ["function calculateTotalPrice..."],
"names": ["calculateTotalPrice", "items", "discount", ...],
"mappings": "AAAA,SAASA,EAAoBC,..."
}
其中 mappings 字段使用 VLQ(Variable Length Quantity)编码,记录了每个位置的映射关系。
还原流程
1. 前端捕获到错误:TypeError at app.min.js:1:42
2. 把错误信息发送到监控后端
3. 后端根据 app.min.js 找到对应的 app.min.js.map
4. 使用 source-map 库解析:
- 输入:行 1,列 42
- 输出:src/utils/price.js,行 3,列 52,函数名 calculateTotalPrice
5. 展示还原后的错误堆栈给开发者
Node.js 中的还原代码示例:
const { SourceMapConsumer } = require('source-map');
async function resolveStack(stackFrame, sourceMapContent) {
const consumer = await new SourceMapConsumer(sourceMapContent);
const originalPosition = consumer.originalPositionFor({
line: stackFrame.line,
column: stackFrame.column
});
consumer.destroy(); // 释放 WASM 内存
return {
file: originalPosition.source,
line: originalPosition.line,
column: originalPosition.column,
functionName: originalPosition.name
};
}
Source Map 的安全问题
Source Map 文件绝对不能暴露在生产环境中。
如果用户能访问到你的 .map 文件,就等于把你的完整源码(包括注释、变量名、业务逻辑)公开了。这是严重的安全隐患。
安全实践:
- 构建时生成 Source Map,但不部署到 CDN
// webpack 配置
module.exports = {
devtool: 'hidden-source-map' // 生成 .map 文件但不在 JS 中添加 sourceMappingURL 注释
};
- 把 Source Map 上传到监控平台的私有存储
# Sentry CLI 上传示例
sentry-cli releases files <release> upload-sourcemaps ./dist --url-prefix '~/static/js'
-
构建完成后从部署产物中删除 .map 文件
-
如果必须保留 sourceMappingURL,用内网地址
//# sourceMappingURL=https://internal.example.com/maps/app.js.map
四、错误聚合与去重
一个未处理的错误在高流量页面上可能一秒钟触发几千次。如果每次都上报,不仅浪费带宽,还会把监控后端打爆。所以错误数据必须经过聚合和去重。
4.1 前端去重
在上报之前,前端先做一层去重:
class ErrorReporter {
constructor() {
this.reported = new Set();
this.buffer = [];
}
report(error) {
// 生成错误指纹
const fingerprint = this.generateFingerprint(error);
// 重复的错误不再上报
if (this.reported.has(fingerprint)) return;
this.reported.add(fingerprint);
// 限制去重集合大小,防止内存泄漏
if (this.reported.size > 100) {
const firstKey = this.reported.values().next().value;
this.reported.delete(firstKey);
}
this.buffer.push({ ...error, fingerprint, timestamp: Date.now() });
this.scheduleFlush();
}
generateFingerprint(error) {
// 用错误类型 + 消息 + 第一行堆栈信息生成指纹
const firstFrame = error.stack?.split('\n')[1]?.trim() || '';
return `${error.type}:${error.message}:${firstFrame}`;
}
scheduleFlush() {
// 批量上报逻辑...
}
}
4.2 后端聚合
前端只能做同一页面实例内的去重。跨用户、跨时间的聚合需要在后端完成:
聚合维度:
- 错误指纹:相同类型 + 相同消息 + 相同堆栈(还原后)→ 视为同一个错误
- 时间窗口:同一个错误在一定时间窗口内的所有发生次数
- 影响范围:有多少个独立用户(UV)受到了这个错误的影响
后端聚合后的展示通常是这样的:
TypeError: Cannot read properties of null (reading 'username')
at UserProfile.render (src/components/UserProfile.jsx:42:18)
at ...
| 首次出现 | 最近出现 | 发生次数 | 影响用户数 | 状态 |
| 2024-03-01 | 2024-03-09 | 12,384 | 3,241 | 未解决 |
4.3 上报频率控制
除了去重,还需要控制上报频率:
class RateLimiter {
constructor(maxPerMinute = 30) {
this.maxPerMinute = maxPerMinute;
this.count = 0;
// 每分钟重置计数
setInterval(() => {
this.count = 0;
}, 60000);
}
canReport() {
if (this.count >= this.maxPerMinute) return false;
this.count++;
return true;
}
}
const limiter = new RateLimiter(30);
function reportError(error) {
if (!limiter.canReport()) {
// 超过频率限制,丢弃或降级处理
return;
}
// 正常上报...
}
五、Sentry 的工作原理
Sentry 是目前最流行的前端错误监控平台之一(开源),面试中经常被提及。理解它的工作原理,有助于你回答”你们的错误监控是怎么做的”这类问题。
5.1 SDK 做了什么
Sentry 的前端 SDK(@sentry/browser)在初始化时做了以下事情:
import * as Sentry from '@sentry/browser';
Sentry.init({
dsn: 'https://key@sentry.example.com/project-id',
release: '1.0.0',
environment: 'production',
sampleRate: 1.0, // 错误采样率
tracesSampleRate: 0.2, // 性能采样率
});
初始化后,SDK 会:
- 劫持全局错误处理:注册
window.onerror、window.addEventListener('error')、unhandledrejection - 劫持(Monkey Patch)浏览器 API:比如
fetch、XMLHttpRequest、console方法、setTimeout/setInterval——这样它能追踪请求、收集面包屑(Breadcrumbs) - 收集上下文信息:用户信息、浏览器信息、操作系统、URL 等
- 维护面包屑队列:记录错误发生前的用户操作轨迹(点击了什么按钮、访问了什么页面、发了什么请求)
5.2 一条错误的完整生命周期
1. 用户页面发生 TypeError
↓
2. 被 SDK 注册的全局错误处理器捕获
↓
3. SDK 收集错误信息:
- Error 对象(message、stack)
- 面包屑(最近的 20 条操作记录)
- 上下文(URL、浏览器、OS、用户信息)
- Release 版本号
↓
4. SDK 对错误进行指纹计算(Fingerprinting)
↓
5. 通过 HTTP POST 发送到 Sentry 后端(DSN 地址)
↓
6. Sentry 后端接收到事件:
- 根据 Release 找到对应的 Source Map
- 还原错误堆栈
- 根据指纹进行聚合(合并为 Issue)
- 判断是否触发报警规则
↓
7. 开发者在 Sentry Dashboard 看到:
- 错误详情(还原后的堆栈)
- 面包屑(用户操作轨迹)
- 影响范围(用户数、发生次数)
- 首次/最近出现时间
5.3 面包屑(Breadcrumbs)
面包屑是 Sentry 非常有价值的一个功能。它记录了错误发生前用户的操作轨迹,帮助你还原”错误是怎么触发的”:
14:23:01 [navigation] /home → /profile
14:23:02 [ui.click] button#load-more
14:23:02 [http] GET /api/user/123 → 200
14:23:03 [http] GET /api/posts?user=123 → 500
14:23:03 [console] Error: Failed to load posts
14:23:03 [error] TypeError: Cannot read properties of null (reading 'map')
看到这个面包屑,你立刻就知道了:用户从首页进入个人页,点了”加载更多”,接口返回了 500,后续代码用 null 调用了 .map() 方法。
5.4 Sentry 的 Scope 机制
// 全局 Scope:设置所有事件共享的上下文
Sentry.setUser({ id: '123', email: 'user@example.com' });
Sentry.setTag('page', 'checkout');
// 局部 Scope:只影响当前作用域内的事件
Sentry.withScope((scope) => {
scope.setExtra('orderData', orderData);
scope.setLevel('warning');
Sentry.captureException(error);
});
六、错误报警策略
有了错误数据,还得有合理的报警策略。太灵敏会造成”报警疲劳”(狼来了效应),太迟钝又会错过重大故障。
6.1 报警维度
| 报警类型 | 条件示例 | 紧急程度 |
|------------|----------------------------------|---------|
| 新错误出现 | 某个从未见过的错误首次发生 | 中 |
| 错误量激增 | 某错误在 5 分钟内出现次数超过阈值 | 高 |
| 影响用户激增 | 某错误在 1 小时内影响用户数超过阈值 | 高 |
| 错误率突变 | 页面错误率从 0.1% 突然飙升到 5% | 紧急 |
| 关键页面错误 | 支付页面 / 登录页面出现任何 JS 错误 | 紧急 |
6.2 合理设置阈值
// 伪代码:报警规则配置示例
const alertRules = [
{
name: '新错误报警',
condition: (issue) => issue.isNew,
cooldown: '1h', // 同一个 issue 1 小时内只报一次
channel: 'slack'
},
{
name: '错误量突增',
condition: (issue) => {
const recent = issue.countInLastMinutes(5);
const baseline = issue.averageCountPer5Min();
return recent > baseline * 10; // 是平均值的 10 倍
},
cooldown: '30min',
channel: 'pagerduty' // 高紧急度用 PagerDuty
},
{
name: '关键页面错误',
condition: (event) => {
const criticalPages = ['/checkout', '/login', '/payment'];
return criticalPages.some(p => event.url.includes(p));
},
cooldown: '10min',
channel: 'phone' // 电话通知
}
];
6.3 报警治理
报警系统运行一段时间后,往往会出现大量”噪音”报警。需要定期治理:
- 该忽略的忽略:比如浏览器插件注入的错误、爬虫触发的错误
- 该降级的降级:已知但暂时无法修复的错误,降为低优先级
- 该修复的修复:如果一个报警一直在响,说明你应该修复它而不是调高阈值
// Sentry 中忽略特定错误
Sentry.init({
ignoreErrors: [
// 浏览器扩展注入的错误
/Extensions/,
/chrome-extension/,
// 已知的第三方脚本错误
'ResizeObserver loop limit exceeded',
// 网络波动导致的错误
'Network request failed',
'Failed to fetch',
'Load failed',
],
denyUrls: [
// 忽略第三方脚本的错误
/google-analytics\.com/,
/googletagmanager\.com/,
]
});
常见误区
误区一:“用了 window.onerror 就能捕获所有前端错误”
这是最常见的误解。window.onerror 只能捕获同步的 JS 运行时错误。它捕获不了:资源加载错误(需要用 addEventListener('error', ..., true) 在捕获阶段监听)、Promise 未捕获错误(需要 unhandledrejection)、异步错误中某些边界情况。一个完整的错误监控至少需要三道防线配合使用。
误区二:“有了 Sentry 就不需要关注错误监控的实现细节了”
Sentry 确实是一个很好的工具,但”拿来就用”和”用好”是两回事。如果你不理解 Source Map 的上传和安全策略,线上错误堆栈永远是压缩后的乱码。如果你不理解采样和频率控制,大流量网站可能会因为错误上报把 Sentry 打爆(或者超出配额、账单暴涨)。如果你不理解面包屑的机制,就无法有效地添加自定义面包屑来辅助排查。工具是手段,理解原理才是目的。
误区三:“前端错误率为零是正常的”
如果你的错误监控显示错误率为零,大概率不是你的代码完美无缺,而是你的监控有漏洞。可能是跨域脚本全都变成了 Script error. 被过滤了,可能是 Promise 错误没有监听 unhandledrejection,可能是采样率设得太低。“零错误”不是好消息,而是一个需要警惕的信号。
误区四:“Source Map 直接放在 CDN 上,方便调试”
绝对不行。Source Map 包含了你的完整源码,放在公开可访问的地方等于开源了你的代码。正确的做法是使用 hidden-source-map(不在 JS 中写入 sourceMappingURL),然后把 .map 文件上传到 Sentry 等监控平台的私有存储中,只有在后端还原堆栈时才使用。
小结
本章我们系统梳理了前端错误监控的完整知识体系。
核心要点
- 四大错误类型:JS 运行时错误、资源加载错误、Promise 未捕获错误、接口错误——每种都需要不同的捕获方式
- 三道捕获防线:
window.onerror(JS 错误)、addEventListener('error', ..., true)(资源错误)、unhandledrejection(Promise 错误) - Source Map 还原:构建时生成、上传到私有存储、后端解析还原——绝不暴露在生产环境
- 错误聚合去重:前端指纹去重 + 后端聚合为 Issue + 频率控制
- Sentry 核心机制:全局劫持 + API Monkey Patch + 面包屑 + Source Map 还原 + 指纹聚合
- 报警策略:新错误、量突增、影响面大、关键页面——分级报警、避免疲劳
本章思维导图
- 错误类型
- JS 运行时错误(TypeError 占大多数)
- 资源加载错误(不冒泡,需捕获阶段监听)
- Promise 未捕获错误(unhandledrejection)
- 接口错误(网络层 / HTTP 层 / 业务层)
- 错误捕获
- try-catch:局部同步错误
- window.onerror:全局 JS 错误兜底
- addEventListener('error', ..., true):资源加载错误
- unhandledrejection:Promise 未捕获
- 框架级别:React ErrorBoundary / Vue errorHandler
- Source Map
- 原理:压缩代码 ↔ 源码的位置映射
- 还原流程:前端上报 → 后端匹配 .map → 解析还原
- 安全:hidden-source-map + 私有存储,绝不上 CDN
- 聚合与去重
- 前端:指纹去重(type + message + stack 首行)
- 后端:按指纹聚合为 Issue
- 频率控制:防止短时间内大量上报
- 监控平台(Sentry)
- SDK 初始化:劫持全局错误 + Monkey Patch API
- 面包屑:记录错误前的用户操作轨迹
- Scope:全局 / 局部上下文附加
- Source Map 上传与还原
- 报警策略
- 分级:新错误、量突增、关键页面
- 报警治理:忽略噪音、定期清理
练习挑战
第一题 ⭐:区分错误捕获方式
以下四种错误分别应该用什么方式捕获?
- 页面中一个
<img>标签的 src 指向了一个不存在的 URL - 用户点击按钮时,回调函数里访问了
undefined.name - 一个
async函数中await fetch()失败了,但调用方没有.catch() - React 组件的
render()方法中抛出了错误
点击查看答案
window.addEventListener('error', handler, true)(资源加载错误,必须在捕获阶段监听,window.onerror捕获不到)window.onerror(JS 运行时错误,全局兜底)或者事件回调内的try-catchwindow.addEventListener('unhandledrejection', handler)(Promise 未捕获错误,window.onerror捕获不到)- React ErrorBoundary 的
componentDidCatch(React 内部的错误在渲染阶段会被 ErrorBoundary 捕获;但如果没有 ErrorBoundary,错误最终也会触发window.onerror)
第二题 ⭐⭐:实现错误指纹生成
请实现一个 generateFingerprint(error) 函数,要求:
- 对于相同的错误(类型、消息、调用栈一致),生成相同的指纹
- 对于不同的错误,尽量生成不同的指纹
- 忽略堆栈中的行号和列号变化(因为每次构建后行号可能变化)
点击查看答案
function generateFingerprint(error) {
const type = error.name || 'UnknownError';
const message = error.message || '';
// 从堆栈中提取函数调用链,忽略行号列号
let callChain = '';
if (error.stack) {
callChain = error.stack
.split('\n')
.slice(1, 4) // 取前 3 帧
.map(line => {
// " at functionName (file.js:123:45)"
// → 提取出 "functionName" 或 "file.js"
const match = line.match(/at\s+(.+?)\s+\(/) || line.match(/at\s+(.+?):\d+:\d+/);
return match ? match[1].trim() : line.trim();
})
.join('|');
}
const raw = `${type}:${message}:${callChain}`;
// 简单的字符串哈希
let hash = 0;
for (let i = 0; i < raw.length; i++) {
const char = raw.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString(36);
}
核心思路:
- 用错误类型 + 错误消息 + 函数调用链(不含行号列号)作为指纹素材
- 只取堆栈前 3 帧,因为深层调用栈可能因入口不同而不同,但核心出错位置通常在前几帧
- 从堆栈帧中只提取函数名或文件名,忽略行号列号,这样代码重新构建后指纹不会变
- 最终做一次字符串哈希,得到一个简短的指纹字符串
第三题 ⭐⭐⭐:设计一个完整的错误上报 SDK
请设计(写伪代码或核心代码即可)一个错误上报 SDK,要求:
- 能捕获 JS 错误、资源加载错误、Promise 未捕获错误
- 支持错误去重(同一个错误在同一页面只上报一次)
- 支持频率控制(每分钟最多上报 N 条)
- 支持面包屑(记录最近 20 条用户操作)
- 使用 sendBeacon + visibilitychange 进行上报
点击查看答案
class ErrorMonitorSDK {
constructor(options) {
this.dsn = options.dsn;
this.maxBreadcrumbs = options.maxBreadcrumbs || 20;
this.maxReportsPerMinute = options.maxReportsPerMinute || 30;
this.breadcrumbs = [];
this.reportedFingerprints = new Set();
this.reportCount = 0;
this.buffer = [];
this._resetRateLimit();
this._initCapture();
this._initBreadcrumbs();
this._initFlush();
}
// ---- 错误捕获 ----
_initCapture() {
// JS 运行时错误
window.onerror = (message, source, lineno, colno, error) => {
this._handleError({
type: 'js_error',
message,
source,
lineno,
colno,
stack: error?.stack
});
};
// 资源加载错误
window.addEventListener('error', (event) => {
const target = event.target;
if (target !== window && (target.src || target.href)) {
this._handleError({
type: 'resource_error',
tagName: target.tagName,
url: target.src || target.href
});
}
}, true);
// Promise 未捕获错误
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
this._handleError({
type: 'promise_error',
message: reason?.message || String(reason),
stack: reason?.stack
});
});
}
// ---- 面包屑 ----
_initBreadcrumbs() {
// 点击事件
document.addEventListener('click', (e) => {
const target = e.target;
this._addBreadcrumb({
type: 'click',
data: {
tag: target.tagName,
id: target.id,
className: target.className?.toString().substring(0, 50),
text: target.textContent?.substring(0, 30)
}
});
}, true);
// 路由变化
const originalPushState = history.pushState;
history.pushState = (...args) => {
this._addBreadcrumb({
type: 'navigation',
data: { from: location.href, to: args[2] }
});
return originalPushState.apply(history, args);
};
// console.error
const originalError = console.error;
console.error = (...args) => {
this._addBreadcrumb({
type: 'console',
data: { level: 'error', message: args.map(String).join(' ') }
});
return originalError.apply(console, args);
};
}
_addBreadcrumb(crumb) {
this.breadcrumbs.push({
...crumb,
timestamp: Date.now()
});
if (this.breadcrumbs.length > this.maxBreadcrumbs) {
this.breadcrumbs.shift();
}
}
// ---- 核心处理 ----
_handleError(error) {
// 1. 生成指纹并去重
const fingerprint = this._fingerprint(error);
if (this.reportedFingerprints.has(fingerprint)) return;
// 2. 频率控制
if (this.reportCount >= this.maxReportsPerMinute) return;
this.reportedFingerprints.add(fingerprint);
this.reportCount++;
// 3. 组装上报数据
this.buffer.push({
...error,
fingerprint,
breadcrumbs: [...this.breadcrumbs],
context: {
url: location.href,
userAgent: navigator.userAgent,
timestamp: Date.now()
}
});
}
_fingerprint(error) {
const key = `${error.type}:${error.message}:${(error.stack || '').split('\n')[1] || ''}`;
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = ((hash << 5) - hash) + key.charCodeAt(i);
hash = hash & hash;
}
return hash.toString(36);
}
// ---- 上报 ----
_initFlush() {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.flush();
});
setInterval(() => this.flush(), 30000);
}
flush() {
if (this.buffer.length === 0) return;
const data = JSON.stringify(this.buffer.splice(0));
if (navigator.sendBeacon) {
navigator.sendBeacon(this.dsn, data);
} else {
fetch(this.dsn, { method: 'POST', body: data, keepalive: true });
}
}
_resetRateLimit() {
setInterval(() => { this.reportCount = 0; }, 60000);
}
}
// 使用
const monitor = new ErrorMonitorSDK({
dsn: '/api/errors',
maxBreadcrumbs: 20,
maxReportsPerMinute: 30
});
这个 SDK 覆盖了题目要求的所有功能点。在实际生产中还需要考虑:Source Map 上传、用户标识关联、采样率控制、SDK 自身的错误隔离(避免监控代码本身的错误导致页面崩溃)等。
自我检测
读完本章后,对照下面的清单检验自己的掌握程度:
- 能列出前端的四大错误类型,并说明每种类型的典型场景
- 能区分
window.onerror和addEventListener('error', ..., true)的捕获范围,并解释为什么资源加载错误必须在捕获阶段监听 - 能解释跨域脚本为什么只报
"Script error.",以及解决方法 - 能说出
unhandledrejection的用途和它与window.onerror的区别 - 能解释 Source Map 的工作原理,并说出为什么不能把
.map文件部署到生产环境 - 能设计一个错误指纹算法,实现同类错误的聚合去重
- 能描述 Sentry SDK 初始化后做了哪些事情(全局劫持、Monkey Patch、面包屑收集)
- 能说出面包屑(Breadcrumbs)的作用和它记录哪些类型的操作
- 能设计合理的错误报警策略,包括分级报警和报警治理
- 能实现一个基础的错误上报 SDK,包含错误捕获、去重、频率控制和上报功能
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90