前端错误监控与处理实战指南
深入探讨了前端开发中常见的错误类型及监控方案。内容涵盖浏览器错误捕获机制(window.onerror, Promise rejection)、资源加载失败处理、网络错误监控封装、SourceMap 堆栈还原、用户行为录屏以及性能优化策略。同时介绍了错误分级处理、优雅降级方案、CI/CD 集成测试以及 TypeScript 和单元测试在预防错误中的应用。旨在帮助开发者建立完善的错误监控体系,降低线上故障影响。

深入探讨了前端开发中常见的错误类型及监控方案。内容涵盖浏览器错误捕获机制(window.onerror, Promise rejection)、资源加载失败处理、网络错误监控封装、SourceMap 堆栈还原、用户行为录屏以及性能优化策略。同时介绍了错误分级处理、优雅降级方案、CI/CD 集成测试以及 TypeScript 和单元测试在预防错误中的应用。旨在帮助开发者建立完善的错误监控体系,降低线上故障影响。

说真的,干前端这些年,我算是看明白了——咱们这行就是个和错误谈恋爱的过程。刚开始你看见控制台飘红就慌得一批,到后来看见报错甚至能笑出声,这种心态转变没个三五年的深夜加班根本练不出来。
谁还没在凌晨三点被 Sentry 报警电话叫醒过?那感觉,心脏直接从胸腔蹦到嗓子眼,比当年初恋对象发"在吗"还刺激。你迷迷糊糊摸过手机,屏幕亮起的瞬间看到"Production Error: TypeError: Cannot read property 'map' of undefined",整个人瞬间就清醒了。这时候你脑子里闪过一万个念头:是不是昨天那个 hotfix 没测全?还是某个接口突然改了返回格式?又或者,是老板正在看着监控大屏准备杀人?
我见过最离谱的一次,大促期间某个核心链路突然崩了,报错信息就一句话:"undefined is not a function"。就这一句,没堆栈,没上下文,跟猜谜似的。团队五个人盯着日志看了两小时,最后发现是某个 CDN 节点缓存了个旧版本的 polyfill,新代码调用了一个不存在的方法。你说这上哪说理去?浏览器它也不告诉你"嘿兄弟,你加载的脚本版本不对",它就给你扔个 undefined,剩下的自己悟去吧。
还有很多人觉得,不就是报错吗,加个 try-catch 不就完了?太天真了。我见过太多这种"保险主义者",代码里 try-catch 套得跟俄罗斯套娃似的,结果用户该白屏还是白屏。为啥?因为 try-catch 只能抓同步错误,那些藏在 setTimeout 里的、躲在 Promise 里的、埋在 async/await 里的错误,该漏还是漏。更坑的是,有时候你 catch 住了错误,但后续逻辑依赖了前面失败的数据,程序没崩,但跑出来的结果全是错的,用户看着满屏的乱码比直接白屏还崩溃。
所以咱们今天不整那些虚头巴脑的理论,就唠唠怎么把那些神出鬼没的 Bug 按在地上摩擦。都是血泪换来的经验,看完能让你少熬几个大夜。
要搞定错误监控,你得先知道敌人长啥样。浏览器的错误机制说复杂也复杂,说简单也简单,但里面的坑是真的多。
全局错误捕获,大家第一个想到的都是 window.onerror。这玩意儿确实能抓不少东西,但你要是以为有了它就高枕无忧,那等着被坑吧。
// 最基础的用法,但说实话,这代码写了跟没写差不多
window.onerror = function(message, source, lineno, colno, error) {
console.log('抓到错误了:', message);
return true; // 返回 true 阻止浏览器默认报错(就是控制台不飘红)
};
// 稍微像样点的版本,至少能把信息攒一攒
window.onerror = function(message, source, lineno, colno, error) {
// 有些老浏览器可能不给 error 对象,得做个兼容
const errorInfo = {
message: message,
source: source,
line: lineno,
column: colno,
stack: error && error.stack ? error.stack : 'no stack trace',
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
// 加个页面 URL,不然你都不知道用户在哪个页面炸的
pageUrl: window.location.href
};
// 发送到监控服务,注意这里要用 try-catch,别监控代码自己崩了
try {
sendToMonitoring(errorInfo);
} catch (e) {
// 实在发不出去,至少存 localStorage,下次有机会再发
localStorage.setItem('pending_error_' + Date.now(), JSON.stringify(errorInfo));
}
return false; // 建议返回 false,让控制台继续飘红,方便开发时看到
};
但 window.onerror 有个巨坑——它抓不到跨域脚本的错误。啥意思呢?你引了个 CDN 上的 jQuery,那里面出错了,onerror 收到的信息就是"Script error",其他啥也没有。这是浏览器的安全策略,怕你别有用心地探测其他域的脚本内容。要解决这个问题,得两边配合:CDN 那边要加 Access-Control-Allow-Origin 头,你这边 script 标签要加 crossorigin="anonymous"。
<!-- 不加 crossorigin,报错信息会被浏览器吞掉 -->
<script src="https://cdn.example.com/app.js"></script>
<!-- 加了 crossorigin,配合 CDN 的 CORS 头,才能拿到详细错误 -->
<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>
很多人喜欢在代码里 console.error 一下,觉得这样就算处理了错误。兄弟,console.error 只是打印到控制台,它既不阻止程序崩溃,也不通知你去处理。更关键的是,生产环境谁看控制台啊?用户的浏览器控制台你看得见吗?
而且异步错误这东西,真的是个幽灵。你看这段代码:
// 这种错误,window.onerror 根本抓不到
setTimeout(() => {
throw new Error('我在异步里爆炸了');
}, 1000);
// Promise 里的错误,onerror 也看不见
Promise.resolve().then(() => {
throw new Error('Promise 内部错误');
});
// async/await 如果没包 try-catch,也是悄无声息地挂
async function fetchData() {
const response = await fetch('/api/data');
// 如果这里网络错误,整个函数就停了
const data = await response.json();
// 如果返回的不是 JSON,这里又炸
return data;
}
这些异步错误,window.onerror 一个都抓不到。它们就像深夜里的刺客,悄无声息地把你的程序干掉,然后消失在事件循环的黑暗中。
Promise 如果没处理 rejection,那简直就是埋了个地雷。而且这地雷还有个特性——专门挑你上线那天炸。
// 这种写法,如果 fetch 失败,Promise 就处于 rejected 状态,但没人管
function getUserInfo(userId) {
return fetch(`/api/user/${userId}`).then(res => res.json());
}
// 调用的时候没 catch,错误就漏了
getUserInfo(123).then(data => {
renderUser(data);
// 如果前面 reject 了,这行根本不会执行
});
// 没有 .catch(),错误就被"吞"了(其实浏览器会报 unhandledrejection,但很多人忽略这个)
// 正确的姿势应该是这样
getUserInfo(123).then(data => renderUser(data)).catch(err => {
console.error('获取用户信息失败:', err);
showErrorToast('用户信息加载失败,请重试');
// 甚至可以上报错误
reportError({
type: 'API_ERROR',
api: '/api/user/123',
error: err.message
});
});
而且现代浏览器对 unhandled rejection 的处理越来越严格。以前可能只是个警告,现在有些环境直接给你把进程杀了。所以一定得加上全局的 unhandledrejection 监听:
window.addEventListener('unhandledrejection', function(event) {
// event.reason 就是 reject 的原因(通常是 Error 对象)
const errorInfo = {
type: 'unhandled_promise_rejection',
message: event.reason?.message || 'Unknown promise rejection',
stack: event.reason?.stack || 'No stack',
timestamp: new Date().toISOString()
};
// 上报错误
reportError(errorInfo);
// 阻止浏览器默认行为(比如在控制台打印)
event.preventDefault();
});
// 还有个 rejectionhandled 事件,用来监听"迟到"的 catch
window.addEventListener('rejectionhandled', function(event) {
// 这个很少用,但知道有这个东西就行
console.log('Promise rejection was handled later:', event.reason);
});
图片挂了、CSS 404 了、脚本加载失败,这些错误 window.onerror 也抓不到。你得用专门的 error 事件监听:
// 监听图片加载失败
document.addEventListener('error', function(event) {
const target = event.target;
// 判断是不是图片
if (target.tagName === 'IMG') {
console.error('图片加载失败:', target.src);
// 可以搞个兜底图,别让页面丑着
target.src = '/assets/fallback-image.png';
// 上报错误,带上图片 URL
reportError({
type: 'RESOURCE_ERROR',
resourceType: 'image',
url: target.src,
pageUrl: window.location.href
});
}
// 判断是不是脚本
if (target.tagName === 'SCRIPT') {
reportError({
type: 'RESOURCE_ERROR',
resourceType: 'script',
url: target.src,
// 脚本错误比较严重,可能需要立即通知
severity: 'high'
});
}
}, true);
// 注意这里要用捕获阶段,因为 error 事件不冒泡
// 还有个专门的 window.addEventListener('error') 可以抓资源错误
window.(, () {
.(, event.);
}, );
但说实话,资源错误的堆栈信息基本为零,你就只能知道"某个 URL 加载失败了",至于为啥失败(是 404?还是超时?还是被 CORS 拦截了?),得靠猜。这时候 network 条件的上下文就特别重要,后面会讲到怎么收集这些信息。
知道了浏览器怎么"发疯",咱们就得给代码穿上防弹衣。但防弹衣也不能乱穿,穿太厚影响行动(性能),穿太薄又防不住子弹(漏报)。
大家通常只关心 JS 逻辑错误,但线上问题一大半其实是网络相关的。接口超时、返回 500、DNS 解析失败,这些"外家功夫"不防,用户该骂娘还是骂娘。
// 封装一个带监控的 fetch,别直接用原生 fetch
class MonitoredFetch {
constructor(options = {}) {
this.timeout = options.timeout || 10000; // 默认 10 秒超时
this.retries = options.retries || 1; // 默认重试 1 次
this.baseURL = options.baseURL || '';
}
async request(url, options = {}) {
const fullURL = this.baseURL + url;
const startTime = performance.now();
let attemptCount = 0;
const executeRequest = async () => {
attemptCount++;
try {
// 创建 AbortController 用于超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(fullURL, {
...options,
signal: controller.signal
});
(timeoutId);
duration = performance.() - startTime;
.(fullURL, duration, response.);
(!response.) {
(, response., fullURL);
}
response;
} (error) {
(error. === ) {
(, fullURL);
}
(error..() || error..()) {
(error., fullURL);
}
error;
}
};
lastError;
( i = ; i < .; i++) {
{
();
} (error) {
lastError = error;
(error && error. >= && error. < ) {
error;
}
(i < . - ) {
.( * (i + ));
}
}
}
.(lastError, {
: fullURL,
: options. || ,
attemptCount,
: performance.() - startTime
});
lastError;
}
() {
(.) {
.();
}
}
() {
errorInfo = {
: ,
: error..,
: error.,
: context.,
: context.,
: context.,
: context.,
: navigator.,
: navigator. ? {
: navigator..,
: navigator..,
: navigator..
} :
};
(errorInfo);
}
() {
( (resolve, ms));
}
}
{
() {
(message);
. = ;
. = status;
. = url;
}
}
{
() {
(message);
. = ;
. = url;
}
}
{
() {
(message);
. = ;
. = url;
}
}
api = ({
: ,
: ,
:
});
api.().( res.()).( .(data)).( {
(err ) {
();
} (err ) {
();
}
});
看到没,网络监控不只是记个错,还得把当时的网络环境、重试次数、耗时都记下来。这样你排查问题的时候才能还原现场,而不是对着"请求失败"四个字发呆。
生产环境的代码都是压缩过的,报错信息长这样:at t.e (app.abc123.js:1:2345)。你看着这一堆 a.b.c,根本不知道是哪个文件哪行代码。这时候 SourceMap 就是你的救命稻草。
但 SourceMap 怎么用,这里面有讲究。你不能直接把 SourceMap 文件扔到生产环境,因为那样等于把源码公开了(虽然可以配置只映射到行不映射到列,但还是能反推个大概)。所以通常的做法是:
// webpack 配置示例,怎么生成 SourceMap
// webpack.config.js
module.exports = {
// 生产环境用这个,生成单独的 map 文件,但不包含源码(只映射行列)
devtool: 'hidden-source-map',
// 或者用这个,更安全,只映射到行,不映射到列,反编译难度大一些
// devtool: 'nosources-source-map',
output: {
filename: '[name].[contenthash].js',
// 很重要:sourceMapFilename 要配置好
sourceMapFilename: 'sourcemaps/[name].[contenthash].js.map'
}
};
// 如果你用 Vite
// vite.config.js
export default {
build: {
sourcemap: 'hidden', // 生成但不引用
// 或者
// sourcemap: true, // 开发时用
}
};
然后在错误监控服务里,你需要实现一个堆栈还原的功能。如果你用 Sentry,它自动就做了。如果自己实现,大概长这样:
// 简化的堆栈还原逻辑,实际要用 source-map 库
const { SourceMapConsumer } = require('source-map');
async function unminifyStackTrace(stackTrace, sourceMapContent) {
const consumer = await new SourceMapConsumer(sourceMapContent);
const lines = stackTrace.split('\n');
const unminifiedLines = lines.map(line => {
// 解析压缩后的位置,比如 "at t.e (app.abc123.js:1:2345)"
const match = line.match(/at\s+(.+?)\s+\((.+):(\d+):(\d+)\)/);
if (!match) return line;
const [, funcName, fileName, lineNum, colNum] = match;
// 用 SourceMap 还原
const originalPosition = consumer.originalPositionFor({
line: parseInt(lineNum),
column: parseInt(colNum)
});
if (originalPosition.source) {
return `at ${originalPosition.name || funcName} (${originalPosition.source}:${originalPosition.line}:${originalPosition.column})`;
}
return line;
});
consumer.destroy();
return unminifiedLines.join('\n');
}
minifiedStack = ;
sourceMap = fs.(, );
(minifiedStack, sourceMap).( .(originalStack));
光知道哪行代码错了还不够,你得知道当时用户干了啥、页面啥状态、网络啥情况。这就需要"录屏"功能——不是真的录视频,而是记录用户操作和页面变化。
// 简化的用户行为录屏实现,用 rrweb 的思路
class SessionRecorder {
constructor(options = {}) {
this.events = [];
this.maxEvents = options.maxEvents || 1000; // 限制数量,别撑爆内存
this.isRecording = false;
}
start() {
if (this.isRecording) return;
this.isRecording = true;
// 记录初始 DOM 状态
this.recordEvent({
type: 'init',
timestamp: Date.now(),
// 这里应该序列化 DOM,简化示例就记个 URL
url: window.location.href,
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
userAgent: navigator.userAgent
});
// 监听点击
this.clickHandler = (e) => {
.({
: ,
: .(),
: .(e.),
: e.,
: e.
});
};
.(, ., );
. = {
(e.. === || e.. === ) {
.({
: ,
: .(),
: .(e.),
: e.. === ? : e...(, )
});
}
};
.(, ., );
. = {
.({
: ,
: .(),
: ..
});
};
.(, .);
. = ( {
.({
: ,
: .(),
: performance. ? {
: performance..,
: performance..
} : ,
: .()
});
}, );
}
() {
tag = element..();
id = element. ? : ;
classes = element. && element. === ? : ;
text = element. ? element..(, ) : ;
;
}
() {
(!.) [];
[];
}
() {
(.. >= .) {
..();
}
..(event);
}
() {
{
: ..(-),
: .()
};
}
() {
. = ;
.(, ., );
.(, ., );
.(, .);
(.);
}
}
recorder = ({ : });
recorder.();
.(, {
context = recorder.();
({
: e.,
: e.?.,
: context.,
: ()
});
});
这种"录屏"数据配合错误堆栈,你排查问题的时候就跟看监控录像似的,用户点了哪、输入了啥、页面怎么变的,一目了然。但要注意隐私合规,密码、身份证号这些敏感信息一定要脱敏。
监控代码本身不能成为性能瓶颈。我见过有团队为了"全量监控",在每个函数开头结尾都插桩,结果主线程直接堵死,页面卡顿得要命。
// 错误的示范:这种写法,函数多了直接卡爆
function heavyFunction() {
monitor.startTrack('heavyFunction');
// 这里可能有 DOM 操作,阻塞渲染
// ... 业务逻辑
monitor.endTrack('heavyFunction');
}
// 正确的姿势:采样 + 异步上报
class SmartMonitor {
constructor(options = {}) {
this.sampleRate = options.sampleRate || 0.1; // 只监控 10% 的请求
this.batchSize = options.batchSize || 10; // 批量上报,攒够 10 条发一次
this.buffer = [];
this.flushInterval = options.flushInterval || 5000; // 最多 5 秒发一次
this.isSending = false;
// 定时刷新
setInterval(() => this.flush(), this.flushInterval);
// 页面关闭前强制发送(用 sendBeacon,可靠)
window.addEventListener('beforeunload', () => this.flush(true));
}
track() {
(.() > .) ;
..({ ...event, : .() });
(.. >= .) {
(.) {
( .(), { : });
} {
( .(), );
}
}
}
() {
(.. === || .) ;
. = ;
data = [....];
. = [];
(isBeacon && navigator.) {
blob = ([.(data)], { : });
navigator.(, blob);
. = ;
} {
(, {
: ,
: { : },
: .(data),
:
}).( {
(.. < ) {
..(...data);
}
}).( {
. = ;
});
}
}
}
monitor = ({ : });
() {
monitor.({ : , : });
monitor.({ : , : , : });
}
关键点:采样(别全量)、批量(别一条条发)、异步(别卡主线程)、降级(sendBeacon)。这样监控代码对性能的影响可以忽略不计。
监控这东西,用好了是神器,用不好就是给自己挖坑。我见过太多团队监控搞得太激进,结果问题没解决,先制造了一堆新问题。
前面说了性能优化,但还有些更隐蔽的坑。比如有些监控库为了"精确计时",用 performance.now() 在每次 DOM 操作前后打戳,结果频繁的时钟查询本身就成为性能瓶颈。
还有些库为了获取"完整的错误上下文",在报错时遍历整个 DOM 树,序列化成字符串。如果页面很大(比如那种无限滚动的长列表),这一下就能卡死好几秒。
// 危险操作:报错时遍历整个 DOM
function getFullDOMContext() {
return document.documentElement.outerHTML; // 大页面这里直接爆炸,内存和 CPU 双杀
}
// 相对安全的做法:只取关键元素
function getSafeContext() {
const context = {
url: window.location.href,
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
// 只取 body 的直接子元素数量,判断页面复杂度
bodyChildrenCount: document.body.children.length,
// 当前聚焦的元素
activeElement: document.activeElement?.tagName,
// 滚动位置
scrollY: window.scrollY
};
return context;
}
另外,监控代码本身也可能报错。比如你想记录 navigator.connection 的网络状态,但某些浏览器不支持这个 API,直接抛错。所以监控代码里要再套 try-catch,形成"监控的监控":
// 防御性编程:监控代码也要被保护
function safeMonitor(fn) {
return function (...args) {
try {
return fn.apply(this, args);
} catch (e) {
// 监控代码出错,至少别影响业务
console.error('Monitor error:', e);
// 甚至可以上报"监控器故障",但小心死循环
if (window.__monitorFailed) {
window.__monitorFailed = true;
// 简化版的上报,避免再用复杂的监控逻辑
fetch('/monitor/self-error', {
method: 'POST',
body: JSON.stringify({ error: e.message }),
keepalive: true
});
}
}
};
}
// 包装所有监控方法
const originalTrack = monitor.track;
monitor.track = safeMonitor(originalTrack);
如果你不做采样和聚合,生产环境的海量报错能把你的监控服务冲垮。想象一下,一个 bug 影响了 10 万用户,每人触发 10 次,这就是 100 万条错误日志。存起来、索引起来、查询起来,都是钱。
所以一定要有分级和聚合策略:
// 错误聚合:相同的错误只记一次,但记次数
class ErrorAggregator {
constructor() {
this.errorMap = new Map(); // 用 Map 做内存中的聚合
this.flushInterval = 60000; // 1 分钟同步一次
setInterval(() => this.syncToServer(), this.flushInterval);
}
// 生成错误指纹,用来判断是不是同一个错误
getFingerprint(error) {
const stack = error.stack || '';
const stackLines = stack.split('\n').slice(0, 3);
// 用正则去掉具体的行号列号,只保留函数名和文件名
const normalizedStack = stackLines.map(line =>
line.replace(/:\d+:\d+/g, '') // 去掉:12:34 这种
).join('|');
// 加上错误类型和消息
return `${error.name}:${error.message}:${normalizedStack}`;
}
report(error, context = {}) {
const fingerprint = .(error);
now = .();
(..(fingerprint)) {
record = ..(fingerprint);
record.++;
record. = now;
(record.. < ) {
record..(context);
}
} {
..(fingerprint, {
fingerprint,
: error.,
: error.,
: error.,
: now,
: now,
: ,
: [context]
});
}
}
() {
(.. === ) ;
errors = .(..());
..();
(, {
: ,
: { : },
: .({
: .(),
: errors,
: errors.( sum + e., ),
: errors.
})
});
}
}
aggregator = ();
.(, {
aggregator.(e., {
: ..,
: ()
});
});
这样你存的不是 100 万条重复记录,而是几百条聚合记录,每条带个计数器。查询的时候也能一眼看出哪个错误影响面最大。
如果监控太敏感,一堆无关紧要的警告会把真正的 P0 级错误淹没。比如第三方脚本的小错误、浏览器插件注入的代码错误、用户网络不稳定导致的请求失败,这些如果都报上来,团队很快就麻木了。
// 错误过滤策略
function shouldReportError(error) {
// 1. 过滤掉已知的无害错误(比如某些浏览器扩展的锅)
const knownHarmlessPatterns = [
/ResizeObserver loop limit exceeded/, // Chrome 的 benign error
/Script error\.?/, // 跨域脚本错误,信息太少,通常处理不了
/Non-Error promise rejection captured/ // 某些框架的警告
];
for (const pattern of knownHarmlessPatterns) {
if (pattern.test(error.message)) return false;
}
// 2. 过滤掉第三方脚本的错误(如果你确定不管)
if (error.stack) {
const isThirdParty = error.stack.split('\n').some(line =>
line.includes('chrome-extension://') ||
line.includes('moz-extension://') ||
line.includes('https://third-party-analytics.com')
);
if (isThirdParty) return false;
}
// 3. 只报自己域名的错误
const isOwnDomain = error.stack && error.stack.includes(window.location.hostname);
if (!isOwnDomain) ;
fingerprint = (error);
count = (fingerprint);
(count > ) {
.() < ;
}
;
}
.(, {
(!(e.)) ;
(e.);
});
过滤策略要持续维护,根据线上实际情况调整。定期 review 错误列表,把那些"无害但 noisy"的错误加入黑名单。
监控搭好了,怎么用是个学问。不能所有错误都一视同仁,得有分级处理机制。
错误得分级,就像医院分急诊和门诊一样。有些错误记下来就行,有些得立即通知,有些得直接触发回滚。
// 错误分级系统
class ErrorSeverityClassifier {
classify(error, context = {}) {
// P0:核心流程阻断,必须立即处理
if (this.isP0Error(error, context)) {
return {
level: 'P0',
action: 'immediate_alert', // 立即电话/短信通知
autoRollback: true, // 考虑自动回滚
notifyChannels: ['pagerduty', 'slack', 'email']
};
}
// P1:重要功能受损,15 分钟内响应
if (this.isP1Error(error, context)) {
return {
level: 'P1',
action: 'urgent_alert',
responseTime: 15 * 60 * 1000, // 15 分钟
notifyChannels: ['slack', 'email']
};
}
// P2:次要功能问题,当天处理
if (this.isP2Error(error, context)) {
return {
level: 'P2',
action: 'daily_digest', // 日报汇总
: []
};
}
{
: ,
: ,
: []
};
}
() {
(context. === && error..()) {
;
}
(context.?.() && context. >= ) {
(context. > ) ;
}
(error.?.() || error.?.()) {
(context.) ;
}
;
}
() {
(context. === || context. === ) {
;
}
(error.?.()) ;
;
}
() {
(context. === ) ;
(error. === && error. === ) {
;
}
;
}
}
() {
(errorInfo. !== ) ;
recentErrors = ( * * );
(recentErrors. < ) ;
.();
(, {
: ,
: { : + },
: .({
: ,
:
})
});
({
: ,
: errorInfo,
: .()
});
}
分级策略要根据业务特点定制。电商网站的支付错误是 P0,内容网站的推荐算法错误可能就是 P2。这个得和团队一起梳理清楚。
现代前端都是 SPA,一个组件挂了可能导致整个页面白屏。这时候得有降级策略,让页面"瘸腿"也能跑。
// React 的错误边界示例,但思路适用于所有框架
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// 下次渲染显示降级 UI
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// 上报错误
reportError({
error: error,
componentStack: errorInfo.componentStack,
componentName: this.props.componentName || 'unknown'
});
// 根据错误类型决定降级策略
if (this.props.critical) {
// 关键组件,整个页面降级
this.setState({ fallbackType: 'fullPage' });
} else {
// 非关键组件,只降级这个组件
this.setState({ : });
}
}
() {
(..) {
(.. === ) {
(
);
}
(
);
}
..;
}
}
() {
(
);
}
() {
(
);
}
对于非 React 项目,可以用类似的思路:
// Vue3 的错误处理
app.config.errorHandler = (err, instance, info) => {
console.error('Vue Error:', err);
// 上报
reportError({
error: err,
component: instance?.$options?.name || 'anonymous',
info: info // 比如 "render function"
});
// 根据严重程度决定要不要 toast 提示用户
if (isCriticalError(err)) {
showGlobalError('页面出现严重错误,建议刷新');
}
};
// 或者更细粒度的,在组件内
export default {
name: 'SafeComponent',
methods: {
riskyOperation() {
try {
// 可能出错的操作
} catch (e) {
// 捕获并处理,不让错误冒泡
this.handleError(e);
}
}
},
errorCaptured(err, instance, info) {
// 捕获子组件错误
console.log('子组件出错:', err);
return false; // 返回 false 阻止错误继续传播
}
};
监控是最后一道防线,更好的办法是在代码提交前就发现问题。配合 CI/CD 流水线,可以自动跑异常模拟测试。
// Cypress E2E 测试示例,模拟各种异常情况
describe('异常场景测试', () => {
it('应该优雅处理 API 500 错误', () => {
// 拦截 API 请求,强制返回 500
cy.intercept('GET', '/api/user/profile', {
statusCode: 500,
body: { error: 'Internal Server Error' }
}).as('getProfile');
cy.visit('/profile');
cy.wait('@getProfile');
// 验证页面没有白屏,显示了错误提示
cy.get('[data-testid="error-toast"]').should('be.visible');
cy.get('[data-testid="retry-button"]').should('be.enabled');
});
it('应该处理网络超时', () => {
// 让请求永远 pending,模拟超时
cy.intercept('GET', '/api/slow-endpoint', (req) => {
// 不回复,让请求超时
}).as('slowRequest');
cy.visit('/slow-page');
// 等待超时时间(比如 5 秒)
cy.wait(5000);
// 验证显示了超时提示
cy.contains().();
});
(, {
cy.();
cy.().( {
win.();
});
cy.().(, );
cy.().();
});
(, {
cy.(, , { : }).();
cy.();
cy.();
cy.().( {
cy.($img).(, ).(, );
});
});
});
这些自动化测试能帮你发现"明显会崩"的场景。虽然不能覆盖所有错误,但至少能保证核心流程在异常情况下不挂。
最烦人的就是这种玄学问题。本地好好的,一上线就崩;测试环境没问题,生产环境就报错。这时候怎么排查?
首先,得有足够的上下文。用户 ID、时间戳、浏览器版本、操作系统,这些基础信息必须有。
// 增强版错误上报,带上丰富的上下文
function getRichContext() {
return {
// 用户标识(脱敏)
userId: getUserId(), // 或者匿名 ID
sessionId: getSessionId(),
// 时间信息
timestamp: Date.now(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
// 浏览器环境
userAgent: navigator.userAgent,
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
screen: {
width: screen.width,
height: screen.height,
colorDepth: screen.colorDepth
},
devicePixelRatio: window.devicePixelRatio,
// 网络状况
connection: navigator.connection ? {
effectiveType: navigator.connection.effectiveType,
downlink: navigator.connection.downlink,
rtt: navigator..,
: navigator..
} : ,
: navigator.,
: ..,
: .,
: .,
: {
: performance. ? performance.. - performance.. : ,
: performance. ? performance.. - performance.. : ,
: performance. ? {
: performance..,
: performance..,
: performance..
} :
},
: performance. ? performance.().(-).( ({
: r.,
: r.,
: r.
})) : [],
: getReduxState ? (()) : ,
: ()
};
}
recentLogs = [];
= ;
[, , , ].( {
original = [method];
[method] = () {
recentLogs.({
method,
: args.( {
{
arg === ? .(arg).(, ) : (arg);
} (e) {
;
}
}),
: .()
});
(recentLogs. > ) {
recentLogs.();
}
original.(, args);
};
});
() {
recentLogs;
}
.(, {
({
: {
: e.,
: e.?.
},
: ()
});
});
有了这些信息,你可以按用户 ID 查日志,看他在报错前都干了啥。也可以按时间戳聚合,看是不是某个时间点集中爆发(可能是 CDN 节点问题、服务器发布问题)。
内存泄漏最难搞,因为它不会立即报错,而是让页面越来越卡,最后崩溃。而且本地开发时通常不会长时间运行,很难发现。
// 内存监控工具
class MemoryLeakDetector {
constructor() {
this.measurements = [];
this.checkInterval = 30000; // 30 秒检查一次
this.growthThreshold = 1.5; // 内存增长超过 50% 就报警
this.startMonitoring();
}
startMonitoring() {
if (!performance.memory) {
console.warn('当前浏览器不支持内存监控');
return;
}
setInterval(() => this.checkMemory(), this.checkInterval);
}
checkMemory() {
const memory = performance.memory;
const usedMB = memory.usedJSHeapSize / 1048576;
const totalMB = memory.totalJSHeapSize / 1048576;
this.measurements.push({
timestamp: Date.now(),
usedMB: usedMB,
totalMB: totalMB,
url: window..
});
(.. > ) {
..();
}
.();
}
() {
(.. < ) ;
recent = ..(-);
first = recent[].;
last = recent[].;
growth = last / first;
(growth > .) {
.({
: growth,
: first,
: last,
: recent,
: .().,
: .()
});
}
}
() {
;
}
() {
({
: ,
: ,
: details,
:
});
(details. > ) {
();
}
}
}
leakDetector = ();
更专业的做法是用 Chrome 的 Performance 面板手动分析,但生产环境你没法让用户帮你开 DevTools。所以这种自动监控是必要的补充。
现在的网站都依赖一堆第三方脚本:统计、广告、客服、支付……这些脚本如果挂了,可能拖慢甚至拖垮你的页面。
// 第三方脚本加载管理器
class ThirdPartyManager {
constructor() {
this.scripts = new Map();
this.timeouts = {
'analytics': 5000, // 统计脚本 5 秒超时
'chat-widget': 10000, // 客服组件 10 秒
'payment': 15000 // 支付脚本 15 秒,这个重要可以多等会儿
};
}
async loadScript(name, url, options = {}) {
const timeout = options.timeout || this.timeouts[name] || 5000;
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.async = true;
// 超时熔断
const timer = setTimeout(() => {
script.remove();
this.scripts.set(name, { status: 'timeout', loadedAt: });
( ());
}, timeout);
script. = {
(timer);
..(name, { : , : .() });
(script);
};
script. = {
(timer);
..(name, { : , : });
( ());
};
(options.) {
.(name, url, resolve, reject);
} {
..(script);
}
}).( {
.(, err);
({
: ,
: name,
: err.
});
;
});
}
() {
iframe = .();
iframe.. = ;
iframe. = ;
iframe. = {
iframeDoc = iframe.;
script = iframeDoc.();
script. = url;
script. = (iframe);
script. = ( ());
iframeDoc..(script);
};
..(iframe);
}
() {
script = ..(name);
script && script. === ;
}
}
tpm = ();
tpm.(, , {
:
});
tpm.(, , {
:
}).( {
();
}).( {
();
});
() {
(tpm.()) {
..(event);
} {
.(, event);
}
}
关键点:超时熔断(别无限等)、失败降级(别阻塞主流程)、可选加载(非关键脚本可以没有)。这样第三方脚本挂了,你的页面还能正常跑。
有时候用户反馈问题,就给你一张模糊的截图,上面半个错误信息。这时候怎么引导他们提供有用的信息?
首先,你的错误页面本身要设计得能提供信息:
<!-- 友好的错误页面,带诊断信息 -->
<div class="error-container">
<h1>哎呀,出错了</h1>
<p>我们已经记录了这个问题,技术团队正在处理</p>
<!-- 给高级用户的诊断信息 -->
<details class="diagnostics">
<summary>技术详情(点击展开,反馈给客服时有用)</summary>
<div class="error-code">
<p>错误 ID: <span id="error-id">ERR-20240315-ABC123</span></p>
<p>时间:<span id="error-time">2024-03-15 14:32:01</span></p>
<p>页面:<span id="error-page">/checkout/payment</span></p>
<button onclick="copyDiagnostics()">复制诊断信息</>
刷新页面
联系客服
其次,客服话术要培训好。别问"你遇到什么问题"这种开放式问题,要问"错误 ID 是多少"、"报错时你在哪个页面"、"有没有看到红色的错误提示"。
说了这么多监控和排查的,最好的错误处理是不出错。这里分享几个从源头减少错误的野路子。
别只是把 TS 当 JS 用,要利用它的严格模式。
// tsconfig.json 严格配置
{
"compilerOptions": {
"strict": true, // 开启所有严格选项
"noImplicitAny": true, // 禁止隐式 any
"strictNullChecks": true, // 严格检查 null/undefined
"noUncheckedIndexedAccess": true, // 索引访问可能 undefined
"exactOptionalPropertyTypes": true, // 区分 undefined 和可选属性
"noImplicitReturns": true, // 函数所有分支必须有返回值
"noFallthroughCasesInSwitch": true, // switch case 必须有 break
"noUncheckedSideEffectImports": true // 检查副作用导入
}
}
// 实际例子:严格类型能避免一堆运行时错误
// 不好的写法:隐式 any,后面调用时不知道有没有这个方法
function processData(data) {
return data.map(item => item.name); // 如果 data 不是数组,这里就炸
}
// 好的写法:明确类型,编译时就发现问题
interface User {
id: number;
name: string;
email: string;
}
function processData(data: User[]): string[] {
// TS 会保证 data 是数组,item 有 name 属性
return data.map(item => item.name);
}
// 更严格的:处理边界情况
function safeProcessData(data: User[] | undefined | null): string[] {
// 开启 strictNullChecks 后,必须处理 undefined/null
if (!data) {
return []; // 或者抛出错误,但至少显式处理了
}
// 开启 noUncheckedIndexedAccess 后,连数组索引都要检查
const first = data[0];
if (first) {
// 必须检查,因为 data[0] 可能是 undefined
console.log(first.name);
}
return data.map(item => item.name);
}
// 用 branded type 防止 ID 混淆
type UserId = string & { : };
type = string & { : };
() { ... }
() { ... }
userId = ;
orderId = ;
(userId);
(orderId);
TS 的学习曲线是陡,但用好了能消灭一半的低级错误。特别是严格 null 检查,能把"Cannot read property of undefined"这种错误在编译期就干掉。
写测试别只测"正常情况",要测边界:空数组、undefined、极大值、网络超时。
// Jest 测试示例,重点测边界
import { processOrder, calculateDiscount } from './order';
describe('processOrder', () => {
// 正常情况
it('应该正确处理正常订单', () => {
const order = { items: [{ price: 100, quantity: 2 }], coupon: 'SAVE20' };
expect(processOrder(order)).toEqual({ total: 200, discount: 40, final: 160 });
});
// 边界:空数组
it('应该处理空购物车', () => {
const order = { items: [] };
expect(processOrder(order)).toEqual({ total: 0, discount: 0, final: 0 });
});
// 边界:undefined 输入
it('应该处理 undefined 输入', () => {
expect(processOrder(undefined)).toThrow('Invalid order data'); // 或者返回默认值,看业务需求
});
// 边界:价格或数量为 0/负数
(, {
order = { : [{ : -, : }] };
( (order)).();
});
(, {
order = { : [{ : ., : }] };
( (order)).();
});
(, () => {
jest.();
promise = ({ : [] });
jest.();
(promise)..();
jest.();
});
});
fc ;
(, {
(, {
fc.(
fc.(
fc.({ : , : }),
fc.({ : , : }),
{
discount = (price, rate);
discount <= price && discount >= ;
}
)
);
});
});
测试覆盖率要追求,但更重要的是覆盖场景。100% 覆盖率但只测了正常路径,不如 80% 覆盖率但边界都测到了。
技术之外,管理手段也很重要。我们团队有个规矩:谁提交的代码导致线上 P0 事故(核心功能挂了、大量用户受影响),谁请全组喝奶茶。
这招比 Code Review 好使多了。Code Review 大家还容易敷衍,"LGTM"(Looks Good To Me)就过了。但想到可能要请 20 个人喝奶茶,Review 的时候眼睛都瞪大了,每一行都仔细看。
而且出了事故不惩罚,只是请喝奶茶,氛围比较轻松。大家复盘的时候也不会互相甩锅,而是真的想怎么避免下次再犯。毕竟谁都有手滑的时候,但同一个错误犯两次就真说不过去了。
我们还配合一个"事故日记",每次 P0 事故都记录下来:什么时候发生的、什么原因、怎么修复、怎么预防。新员工入职先看这个日记,了解团队的"黑历史",也学习排查问题的思路。
说了这么多,其实想传达一个心态:bug 是写出来的,不是测出来的。只要代码在变,bug 就在路上。重要的不是追求零 bug(那不可能),而是建立一套体系,让 bug 的影响可控、可快速修复、可从中学习。
我现在看到 Sentry 的报警邮件,第一反应已经不是"卧槽又出事了",而是"让我看看这次是什么新鲜玩意儿"。毕竟处理过的错误类型越多,排查新问题的速度就越快。而且很多错误看着吓人,其实就是个边界条件没处理好,改两行代码就完事。
记住,没有完美的系统,只有永远在修 Bug 的路上狂奔的我们。今晚能不能睡个安稳觉,就看你这波监控搭得稳不稳、降级方案设计得合理不合理、团队配合默不默契了。
不过说真的,自从我们把监控体系搭完善之后,凌晨三点的电话确实少多了。现在偶尔响一次,我甚至有点怀念那种肾上腺素飙升的感觉——毕竟,这才是前端工程师的"浪漫"啊(不是)。
好了,该说的都说了,去检查下你的 Sentry 配置吧,别等会儿电话真响了。☕

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online