跳到主要内容
前端错误监控与处理实战指南 | 极客日志
JavaScript 大前端
前端错误监控与处理实战指南 综述由AI生成 深入探讨了前端开发中常见的错误类型及监控方案。内容涵盖浏览器错误捕获机制(window.onerror, Promise rejection)、资源加载失败处理、网络错误监控封装、SourceMap 堆栈还原、用户行为录屏以及性能优化策略。同时介绍了错误分级处理、优雅降级方案、CI/CD 集成测试以及 TypeScript 和单元测试在预防错误中的应用。旨在帮助开发者建立完善的错误监控体系,降低线上故障影响。
并发大师 发布于 2026/4/6 更新于 2026/5/24 26 浏览前端错误监控与处理实战指南
别在那硬扛了,聊聊咱们天天跟 JS 错误斗智斗勇那点破事
说真的,干前端这些年,我算是看明白了——咱们这行就是个和错误谈恋爱的过程。刚开始你看见控制台飘红就慌得一批,到后来看见报错甚至能笑出声,这种心态转变没个三五年的深夜加班根本练不出来。
谁还没在凌晨三点被 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。这玩意儿确实能抓不少东西,但你要是以为有了它就高枕无忧,那等着被坑吧。
window .onerror = function (message, source, lineno, colno, error ) {
console .log ('抓到错误了:' , message);
return true ;
};
window .onerror = function (message, source, lineno, colno, error ) {
const errorInfo = {
message : message,
source : source,
line : lineno,
column : colno,
stack : error && error. ? error. : ,
: navigator. ,
: (). (),
: . .
};
{
(errorInfo);
} (e) {
. ( + . (), . (errorInfo));
}
;
};
stack
stack
'no stack trace'
userAgent
userAgent
timestamp
new
Date
toISOString
pageUrl
window
location
href
try
sendToMonitoring
catch
localStorage
setItem
'pending_error_'
Date
now
JSON
stringify
return
false
但 window.onerror 有个巨坑——它抓不到跨域脚本的错误。啥意思呢?你引了个 CDN 上的 jQuery,那里面出错了,onerror 收到的信息就是"Script error",其他啥也没有。这是浏览器的安全策略,怕你别有用心地探测其他域的脚本内容。要解决这个问题,得两边配合:CDN 那边要加 Access-Control-Allow-Origin 头,你这边 script 标签要加 crossorigin="anonymous"。
<script src ="https://cdn.example.com/app.js" > </script >
<script src ="https://cdn.example.com/app.js" crossorigin ="anonymous" > </script >
console.error 就是个摆设,别指望它 很多人喜欢在代码里 console.error 一下,觉得这样就算处理了错误。兄弟,console.error 只是打印到控制台,它既不阻止程序崩溃,也不通知你去处理。更关键的是,生产环境谁看控制台啊?用户的浏览器控制台你看得见吗?
setTimeout (() => {
throw new Error ('我在异步里爆炸了' );
}, 1000 );
Promise .resolve ().then (() => {
throw new Error ('Promise 内部错误' );
});
async function fetchData ( ) {
const response = await fetch ('/api/data' );
const data = await response.json ();
return data;
}
这些异步错误,window.onerror 一个都抓不到。它们就像深夜里的刺客,悄无声息地把你的程序干掉,然后消失在事件循环的黑暗中。
Promise.reject 这个定时炸弹 Promise 如果没处理 rejection,那简直就是埋了个地雷。而且这地雷还有个特性——专门挑你上线那天炸。
function getUserInfo (userId ) {
return fetch (`/api/user/${userId} ` ).then (res => res.json ());
}
getUserInfo (123 ).then (data => {
renderUser (data);
});
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 ) {
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 ();
});
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' ;
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 );
window .addEventListener ('error' , function (event ) {
console .log ('资源错误详情:' , event.target );
}, true );
但说实话,资源错误的堆栈信息基本为零,你就只能知道"某个 URL 加载失败了",至于为啥失败(是 404?还是超时?还是被 CORS 拦截了?),得靠猜。这时候 network 条件的上下文就特别重要,后面会讲到怎么收集这些信息。
给代码穿上防弹衣,这些监控手段必须安排上 知道了浏览器怎么"发疯",咱们就得给代码穿上防弹衣。但防弹衣也不能乱穿,穿太厚影响行动(性能),穿太薄又防不住子弹(漏报)。
网络错误这块,很多人其实没监控到位 大家通常只关心 JS 逻辑错误,但线上问题一大半其实是网络相关的。接口超时、返回 500、DNS 解析失败,这些"外家功夫"不防,用户该骂娘还是骂娘。
class MonitoredFetch {
constructor (options = {} ) {
this .timeout = options.timeout || 10000 ;
this .retries = options.retries || 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 {
const controller = new AbortController ();
const timeoutId = setTimeout (() => controller.abort (), this .timeout );
const response = await fetch (fullURL, {
...options,
signal : controller.signal
});
clearTimeout (timeoutId);
const duration = performance.now () - startTime;
this .logPerformance (fullURL, duration, response.status );
if (!response.ok ) {
throw new HTTPError (`HTTP ${response.status} ` , response.status , fullURL);
}
return response;
} catch (error) {
if (error.name === 'AbortError' ) {
throw new TimeoutError (`Request timeout after ${this .timeout} ms` , fullURL);
}
if (error.message .includes ('fetch' ) || error.message .includes ('network' )) {
throw new NetworkError (error.message , fullURL);
}
throw error;
}
};
let lastError;
for (let i = 0 ; i < this .retries ; i++) {
try {
return await executeRequest ();
} catch (error) {
lastError = error;
if (error instanceof HTTPError && error.status >= 400 && error.status < 500 ) {
throw error;
}
if (i < this .retries - 1 ) {
await this .delay (1000 * (i + 1 ));
}
}
}
this .reportError (lastError, {
url : fullURL,
method : options.method || 'GET' ,
attemptCount,
duration : performance.now () - startTime
});
throw lastError;
}
logPerformance (url, duration, status ) {
if (window .performanceObserver ) {
console .log (`[Performance] ${url} : ${duration.toFixed(2 )} ms, status: ${status} ` );
}
}
reportError (error, context ) {
const errorInfo = {
type : 'NETWORK_ERROR' ,
errorType : error.constructor .name ,
message : error.message ,
url : context.url ,
method : context.method ,
attemptCount : context.attemptCount ,
duration : context.duration ,
userAgent : navigator.userAgent ,
connection : navigator.connection ? {
effectiveType : navigator.connection .effectiveType ,
downlink : navigator.connection .downlink ,
rtt : navigator.connection .rtt
} : 'unknown'
};
reportError (errorInfo);
}
delay (ms ) {
return new Promise (resolve => setTimeout (resolve, ms));
}
}
class HTTPError extends Error {
constructor (message, status, url ) {
super (message);
this .name = 'HTTPError' ;
this .status = status;
this .url = url;
}
}
class TimeoutError extends Error {
constructor (message, url ) {
super (message);
this .name = 'TimeoutError' ;
this .url = url;
}
}
class NetworkError extends Error {
constructor (message, url ) {
super (message);
this .name = 'NetworkError' ;
this .url = url;
}
}
const api = new MonitoredFetch ({
baseURL : 'https://api.example.com' ,
timeout : 5000 ,
retries : 2
});
api.request ('/user/profile' ).then (res => res.json ()).then (data => console .log (data)).catch (err => {
if (err instanceof TimeoutError ) {
showToast ('网络太慢了,请检查网络或稍后再试' );
} else if (err instanceof NetworkError ) {
showToast ('网络好像断了,看看 WiFi 是不是没连' );
}
});
看到没,网络监控不只是记个错,还得把当时的网络环境、重试次数、耗时都记下来。这样你排查问题的时候才能还原现场,而不是对着"请求失败"四个字发呆。
SourceMap 这玩意儿,真的是救命稻草 生产环境的代码都是压缩过的,报错信息长这样:at t.e (app.abc123.js:1:2345)。你看着这一堆 a.b.c,根本不知道是哪个文件哪行代码。这时候 SourceMap 就是你的救命稻草。
但 SourceMap 怎么用,这里面有讲究。你不能直接把 SourceMap 文件扔到生产环境,因为那样等于把源码公开了(虽然可以配置只映射到行不映射到列,但还是能反推个大概)。所以通常的做法是:
构建时生成 SourceMap,但不上传到 CDN
把 SourceMap 文件存到内部服务器或者 Sentry 这种错误监控平台
线上报错时,用监控平台保存的 SourceMap 来还原堆栈
module .exports = {
devtool : 'hidden-source-map' ,
output : {
filename : '[name].[contenthash].js' ,
sourceMapFilename : 'sourcemaps/[name].[contenthash].js.map'
}
};
export default {
build : {
sourcemap : 'hidden' ,
}
};
然后在错误监控服务里,你需要实现一个堆栈还原的功能。如果你用 Sentry,它自动就做了。如果自己实现,大概长这样:
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 => {
const match = line.match (/at\s+(.+?)\s+\((.+):(\d+):(\d+)\)/ );
if (!match) return line;
const [, funcName, fileName, lineNum, colNum] = match;
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' );
}
const minifiedStack = "at t.e (app.abc123.js:1:2345)\nat n.r (app.abc123.js:1:5678)" ;
const sourceMap = fs.readFileSync ('./app.abc123.js.map' , 'utf8' );
unminifyStackTrace (minifiedStack, sourceMap).then (originalStack => console .log (originalStack));
还原报错现场,得像侦探小说一样精彩 光知道哪行代码错了还不够,你得知道当时用户干了啥、页面啥状态、网络啥情况。这就需要"录屏"功能——不是真的录视频,而是记录用户操作和页面变化。
class SessionRecorder {
constructor (options = {} ) {
this .events = [];
this .maxEvents = options.maxEvents || 1000 ;
this .isRecording = false ;
}
start ( ) {
if (this .isRecording ) return ;
this .isRecording = true ;
this .recordEvent ({
type : 'init' ,
timestamp : Date .now (),
url : window .location .href ,
viewport : {
width : window .innerWidth ,
height : window .innerHeight
},
userAgent : navigator.userAgent
});
this .clickHandler = (e ) => {
this .recordEvent ({
type : 'click' ,
timestamp : Date .now (),
target : this .describeElement (e.target ),
x : e.clientX ,
y : e.clientY
});
};
document .addEventListener ('click' , this .clickHandler , true );
this .inputHandler = (e ) => {
if (e.target .tagName === 'INPUT' || e.target .tagName === 'TEXTAREA' ) {
this .recordEvent ({
type : 'input' ,
timestamp : Date .now (),
target : this .describeElement (e.target ),
value : e.target .type === 'password' ? '***' : e.target .value .substring (0 , 100 )
});
}
};
document .addEventListener ('input' , this .inputHandler , true );
this .popstateHandler = () => {
this .recordEvent ({
type : 'navigation' ,
timestamp : Date .now (),
url : window .location .href
});
};
window .addEventListener ('popstate' , this .popstateHandler );
this .performanceInterval = setInterval (() => {
this .recordEvent ({
type : 'performance' ,
timestamp : Date .now (),
memory : performance.memory ? {
usedJSHeapSize : performance.memory .usedJSHeapSize ,
totalJSHeapSize : performance.memory .totalJSHeapSize
} : null ,
longTasks : this .getLongTasks ()
});
}, 5000 );
}
describeElement (element ) {
const tag = element.tagName .toLowerCase ();
const id = element.id ? `#${element.id} ` : '' ;
const classes = element.className && typeof element.className === 'string' ? `.${element.className.split(' ' ).join('.' )} ` : '' ;
const text = element.textContent ? element.textContent .substring (0 , 50 ) : '' ;
return `${tag} ${id} ${classes} ${text ? ` "${text} "` : '' } ` ;
}
getLongTasks ( ) {
if (!window .PerformanceObserver ) return [];
return [];
}
recordEvent (event ) {
if (this .events .length >= this .maxEvents ) {
this .events .shift ();
}
this .events .push (event);
}
getContextForError ( ) {
return {
recentEvents : this .events .slice (-50 ),
timestamp : Date .now ()
};
}
stop ( ) {
this .isRecording = false ;
document .removeEventListener ('click' , this .clickHandler , true );
document .removeEventListener ('input' , this .inputHandler , true );
window .removeEventListener ('popstate' , this .popstateHandler );
clearInterval (this .performanceInterval );
}
}
const recorder = new SessionRecorder ({ maxEvents : 500 });
recorder.start ();
window .addEventListener ('error' , (e ) => {
const context = recorder.getContextForError ();
reportError ({
message : e.message ,
stack : e.error ?.stack ,
userActions : context.recentEvents ,
domSnapshot : captureDOMSnapshot ()
});
});
这种"录屏"数据配合错误堆栈,你排查问题的时候就跟看监控录像似的,用户点了哪、输入了啥、页面怎么变的,一目了然。但要注意隐私合规,密码、身份证号这些敏感信息一定要脱敏。
埋点策略得有讲究,别把用户浏览器卡死 监控代码本身不能成为性能瓶颈。我见过有团队为了"全量监控",在每个函数开头结尾都插桩,结果主线程直接堵死,页面卡顿得要命。
function heavyFunction ( ) {
monitor.startTrack ('heavyFunction' );
monitor.endTrack ('heavyFunction' );
}
class SmartMonitor {
constructor (options = {} ) {
this .sampleRate = options.sampleRate || 0.1 ;
this .batchSize = options.batchSize || 10 ;
this .buffer = [];
this .flushInterval = options.flushInterval || 5000 ;
this .isSending = false ;
setInterval (() => this .flush (), this .flushInterval );
window .addEventListener ('beforeunload' , () => this .flush (true ));
}
track (event ) {
if (Math .random () > this .sampleRate ) return ;
this .buffer .push ({ ...event, timestamp : Date .now () });
if (this .buffer .length >= this .batchSize ) {
if (window .requestIdleCallback ) {
requestIdleCallback (() => this .flush (), { timeout : 2000 });
} else {
setTimeout (() => this .flush (), 0 );
}
}
}
flush (isBeacon = false ) {
if (this .buffer .length === 0 || this .isSending ) return ;
this .isSending = true ;
const data = [...this .buffer ];
this .buffer = [];
if (isBeacon && navigator.sendBeacon ) {
const blob = new Blob ([JSON .stringify (data)], { type : 'application/json' });
navigator.sendBeacon ('/monitor/collect' , blob);
this .isSending = false ;
} else {
fetch ('/monitor/collect' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON .stringify (data),
keepalive : true
}).catch (err => {
if (this .buffer .length < 100 ) {
this .buffer .unshift (...data);
}
}).finally (() => {
this .isSending = false ;
});
}
}
}
const monitor = new SmartMonitor ({ sampleRate : 0.1 });
function businessLogic ( ) {
monitor.track ({ type : 'function_start' , name : 'businessLogic' });
monitor.track ({ type : 'function_end' , name : 'businessLogic' , duration : 123 });
}
关键点:采样(别全量)、批量(别一条条发)、异步(别卡主线程)、降级(sendBeacon)。这样监控代码对性能的影响可以忽略不计。
这招好用是好用,但副作用你也得心里有数 监控这东西,用好了是神器,用不好就是给自己挖坑。我见过太多团队监控搞得太激进,结果问题没解决,先制造了一堆新问题。
监控代码太猛,主线程直接堵死 前面说了性能优化,但还有些更隐蔽的坑。比如有些监控库为了"精确计时",用 performance.now() 在每次 DOM 操作前后打戳,结果频繁的时钟查询本身就成为性能瓶颈。
还有些库为了获取"完整的错误上下文",在报错时遍历整个 DOM 树,序列化成字符串。如果页面很大(比如那种无限滚动的长列表),这一下就能卡死好几秒。
function getFullDOMContext ( ) {
return document .documentElement .outerHTML ;
}
function getSafeContext ( ) {
const context = {
url : window .location .href ,
viewport : {
width : window .innerWidth ,
height : window .innerHeight
},
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 ();
this .flushInterval = 60000 ;
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 , '' )
).join ('|' );
return `${error.name} :${error.message} :${normalizedStack} ` ;
}
report (error, context = {} ) {
const fingerprint = this .getFingerprint (error);
const now = Date .now ();
if (this .errorMap .has (fingerprint)) {
const record = this .errorMap .get (fingerprint);
record.count ++;
record.lastTime = now;
if (record.samples .length < 5 ) {
record.samples .push (context);
}
} else {
this .errorMap .set (fingerprint, {
fingerprint,
name : error.name ,
message : error.message ,
stack : error.stack ,
firstTime : now,
lastTime : now,
count : 1 ,
samples : [context]
});
}
}
syncToServer ( ) {
if (this .errorMap .size === 0 ) return ;
const errors = Array .from (this .errorMap .values ());
this .errorMap .clear ();
fetch ('/monitor/aggregated-errors' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON .stringify ({
timestamp : Date .now (),
errors : errors,
totalErrors : errors.reduce ((sum, e ) => sum + e.count , 0 ),
uniqueErrors : errors.length
})
});
}
}
const aggregator = new ErrorAggregator ();
window .addEventListener ('error' , (e ) => {
aggregator.report (e.error , {
url : window .location .href ,
userId : getCurrentUserId ()
});
});
这样你存的不是 100 万条重复记录,而是几百条聚合记录,每条带个计数器。查询的时候也能一眼看出哪个错误影响面最大。
误报率太高,狼来了的故事听多了 如果监控太敏感,一堆无关紧要的警告会把真正的 P0 级错误淹没。比如第三方脚本的小错误、浏览器插件注入的代码错误、用户网络不稳定导致的请求失败,这些如果都报上来,团队很快就麻木了。
function shouldReportError (error ) {
const knownHarmlessPatterns = [
/ResizeObserver loop limit exceeded/ ,
/Script error\.?/ ,
/Non-Error promise rejection captured/
];
for (const pattern of knownHarmlessPatterns) {
if (pattern.test (error.message )) return false ;
}
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 ;
}
const isOwnDomain = error.stack && error.stack .includes (window .location .hostname );
if (!isOwnDomain) return false ;
const fingerprint = getFingerprint (error);
const count = getRecentErrorCount (fingerprint);
if (count > 100 ) {
return Math .random () < 0.1 ;
}
return true ;
}
window .addEventListener ('error' , (e ) => {
if (!shouldReportError (e.error )) return ;
reportError (e.error );
});
过滤策略要持续维护,根据线上实际情况调整。定期 review 错误列表,把那些"无害但 noisy"的错误加入黑名单。
真到了生产环境,这套组合拳该怎么打 监控搭好了,怎么用是个学问。不能所有错误都一视同仁,得有分级处理机制。
分级处理才是王道 错误得分级,就像医院分急诊和门诊一样。有些错误记下来就行,有些得立即通知,有些得直接触发回滚。
class ErrorSeverityClassifier {
classify (error, context = {} ) {
if (this .isP0Error (error, context)) {
return {
level : 'P0' ,
action : 'immediate_alert' ,
autoRollback : true ,
notifyChannels : ['pagerduty' , 'slack' , 'email' ]
};
}
if (this .isP1Error (error, context)) {
return {
level : 'P1' ,
action : 'urgent_alert' ,
responseTime : 15 * 60 * 1000 ,
notifyChannels : ['slack' , 'email' ]
};
}
if (this .isP2Error (error, context)) {
return {
level : 'P2' ,
action : 'daily_digest' ,
notifyChannels : ['email' ]
};
}
return {
level : 'P3' ,
action : 'weekly_review' ,
notifyChannels : []
};
}
isP0Error (error, context ) {
if (context.pageType === 'checkout' && error.message .includes ('payment' )) {
return true ;
}
if (context.apiPath ?.includes ('/api/core' ) && context.httpStatus >= 500 ) {
if (context.errorRate > 0.1 ) return true ;
}
if (error.message ?.includes ('render' ) || error.message ?.includes ('mount' )) {
if (context.isWhiteScreen ) return true ;
}
return false ;
}
isP1Error (error, context ) {
if (context.pageType === 'login' || context.pageType === 'signup' ) {
return true ;
}
if (error.message ?.includes ('user profile' )) return true ;
return false ;
}
isP2Error (error, context ) {
if (context.pageType === 'recommendation' ) return true ;
if (error.type === 'RESOURCE_ERROR' && error.resourceType === 'image' ) {
return true ;
}
return false ;
}
}
async function autoRollback (errorInfo ) {
if (errorInfo.level !== 'P0' ) return ;
const recentErrors = await getRecentP0Errors (5 * 60 * 1000 );
if (recentErrors.length < 10 ) return ;
console .error ('🚨 触发自动回滚!' );
fetch ('/deploy/rollback' , {
method : 'POST' ,
headers : { 'Authorization' : 'Bearer ' + DEPLOY_TOKEN },
body : JSON .stringify ({
reason : `Auto-rollback due to P0 error: ${errorInfo.message} ` ,
targetVersion : 'previous-stable'
})
});
notifyTeam ({
type : 'AUTO_ROLLBACK_TRIGGERED' ,
error : errorInfo,
timestamp : Date .now ()
});
}
分级策略要根据业务特点定制。电商网站的支付错误是 P0,内容网站的推荐算法错误可能就是 P2。这个得和团队一起梳理清楚。
优雅的降级方案,让页面"瘸腿"也能跑 现代前端都是 SPA,一个组件挂了可能导致整个页面白屏。这时候得有降级策略,让页面"瘸腿"也能跑。
import React from 'react' ;
class ErrorBoundary extends React.Component {
constructor (props ) {
super (props);
this .state = { hasError : false , error : null };
}
static getDerivedStateFromError (error ) {
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 ({ fallbackType : 'component' });
}
}
render ( ) {
if (this .state .hasError ) {
if (this .state .fallbackType === 'fullPage' ) {
return (
<div className ="error-page" >
<h1 > 页面出了点小问题</h1 >
<p > 我们的技术团队已经收到通知,正在紧急修复</p >
<button onClick ={() => window.location.reload()}>刷新试试</button >
{/* 甚至可以提供"简化版"入口 */}
<a href ="/lite-version" > 访问简化版</a >
</div >
);
}
return (
<div className ="component-error-fallback" >
<p > 这部分内容加载失败</p >
<button onClick ={() => this.setState({ hasError: false })}>重试</button >
</div >
);
}
return this .props .children ;
}
}
function ProductPage ( ) {
return (
<ErrorBoundary critical componentName ="ProductPage" >
<ProductDetail />
<RelatedProducts />
</ErrorBoundary >
);
}
function Sidebar ( ) {
return (
<ErrorBoundary componentName ="Sidebar" >
<Recommendations />
</ErrorBoundary >
);
}
app.config .errorHandler = (err, instance, info ) => {
console .error ('Vue Error:' , err);
reportError ({
error : err,
component : instance?.$options ?.name || 'anonymous' ,
info : info
});
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 ;
}
};
配合 CI/CD,把低级错误扼杀在摇篮里 监控是最后一道防线,更好的办法是在代码提交前就发现问题。配合 CI/CD 流水线,可以自动跑异常模拟测试。
describe ('异常场景测试' , () => {
it ('应该优雅处理 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 ('应该处理网络超时' , () => {
cy.intercept ('GET' , '/api/slow-endpoint' , (req ) => {
}).as ('slowRequest' );
cy.visit ('/slow-page' );
cy.wait (5000 );
cy.contains ('请求超时' ).should ('be.visible' );
});
it ('应该处理 JS 运行时错误' , () => {
cy.visit ('/test-page' );
cy.window ().then ((win ) => {
win.eval ('setTimeout(() => { throw new Error("Test error"); }, 100)' );
});
cy.get ('body' ).should ('not.have.class' , 'white-screen' );
cy.get ('[data-testid="error-boundary"]' ).should ('be.visible' );
});
it ('应该处理资源加载失败' , () => {
cy.intercept ('GET' , '**/*.jpg' , { statusCode : 404 }).as ('image404' );
cy.visit ('/gallery' );
cy.wait ('@image404' );
cy.get ('img' ).each (($img ) => {
cy.wrap ($img).should ('have.attr' , 'src' ).and ('include' , 'fallback' );
});
});
});
这些自动化测试能帮你发现"明显会崩"的场景。虽然不能覆盖所有错误,但至少能保证核心流程在异常情况下不挂。
遇到那种"在我本地明明是好的"玄学问题咋整 最烦人的就是这种玄学问题。本地好好的,一上线就崩;测试环境没问题,生产环境就报错。这时候怎么排查?
复现不了是最耍流氓的,教你几招顺藤摸瓜 首先,得有足够的上下文。用户 ID、时间戳、浏览器版本、操作系统,这些基础信息必须有。
function getRichContext ( ) {
return {
userId : getUserId (),
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.connection .rtt ,
saveData : navigator.connection .saveData
} : null ,
online : navigator.onLine ,
url : window .location .href ,
referrer : document .referrer ,
visibilityState : document .visibilityState ,
performance : {
loadTime : performance.timing ? performance.timing .loadEventEnd - performance.timing .navigationStart : null ,
domContentLoaded : performance.timing ? performance.timing .domContentLoadedEventEnd - performance.timing .navigationStart : null ,
memory : performance.memory ? {
usedJSHeapSize : performance.memory .usedJSHeapSize ,
totalJSHeapSize : performance.memory .totalJSHeapSize ,
jsHeapSizeLimit : performance.memory .jsHeapSizeLimit
} : null
},
resources : performance.getEntriesByType ? performance.getEntriesByType ('resource' ).slice (-20 ).map (r => ({
name : r.name ,
duration : r.duration ,
initiatorType : r.initiatorType
})) : [],
storeState : getReduxState ? sanitizeState (getReduxState ()) : null ,
recentLogs : getRecentConsoleLogs ()
};
}
const recentLogs = [];
const MAX_LOGS = 50 ;
['log' , 'warn' , 'error' , 'info' ].forEach (method => {
const original = console [method];
console [method] = function (...args ) {
recentLogs.push ({
method,
args : args.map (arg => {
try {
return typeof arg === 'object' ? JSON .stringify (arg).substring (0 , 200 ) : String (arg);
} catch (e) {
return '[unserializable]' ;
}
}),
timestamp : Date .now ()
});
if (recentLogs.length > MAX_LOGS ) {
recentLogs.shift ();
}
return original.apply (this , args);
};
});
function getRecentConsoleLogs ( ) {
return recentLogs;
}
window .addEventListener ('error' , (e ) => {
reportError ({
error : {
message : e.message ,
stack : e.error ?.stack
},
context : getRichContext ()
});
});
有了这些信息,你可以按用户 ID 查日志,看他在报错前都干了啥。也可以按时间戳聚合,看是不是某个时间点集中爆发(可能是 CDN 节点问题、服务器发布问题)。
偶发的内存泄漏,怎么像抓鬼一样揪出来 内存泄漏最难搞,因为它不会立即报错,而是让页面越来越卡,最后崩溃。而且本地开发时通常不会长时间运行,很难发现。
class MemoryLeakDetector {
constructor ( ) {
this .measurements = [];
this .checkInterval = 30000 ;
this .growthThreshold = 1.5 ;
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 .location .href
});
if (this .measurements .length > 20 ) {
this .measurements .shift ();
}
this .analyzeTrend ();
}
analyzeTrend ( ) {
if (this .measurements .length < 5 ) return ;
const recent = this .measurements .slice (-5 );
const first = recent[0 ].usedMB ;
const last = recent[4 ].usedMB ;
const growth = last / first;
if (growth > this .growthThreshold ) {
this .reportLeak ({
growthFactor : growth,
startMB : first,
endMB : last,
measurements : recent,
domNodes : document .getElementsByTagName ('*' ).length ,
eventListeners : this .getEventListenerCount ()
});
}
}
getEventListenerCount ( ) {
return 'estimated' ;
}
reportLeak (details ) {
reportError ({
type : 'MEMORY_LEAK_DETECTED' ,
severity : 'warning' ,
details : details,
suggestion : '页面内存持续增长,可能存在泄漏,建议检查定时器、事件监听、DOM 引用'
});
if (details.growthFactor > 3 ) {
showToast ('页面运行时间较长,建议刷新以获得最佳体验' );
}
}
}
const leakDetector = new MemoryLeakDetector ();
更专业的做法是用 Chrome 的 Performance 面板手动分析,但生产环境你没法让用户帮你开 DevTools。所以这种自动监控是必要的补充。
第三方脚本挂了怎么办?沙箱隔离和超时熔断 现在的网站都依赖一堆第三方脚本:统计、广告、客服、支付……这些脚本如果挂了,可能拖慢甚至拖垮你的页面。
class ThirdPartyManager {
constructor ( ) {
this .scripts = new Map ();
this .timeouts = {
'analytics' : 5000 ,
'chat-widget' : 10000 ,
'payment' : 15000
};
}
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 : null });
reject (new Error (`Script ${name} load timeout after ${timeout} ms` ));
}, timeout);
script.onload = () => {
clearTimeout (timer);
this .scripts .set (name, { status : 'loaded' , loadedAt : Date .now () });
resolve (script);
};
script.onerror = () => {
clearTimeout (timer);
this .scripts .set (name, { status : 'error' , loadedAt : null });
reject (new Error (`Script ${name} load failed` ));
};
if (options.sandbox ) {
this .loadInSandbox (name, url, resolve, reject);
} else {
document .head .appendChild (script);
}
}).catch (err => {
console .warn (`第三方脚本 ${name} 加载失败:` , err);
reportError ({
type : 'THIRD_PARTY_ERROR' ,
scriptName : name,
error : err.message
});
return null ;
});
}
loadInSandbox (name, url, resolve, reject ) {
const iframe = document .createElement ('iframe' );
iframe.style .display = 'none' ;
iframe.sandbox = 'allow-scripts allow-same-origin' ;
iframe.onload = () => {
const iframeDoc = iframe.contentDocument ;
const script = iframeDoc.createElement ('script' );
script.src = url;
script.onload = () => resolve (iframe);
script.onerror = () => reject (new Error ('Sandboxed script failed' ));
iframeDoc.head .appendChild (script);
};
document .body .appendChild (iframe);
}
isAvailable (name ) {
const script = this .scripts .get (name);
return script && script.status === 'loaded' ;
}
}
const tpm = new ThirdPartyManager ();
tpm.loadScript ('analytics' , 'https://analytics.example.com/track.js' , {
timeout : 3000
});
tpm.loadScript ('payment' , 'https://payment.example.com/sdk.js' , {
timeout : 10000
}).then (() => {
initPayment ();
}).catch (() => {
showPaymentFallback ();
});
function trackEvent (event ) {
if (tpm.isAvailable ('analytics' )) {
window .analytics .track (event);
} else {
console .log ('Analytics not ready, dropping 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()" > 复制诊断信息</button >
</div >
</details >
<div class ="actions" >
<button onclick ="location.reload()" > 刷新页面</button >
<button onclick ="contactSupport()" > 联系客服</button >
</div >
</div >
<script >
function generateErrorId ( ) {
return 'ERR-' + new Date ().toISOString ().slice (0 , 10 ).replace (/-/g , '' ) + '-' + Math .random ().toString (36 ).substring (2 , 8 ).toUpperCase ();
}
function copyDiagnostics ( ) {
const info = {
errorId : document .getElementById ('error-id' ).textContent ,
time : document .getElementById ('error-time' ).textContent ,
page : document .getElementById ('error-page' ).textContent ,
userAgent : navigator.userAgent
};
navigator.clipboard .writeText (JSON .stringify (info, null , 2 )).then (() => alert ( )). ( ( ));
}
({
: . ( ). ,
});
</script >
其次,客服话术要培训好。别问"你遇到什么问题"这种开放式问题,要问"错误 ID 是多少"、"报错时你在哪个页面"、"有没有看到红色的错误提示"。
几个让你少加班的野路子技巧 说了这么多监控和排查的,最好的错误处理是不出错。这里分享几个从源头减少错误的野路子。
TypeScript 把类型检查做到极致 别只是把 TS 当 JS 用,要利用它的严格模式。
{
"compilerOptions" : {
"strict" : true ,
"noImplicitAny" : true ,
"strictNullChecks" : true ,
"noUncheckedIndexedAccess" : true ,
"exactOptionalPropertyTypes" : true ,
"noImplicitReturns" : true ,
"noFallthroughCasesInSwitch" : true ,
"noUncheckedSideEffectImports" : true
}
}
function processData (data ) {
return data.map (item => item.name );
}
interface User {
id : number;
name : string;
email : string;
}
function processData (data: User[] ): string[] {
return data.map (item => item.name );
}
function safeProcessData (data: User[] | undefined | null ): string[] {
if (!data) {
return [];
}
const first = data[0 ];
if (first) {
console .log (first.name );
}
return data.map (item => item.name );
}
type UserId = string & { __brand : 'UserId' };
type OrderId = string & { __brand : 'OrderId' };
function getUser (id: UserId ) { ... }
function getOrder (id: OrderId ) { ... }
const userId = '123' as UserId ;
const orderId = '456' as OrderId ;
getUser (userId);
getUser (orderId);
TS 的学习曲线是陡,但用好了能消灭一半的低级错误。特别是严格 null 检查,能把"Cannot read property of undefined"这种错误在编译期就干掉。
单元测试别偷懒,边界条件才是重灾区 写测试别只测"正常情况",要测边界:空数组、undefined、极大值、网络超时。
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 });
});
it ('应该处理 undefined 输入' , () => {
expect (processOrder (undefined )).toThrow ('Invalid order data' );
});
it ('应该拒绝无效的价格' , () => {
const order = { items : [{ price : -100 , quantity : 1 }] };
expect (() => processOrder (order)).toThrow ('Invalid price' );
});
it ('应该处理极大金额' , () => {
const order = { items : [{ price : Number .MAX_SAFE_INTEGER , quantity : 2 }] };
expect (() => processOrder (order)).toThrow ('Amount overflow' );
});
it ('应该处理 API 超时' , async () => {
jest.useFakeTimers ();
const promise = processOrderWithAPI ({ items : [] });
jest.advanceTimersByTime (10000 );
await expect (promise).rejects .toThrow ('Request timeout' );
jest.useRealTimers ();
});
});
import fc from 'fast-check' ;
describe ('calculateDiscount' , () => {
it ('折扣不应该超过原价' , () => {
fc.assert (
fc.property (
fc.integer ({ min : 0 , max : 100000 }),
fc.float ({ min : 0 , max : 1 }),
(price, rate ) => {
const discount = calculateDiscount (price, rate);
return discount <= price && discount >= 0 ;
}
)
);
});
});
测试覆盖率要追求,但更重要的是覆盖场景。100% 覆盖率但只测了正常路径,不如 80% 覆盖率但边界都测到了。
团队规矩:谁搞出 P0 事故谁请喝奶茶 技术之外,管理手段也很重要。我们团队有个规矩:谁提交的代码导致线上 P0 事故(核心功能挂了、大量用户受影响),谁请全组喝奶茶。
这招比 Code Review 好使多了。Code Review 大家还容易敷衍,"LGTM"(Looks Good To Me)就过了。但想到可能要请 20 个人喝奶茶,Review 的时候眼睛都瞪大了,每一行都仔细看。
而且出了事故不惩罚,只是请喝奶茶,氛围比较轻松。大家复盘的时候也不会互相甩锅,而是真的想怎么避免下次再犯。毕竟谁都有手滑的时候,但同一个错误犯两次就真说不过去了。
我们还配合一个"事故日记",每次 P0 事故都记录下来:什么时候发生的、什么原因、怎么修复、怎么预防。新员工入职先看这个日记,了解团队的"黑历史",也学习排查问题的思路。
下次再看到红色报错别心慌,甚至想笑 说了这么多,其实想传达一个心态:bug 是写出来的,不是测出来的。只要代码在变,bug 就在路上。重要的不是追求零 bug(那不可能),而是建立一套体系,让 bug 的影响可控、可快速修复、可从中学习。
我现在看到 Sentry 的报警邮件,第一反应已经不是"卧槽又出事了",而是"让我看看这次是什么新鲜玩意儿"。毕竟处理过的错误类型越多,排查新问题的速度就越快。而且很多错误看着吓人,其实就是个边界条件没处理好,改两行代码就完事。
记住,没有完美的系统,只有永远在修 Bug 的路上狂奔的我们。今晚能不能睡个安稳觉,就看你这波监控搭得稳不稳、降级方案设计得合理不合理、团队配合默不默契了。
不过说真的,自从我们把监控体系搭完善之后,凌晨三点的电话确实少多了。现在偶尔响一次,我甚至有点怀念那种肾上腺素飙升的感觉——毕竟,这才是前端工程师的"浪漫"啊(不是)。
好了,该说的都说了,去检查下你的 Sentry 配置吧,别等会儿电话真响了。☕
'诊断信息已复制,请粘贴给客服'
catch
() =>
alert
'复制失败,请手动截图'
reportError
errorId
document
getElementById
'error-id'
textContent
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online