跳到主要内容
前端老鸟血泪史:搞定那些让人头秃的报错,让线上稳如老狗 | 极客日志
Python
前端老鸟血泪史:搞定那些让人头秃的报错,让线上稳如老狗 综述由AI生成 前端老鸟血泪史:搞定那些让人头秃的报错,让线上稳如老狗 前端老鸟血泪史:搞定那些让人头秃的报错,让线上稳如老狗 别在那硬扛了,聊聊咱们天天跟JS错误斗智斗勇那点破事 扒一扒浏览器到底是怎么"发疯"报错的 window.onerror这个老六,到底靠不靠谱 console.error就是个摆设,别指望它 Promise.reject这个定时炸弹 资源加载失败,浏览器的提示有多敷衍 给代码穿上防弹衣,…
虚拟内存 发布于 2026/4/6 更新于 2026/5/23 57K 浏览前端老鸟血泪史:搞定那些让人头秃的报错,让线上稳如老狗
前端老鸟血泪史:搞定那些让人头秃的报错,让线上稳如老狗
别在那硬扛了,聊聊咱们天天跟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);returntrue;// 返回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:newDate().toISOString(),// 加个页面URL,不然你都不知道用户在哪个页面炸的pageUrl: window.location.href };// 发送到监控服务,注意这里要用try-catch,别监控代码自己崩了try{sendToMonitoring(errorInfo);}catch(e){// 实在发不出去,至少存localStorage,下次有机会再发 localStorage.setItem('pending_error_' + Date.now(),JSON.stringify(errorInfo));}returnfalse;// 建议返回false ,让控制台继续飘红,方便开发时看到};
但window.onerror有个巨坑——它抓不到跨域脚本的错误。啥意思呢?你引了个CDN上的jQuery,那里面出错了,onerror收到的信息就是"Script error",其他啥也没有。这是浏览器的安全策略,怕你别有用心地探测其他域的脚本内容。要解决这个问题,得两边配合:CDN那边要加Access-Control-Allow-Origin头,你这边script标签要加crossorigin="anonymous"。
<scriptsrc="https://cdn.example.com/app.js"></script > <scriptsrc="https://cdn.example.com/app.js"crossorigin="anonymous"></script >
console.error就是个摆设,别指望它 很多人喜欢在代码里console.error一下,觉得这样就算处理了错误。兄弟,console.error只是打印到控制台,它既不阻止程序崩溃,也不通知你去处理。更关键的是,生产环境谁看控制台啊?用户的浏览器控制台你看得见吗?
// 这种错误,window.onerror根本抓不到setTimeout(()=>{thrownewError('我在异步里爆炸了' );},1000 );// Promise里的错误,onerror也看不见 Promise.resolve().then(()=>{thrownewError('Promise内部错误' );});// async/await如果没包try-catch,也是悄无声息地挂asyncfunctionfetchData(){const response =awaitfetch('/api/data' );// 如果这里网络错误,整个函数就停了const data =await response.json();// 如果返回的不是JSON,这里又炸return data;}
这些异步错误,window.onerror一个都抓不到。它们就像深夜里的刺客,悄无声息地把你的程序干掉,然后消失在事件循环的黑暗中。
Promise.reject这个定时炸弹 Promise如果没处理rejection,那简直就是埋了个地雷。而且这地雷还有个特性——专门挑你上线那天炸。
// 这种写法,如果fetch失败,Promise就处于rejected状态,但没人管functiongetUserInfo(userId){returnfetch(`/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:newDate().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' ;// 上报错误,带上图片URLreportError({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.addEventListener('error' ,function (event) {// event.target可以判断是哪种资源 console.log ('资源错误详情:' , event.target);},true );
但说实话,资源错误的堆栈信息基本为零,你就只能知道"某个URL加载失败了",至于为啥失败(是404?还是超时?还是被CORS拦截了?),得靠猜。这时候network条件的上下文就特别重要,后面会讲到怎么收集这些信息。
给代码穿上防弹衣,这些监控手段必须安排上 知道了浏览器怎么"发疯",咱们就得给代码穿上防弹衣。但防弹衣也不能乱穿,穿太厚影响行动(性能),穿太薄又防不住子弹(漏报)。
网络错误这块,很多人其实没监控到位 大家通常只关心JS逻辑错误,但线上问题一大半其实是网络相关的。接口超时、返回500、DNS解析失败,这些"外家功夫"不防,用户该骂娘还是骂娘。
// 封装一个带监控的fetch,别直接用原生fetchclassMonitoredFetch{constructor(options ={}){this.timeout = options.timeout ||10000 ;// 默认10 秒超时this.retries = options.retries ||1 ;// 默认重试1 次this.baseURL = options.baseURL ||'' ;}asyncrequest(url, options ={}){const fullURL =this.baseURL + url;const startTime = performance.now();let attemptCount =0 ;constexecuteRequest=async()=>{ attemptCount++;try{// 创建AbortController用于超时控制const controller =newAbortController();const timeoutId =setTimeout(()=> controller.abort(),this.timeout);const response =awaitfetch(fullURL,{...options,signal: controller.signal });clearTimeout(timeoutId);// 记录性能数据,不管成功失败都要记const duration = performance.now()- startTime;this.logPerformance(fullURL, duration, response.status);// HTTP错误状态也要当作错误处理if (!response.ok){thrownewHTTPError(`HTTP ${response.status}` , response.status, fullURL);}return response;}catch(error){// 区分错误类型if (error.name ==='AbortError' ){thrownewTimeoutError(`Request timeout after ${this.timeout}ms` , fullURL);}// 网络错误(断网、DNS失败等)if (error.message.includes('fetch' )|| error.message.includes('network' )){thrownewNetworkError(error.message, fullURL);}throw error;}};// 重试逻辑let lastError;for (let i =0 ; i <this.retries; i++){try{returnawaitexecuteRequest();}catch(error){ lastError = error;// 只有网络错误和超时才重试,4xx错误重试也没用if (error instanceofHTTPError&& error.status >=400 && error.status <500 ){throw error;}// 延迟重试,别猛冲if (i <this.retries -1 ){awaitthis.delay(1000 *(i +1 ));// 指数退避}}}// 重试用完了还是失败,上报错误this.reportError(lastError,{url: fullURL,method : options .method ||'GET ', attemptCount ,duration : performance .now ()- startTime });throw lastError;}logPerformance(url, duration, status){// 发送到性能监控,可以用来做SLIif(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,// 4g/3g/2gdownlink: navigator.connection.downlink,rtt: navigator.connection.rtt }:'unknown' };reportError(errorInfo);}delay(ms){returnnewPromise(resolve=> setTimeout(resolve, ms));}}// 自定义错误类型,方便区分classHTTPErrorextendsError{constructor(message, status, url){super(message);this.name ='HTTPError' ;this.status = status;this.url = url;}}classTimeoutErrorextendsError{constructor(message, url){super(message);this.name ='TimeoutError' ;this.url = url;}}classNetworkErrorextendsError{constructor(message, url){super(message);this.name ='NetworkError' ;this.url = url;}}// 使用示例const api =newMonitoredFetch({baseURL:'https://api.example.com' ,timeout:5000 ,retries:2 });// 调用的时候就像普通fetch一样,但背后有监控和重试 api.request('/user/profile' ).then(res=> res.json()).then(data=> console.log(data)).catch(err=> {// 这里拿到的错误已经是分类好的,可以针对性处理if (err instanceofTimeoutError){showToast('网络太慢了,请检查网络或稍后再试' );}elseif(err instanceofNetworkError){showToast('网络好像断了,看看WiFi是不是没连' );}});
看到没,网络监控不只是记个错,还得把当时的网络环境、重试次数、耗时都记下来。这样你排查问题的时候才能还原现场,而不是对着"请求失败"四个字发呆。
SourceMap这玩意儿,真的是救命稻草 生产环境的代码都是压缩过的,报错信息长这样:at t.e (app.abc123.js:1:2345)。你看着这一堆a.b.c,根本不知道是哪个文件哪行代码。这时候SourceMap就是你的救命稻草。
但SourceMap怎么用,这里面有讲究。你不能直接把SourceMap文件扔到生产环境,因为那样等于把源码公开了(虽然可以配置只映射到行不映射到列,但还是能反推个大概)。所以通常的做法是:
构建时生成SourceMap,但不上传到CDN
把SourceMap文件存到内部服务器或者Sentry这种错误监控平台
线上报错时,用监控平台保存的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.jsexportdefault{build:{sourcemap:'hidden' ,// 生成但不引用// 或者// sourcemap: true, // 开发时用}};
然后在错误监控服务里,你需要实现一个堆栈还原的功能。如果你用Sentry,它自动就做了。如果自己实现,大概长这样:
// 简化的堆栈还原逻辑,实际要用source-map库const{ SourceMapConsumer }=require ('source-map' );asyncfunctionunminifyStackTrace(stackTrace, sourceMapContent){const consumer =awaitnewSourceMapConsumer(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' );}// 使用示例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));// 输出: at handleClick (src/components/Button.tsx:45 :12 )\nat render (src/App.tsx:123 :8 )
还原报错现场,得像侦探小说一样精彩 光知道哪行代码错了还不够,你得知道当时用户干了啥、页面啥状态、网络啥情况。这就需要"录屏"功能——不是真的录视频,而是记录用户操作和页面变化。
// 简化的用户行为录屏实现,用rrweb的思路classSessionRecorder{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,简化示例就记个URLurl: 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,// 长任务(阻塞主线程超过50ms 的任务)longTasks:this.getLongTasks ()});},5000 );}describeElement(element){// 给元素一个可读描述,比如 "button #submit .primary-btn "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(){// 需要PerformanceObserver支持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 ),// 报错前最近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 =newSessionRecorder({maxEvents:500 }); recorder.start ();// 报错时 window.addEventListener ('error',(e)=>{const context = recorder.getContextForError ();reportError({message: e.message,stack: e.error?.stack,// 带上用户操作上下文userActions: context.recentEvents,// 还可以带上DOM快照(如果用了rrweb的话)domSnapshot:captureDOMSnapshot ()});});
这种"录屏"数据配合错误堆栈,你排查问题的时候就跟看监控录像似的,用户点了哪、输入了啥、页面怎么变的,一目了然。但要注意隐私合规,密码、身份证号这些敏感信息一定要脱敏。
埋点策略得有讲究,别把用户浏览器卡死 监控代码本身不能成为性能瓶颈。我见过有团队为了"全量监控",在每个函数开头结尾都插桩,结果主线程直接堵死,页面卡顿得要命。
// 错误的示范:这种写法,函数多了直接卡爆functionheavyFunction(){ monitor.startTrack('heavyFunction' );// 这里可能有DOM操作,阻塞渲染// ... 业务逻辑 monitor.endTrack('heavyFunction' );}// 正确的姿势:采样 + 异步上报classSmartMonitor{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){// 采样:不是所有事件都记if (Math.random()>this.sampleRate)return ;this.buffer.push({...event,timestamp: Date.now()});// 攒够一批就发,但用requestIdleCallback,别卡主线程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){// 页面关闭时用sendBeacon,可靠但大小有限制(通常64KB)const blob =newBlob([JSON.stringify(data)],{type:'application/json' }); navigator.sendBeacon('/monitor/collect' , blob);this.isSending =false;}else {// 普通fetch上报fetch('/monitor/collect' ,{method :'POST ',headers : {'Content-Type' :'application/json' },body:JSON.stringify(data),keepalive:true// 页面关闭后也尽量发完}).catch(err=> {// 上报失败,存回buffer下次再试,但别无限增长if (this.buffer.length <100 ){this.buffer.unshift(...data);}}).finally(()=>{this.isSending =false;});}}}// 使用:性能开销极小const monitor =newSmartMonitor({sampleRate:0 .1 });functionbusinessLogic(){ monitor.track({type:'function_start' ,name:'businessLogic' });// ... 业务代码 monitor.track({type:'function_end' ,name:'businessLogic' ,duration:123 });}
关键点:采样(别全量)、批量(别一条条发)、异步(别卡主线程)、降级(sendBeacon)。这样监控代码对性能的影响可以忽略不计。
这招好用是好用,但副作用你也得心里有数 监控这东西,用好了是神器,用不好就是给自己挖坑。我见过太多团队监控搞得太激进,结果问题没解决,先制造了一堆新问题。
监控代码太猛,主线程直接堵死 前面说了性能优化,但还有些更隐蔽的坑。比如有些监控库为了"精确计时",用performance.now()在每次DOM操作前后打戳,结果频繁的时钟查询本身就成为性能瓶颈。
还有些库为了获取"完整的错误上下文",在报错时遍历整个DOM树,序列化成字符串。如果页面很大(比如那种无限滚动的长列表),这一下就能卡死好几秒。
// 危险操作:报错时遍历整个DOMfunctiongetFullDOMContext(){return document.documentElement.outerHTML;// 大页面这里直接爆炸,内存和CPU双杀}// 相对安全的做法:只取关键元素functiongetSafeContext(){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,形成"监控的监控":
// 防御性编程:监控代码也要被保护functionsafeMonitor(fn){returnfunction(...args){try{returnfn.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万条错误日志。存起来、索引起来、查询起来,都是钱。
// 错误聚合:相同的错误只记一次,但记次数classErrorAggregator{constructor(){this.errorMap =newMap();// 用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 =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 =newErrorAggregator(); window.addEventListener('error' ,(e)=>{ aggregator.report(e.error,{url: window.location.href,userId:getCurrentUserId()// 如果有的话});});
这样你存的不是100万条重复记录,而是几百条聚合记录,每条带个计数器。查询的时候也能一眼看出哪个错误影响面最大。
误报率太高,狼来了的故事听多了 如果监控太敏感,一堆无关紧要的警告会把真正的P0级错误淹没。比如第三方脚本的小错误、浏览器插件注入的代码错误、用户网络不稳定导致的请求失败,这些如果都报上来,团队很快就麻木了。
// 错误过滤策略functionshouldReportError(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))returnfalse;}/ / 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)returnfalse;}/ / 3. 只报自己域名的错误const isOwnDomain = error.stack && error.stack.includes(window.location.hostname);if(!isOwnDomain)returnfalse;/ / 谨慎开启,可能会漏掉CDN资源的错误/ / 4. 采样:高频错误降低采样率const fingerprint =getFingerprint(error);const count =getRecentErrorCount(fingerprint);/ / 需要维护一个计数if(count >100){/ / 这个错误已经出现100次以上了,只采样10%return Math.random()<0.1;}returntrue;}/ / 上报前检查 window.addEventListener('error' ,(e)=>{if (!shouldReportError(e.error))return ;reportError(e.error);});
过滤策略要持续维护,根据线上实际情况调整。定期review错误列表,把那些"无害但 noisy"的错误加入黑名单。
真到了生产环境,这套组合拳该怎么打 监控搭好了,怎么用是个学问。不能所有错误都一视同仁,得有分级处理机制。
分级处理才是王道 错误得分级,就像医院分急诊和门诊一样。有些错误记下来就行,有些得立即通知,有些得直接触发回滚。
// 错误分级系统classErrorSeverityClassifier{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' ,// 日报汇总notifyChannels:['email' ]};}// P3:轻微问题,周会reviewreturn{level:'P3' ,action:'weekly_review' ,notifyChannels:[]};}isP0Error(error, context){// 支付流程报错if (context.pageType ==='checkout' && error.message.includes('payment' )){returntrue;}// 核心API持续500 错误if (context.apiPath?.includes('/api/core' )&& context.httpStatus >=500 ){// 而且错误率超过阈值if (context.errorRate >0 .1 )returntrue;// 10 %的 请求失败}// 白屏错误(渲染层崩溃)if (error.message?.includes('render' )|| error.message?.includes('mount' )){if (context.isWhiteScreen)returntrue;}returnfalse;}isP1Error(error, context){// 登录注册流程if (context.pageType ==='login' || context.pageType ==='signup' ){returntrue;}// 用户数据加载失败if (error.message?.includes('user profile' ))returntrue;returnfalse;}isP2Error(error, context){// 非核心功能,比如推荐算法挂了if (context.pageType ==='recommendation' )returntrue;// 单个图片加载失败if (error.type ==='RESOURCE_ERROR' && error.resourceType ==='image' ){returntrue;}returnfalse;}}// 自动回滚逻辑(需要配合CI/CD)asyncfunctionautoRollback(errorInfo){// 只有P0错误且持续5 分钟以上才触发if (errorInfo.level !=='P0' )return ;const recentErrors =awaitgetRecentP0Errors(5 *60 *1000 );// 最近5 分钟if (recentErrors.length <10 )return ;// 错误数不够,可能是偶发// 触发回滚 console.error('🚨 触发自动回滚!' );// 调用CI/CD API回滚到上一个版本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,一个组件挂了可能导致整个页面白屏。这时候得有降级策略,让页面"瘸腿"也能跑。
// React的错误边界示例,但思路适用于所有框架import React from'react';classErrorBoundaryextendsReact.Component{constructor(props){super(props);this.state ={hasError:false,error:null};}staticgetDerivedStateFromError(error){// 下次渲染显示降级UIreturn{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){// 根据fallbackType显示不同的降级UIif(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 > );}returnthis.props.children;}}// 使用:给关键页面包一层functionProductPage(){return(<ErrorBoundary critical componentName ="ProductPage" > <ProductDetail /> <RelatedProducts /> </ErrorBoundary > );}// 给非关键组件单独包functionSidebar(){return(<ErrorBoundary componentName ="Sidebar" > <Recommendations /> </ErrorBoundary > );}
// 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('页面出现严重错误,建议刷新' );}};// 或者更细粒度的,在组件内exportdefault{name:'SafeComponent' ,methods:{riskyOperation(){try{// 可能出错的操作}catch(e){// 捕获并处理,不让错误冒泡this.handleError(e);}}},errorCaptured(err, instance, info){// 捕获子组件错误 console.log('子组件出错:' , err);returnfalse;// 返回false阻止错误继续传播}};
配合CI/CD,把低级错误扼杀在摇篮里 监控是最后一道防线,更好的办法是在代码提交前就发现问题。配合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('请求超时' ).should('be.visible' );});it('应该处理JS运行时错误' ,()=>{// 故意制造一个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('应该处理资源加载失败' ,()=>{// 拦截图片请求,返回404 cy.intercept('GET' ,'**/*.jpg' ,{statusCode:404 }).as('image404' ); cy.visit('/gallery' ); cy.wait('@image404' );// 验证显示了兜底图或者错误占位符 cy.get('img' ).each(($img )=>{// 检查onerror是否被触发,图片是否被替换为兜底图 cy.wrap($img ).should('have.attr' ,'src' ).and('include' ,'fallback' );});});});// 在CI中跑这些测试// .github/workflows/test.yml/* name: E2E Tests on: [push ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Cypress run uses: cypress-io/github-action@v4 with: start: npm start wait -on: 'http://localhost:3000' */
这些自动化测试能帮你发现"明显会崩"的场景。虽然不能覆盖所有错误,但至少能保证核心流程在异常情况下不挂。
遇到那种"在我本地明明是好的"玄学问题咋整 最烦人的就是这种玄学问题。本地好好的,一上线就崩;测试环境没问题,生产环境就报错。这时候怎么排查?
复现不了是最耍流氓的,教你几招顺藤摸瓜 首先,得有足够的上下文。用户ID、时间戳、浏览器版本、操作系统,这些基础信息必须有。
// 增强版错误上报,带上丰富的上下文functiongetRichContext(){return{// 用户标识(脱敏)userId:getUserId (),// 或者匿名IDsessionId: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,// DOM内容加载时间domContentLoaded: performance.timing ? performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart :null,// 内存使用(Chrome only)memory: performance.memory ?{usedJSHeapSize: performance.memory.usedJSHeapSize,totalJSHeapSize: performance.memory.totalJSHeapSize,jsHeapSizeLimit: performance.memory.jsHeapSizeLimit }:null},// 已加载的资源(用来排查资源加载顺序问题)resources: performance.getEntriesByType ? performance.getEntriesByType ('resource' ).slice (-20 )// 最近20 个资源.map (r=>({name: r.name,duration: r.duration,initiatorType: r.initiatorType })):[],// 全局状态(如果你用Redux/Vuex)storeState: getReduxState ?sanitizeState (getReduxState ()):null,// 最近的console日志(需要提前劫持console方法)recentLogs:getRecentConsoleLogs ()};}// 劫持console,记录最近的日志const recentLogs =[] ;constMAX_LOGS=50 ;['log' ,'warn' ,'error' ,'info' ] .forEach (method=>{const original = console[method] ; console[method] =function(...args ){ recentLogs.push ({ method,args: args.map (arg=>{// 简化序列化,避免循环引用try{returntypeof arg ==='object '?JSON.stringify (arg).substring (0 ,200 ):String (arg);}catch(e){return'[unserializable] ';}}),timestamp: Date.now ()});if(recentLogs.length >MAX_LOGS){ recentLogs.shift ();}returnoriginal.apply (this, args);};});functiongetRecentConsoleLogs(){return recentLogs;}// 上报时带上这些上下文 window.addEventListener ('error',(e)=>{reportError({error:{message: e.message,stack: e.error?.stack },context:getRichContext ()});});
有了这些信息,你可以按用户ID查日志,看他在报错前都干了啥。也可以按时间戳聚合,看是不是某个时间点集中爆发(可能是CDN节点问题、服务器发布问题)。
偶发的内存泄漏,怎么像抓鬼一样揪出来 内存泄漏最难搞,因为它不会立即报错,而是让页面越来越卡,最后崩溃。而且本地开发时通常不会长时间运行,很难发现。
// 内存监控工具classMemoryLeakDetector{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.location.href // 记录当前页面,SPA可能切换路由});// 只保留最近20 个测量点if (this.measurements.length >20 ){this.measurements.shift();}// 分析趋势this.analyzeTrend();}analyzeTrend(){if (this.measurements.length <5 )return ;// 计算最近5 个点的平均增长率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,// 尝试获取堆快照(需要Chrome DevTools协议,生产环境通常不行)// 但可以记录当前的DOM节点数作为参考domNodes: document.getElementsByTagName('*' ).length,eventListeners:this.getEventListenerCount()// 粗略估计});}}getEventListenerCount(){// 这个很难精确获取,但可以通过hack的方式估计// 实际生产环境建议用WeakRef和FinalizationRegistry来跟踪对象return 'estimated' ;}reportLeak(details){reportError({type:'MEMORY_LEAK_DETECTED' ,severity:'warning' ,details: details,suggestion:'页面内存持续增长,可能存在泄漏,建议检查定时器、事件监听、DOM引用' });// 如果增长太夸张,提示用户刷新(避免崩溃)if (details.growthFactor >3 ){showToast('页面运行时间较长,建议刷新以获得最佳体验' );}}}// 使用const leakDetector =newMemoryLeakDetector();
更专业的做法是用Chrome的Performance面板手动分析,但生产环境你没法让用户帮你开DevTools。所以这种自动监控是必要的补充。
第三方脚本挂了怎么办?沙箱隔离和超时熔断 现在的网站都依赖一堆第三方脚本:统计、广告、客服、支付……这些脚本如果挂了,可能拖慢甚至拖垮你的页面。
// 第三方脚本加载管理器classThirdPartyManager{constructor(){this.scripts =newMap();this.timeouts ={'analytics' :5000 ,// 统计脚本5 秒超时'chat-widget' :10000 ,// 客服组件10 秒'payment' :15000 // 支付脚本15 秒,这个重要可以多等会儿};}asyncloadScript(name, url, options ={}){const timeout = options.timeout ||this.timeouts[name]||5000 ;returnnewPromise((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(newError(`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(newError(`Script ${name} load failed` ));};// 沙箱隔离:用iframe加载特别危险的脚本(可选,看安全需求)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 });returnnull;// 返回null表示加载失败,但业务代码继续跑});}loadInSandbox(name, url, resolve, reject){// 创建隐藏iframe作为沙箱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(newError('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 =newThirdPartyManager();// 加载非关键脚本,失败也无所谓 tpm.loadScript('analytics' ,'https://analytics.example.com/track.js' ,{timeout:3000 // 统计脚本3 秒没加载完就放弃,别拖慢页面});// 加载关键脚本(比如支付),但也要有超时 tpm.loadScript('payment' ,'https://payment.example.com/sdk.js' ,{timeout:10000 }).then(()=>{// 初始化支付组件initPayment();}).catch(()=>{// 支付脚本加载失败,显示降级提示showPaymentFallback();});// 业务代码里检查可用性functiontrackEvent(event){if (tpm.isAvailable('analytics' )){ window.analytics.track(event);// 假设第三方脚本全局暴露了方法}else {// 脚本不可用,存到本地队列,等它加载了再补发,或者直接丢弃 console.log('Analytics not ready, dropping event:' , event);}}
关键点:超时熔断(别无限等)、失败降级(别阻塞主流程)、可选加载(非关键脚本可以没有)。这样第三方脚本挂了,你的页面还能正常跑。
面对用户截图里那个模糊不清的报错,怎么引导他们提供有价值的信息 有时候用户反馈问题,就给你一张模糊的截图,上面半个错误信息。这时候怎么引导他们提供有用的信息?
<divclass="error-container"><h1 > 哎呀,出错了</h1 > <p > 我们已经记录了这个问题,技术团队正在处理</p > <detailsclass="diagnostics"><summary > 技术详情(点击展开,反馈给客服时有用)</summary > <divclass="error-code"><p > 错误ID: <spanid="error-id">ERR-20240315-ABC123</span > </p > <p > 时间: <spanid="error-time">2024-03-15 14:32:01</span > </p > <p > 页面: <spanid="error-page">/checkout/payment</span > </p > <buttononclick="copyDiagnostics()">复制诊断信息</button > </div > </details > <divclass="actions"><buttononclick="location.reload()">刷新页面</button > <buttononclick="contactSupport()">联系客服</button > </div > </div > <script > </script >
其次,客服话术要培训好。别问"你遇到什么问题"这种开放式问题,要问"错误ID是多少"、'报错时你在哪个页面'、'有没有看到红色的错误提示'。
几个让你少加班的野路子技巧 说了这么多监控和排查的,最好的错误处理是不出错。这里分享几个从源头减少错误的野路子。
TypeScript把类型检查做到极致 // 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,后面调用时不知道有没有这个方法functionprocessData(data){return data.map(item => item.name);/ / 如果data不是数组,这里就炸}/ / 好的写法:明确类型,编译时就发现问题interfaceUser{ id:number; name:string; email:string;}functionprocessData(data: User[]):string[]{/ / TS会保证data是数组,item有name属性return data.map(item => item.name);}/ / 更严格的:处理边界情况functionsafeProcessData(data: User[]|undefined|null):string[]{/ / 开启strictNullChecks后,必须处理undefined/nullif (!data){return [];// 或者抛出错误,但至少显式处理了}// 开启noUncheckedIndexedAccess后,连数组索引都要检查const first = data[0];if(first){/ / 必须检查,因为data[0]可能是undefinedconsole.log(first.name);}return data.map(item => item.name);}/ / 用 branded type 防止ID混淆typeUserId=string&{ __brand:'UserId'};typeOrderId=string&{ __brand:'OrderId'};functiongetUser(id: UserId){...}functiongetOrder(id: OrderId){...}const userId ='123'as UserId;const orderId ='456'as OrderId;getUser(userId);/ / OKgetUser(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 / 负数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 }]};/ / 应该抛出溢出错误或者用BigInt 处理expect(()= > processOrder(order )).toThrow('Amount overflow' );});/ / 异步错误it('应该处理API超时' ,async()= > {/ / 模拟超时 jest.useFakeTimers();const promise = processOrderWithAPI({items:[]}); jest.advanceTimersByTime(10000 );/ / 快进10 秒awaitexpect(promise).rejects.toThrow('Request timeout' ); jest.useRealTimers();});});/ / 用属性测试(Property- based testing)发现边界情况/ / 用fast- check 库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配置吧,别等会儿电话真响了。☕
相关免费在线工具 curl 转代码 解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online