前端老鸟血泪史:搞定那些让人头秃的报错,让线上稳如老狗

前端老鸟血泪史:搞定那些让人头秃的报错,让线上稳如老狗

前端老鸟血泪史:搞定那些让人头秃的报错,让线上稳如老狗

前端老鸟血泪史:搞定那些让人头秃的报错,让线上稳如老狗

别在那硬扛了,聊聊咱们天天跟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"

<!-- 不加crossorigin,报错信息会被浏览器吞掉 --><scriptsrc="https://cdn.example.com/app.js"></script><!-- 加了crossorigin,配合CDN的CORS头,才能拿到详细错误 --><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文件扔到生产环境,因为那样等于把源码公开了(虽然可以配置只映射到行不映射到列,但还是能反推个大概)。所以通常的做法是:

  1. 构建时生成SourceMap,但不上传到CDN
  2. 把SourceMap文件存到内部服务器或者Sentry这种错误监控平台
  3. 线上报错时,用监控平台保存的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>);}

对于非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('页面出现严重错误,建议刷新');}};// 或者更细粒度的,在组件内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>// 生成错误ID,方便后续追踪functiongenerateErrorId(){return'ERR-'+newDate().toISOString().slice(0,10).replace(/-/g,'')+'-'+ Math.random().toString(36).substring(2,8).toUpperCase();}// 把诊断信息复制到剪贴板,方便用户粘贴给客服functioncopyDiagnostics(){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('诊断信息已复制,请粘贴给客服')).catch(()=>alert('复制失败,请手动截图'));}// 上报时带上这个errorIdreportError({errorId: document.getElementById('error-id').textContent,// ... 其他信息});</script>

其次,客服话术要培训好。别问"你遇到什么问题"这种开放式问题,要问"错误ID是多少"、“报错时你在哪个页面”、“有没有看到红色的错误提示”。

几个让你少加班的野路子技巧

说了这么多监控和排查的,最好的错误处理是不出错。这里分享几个从源头减少错误的野路子。

TypeScript把类型检查做到极致

别只是把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,后面调用时不知道有没有这个方法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配置吧,别等会儿电话真响了。☕

在这里插入图片描述

Read more

前端SSE(Server-Sent Events)实现详解:从原理到前端AI对话应用

一、什么是SSE? SSE(Server-Sent Events)是一种服务器向客户端推送数据的技术,它允许服务器主动向客户端发送数据,而不需要客户端频繁轮询。SSE特别适合实时通信场景,比如AI聊天的流式输出、实时通知、股票行情更新等。 SSE的核心特点: * 单向通信 :服务器向客户端单向推送数据 * 基于HTTP :使用标准的HTTP协议,不需要特殊的服务器支持 * 自动重连 :连接断开时会自动尝试重连 * 文本格式 :使用简单的文本格式传输数据 * 轻量级 :实现简单,开销小 二、SSE的工作原理 1. 连接建立 客户端通过向服务器发送一个HTTP请求来建立SSE连接。服务器返回一个特殊的响应,设置 Content-Type: text/event-stream 头,告诉客户端这是一个SSE流。 2. 数据传输 服务器以流的形式持续发送数据,每个数据块都是一个SSE格式的消息。SSE消息格式如下: data: 消息内容\n\n 其中: * data: 是固定前缀 * 消息内容可以是任意文本,

地理空间大揭秘:身份证首位数字的隐藏含义-使用WebGIS进行传统6大区域展示

地理空间大揭秘:身份证首位数字的隐藏含义-使用WebGIS进行传统6大区域展示

目录 前言 一、关于身份证的空间信息 1、身份证与省份信息 2、首位数字与区域 二、数字与空间展示可视化 1、地域及图例的前端定义 2、省份与区域信息展示 三、成果展示 1、华北地区 2、东北地区 3、华东地区  4、中南地区 5、西南地区 6、西北地区  四、总结 前言         在我们日常生活中,身份证号码是每个人独一无二的身份标识,它承载着丰富的信息,其中第一位数字更是蕴含着与地理空间紧密相关的秘密。这一位数字并非随意排列,而是与我国广袤的国土划分有着深刻的联系。通过 WebGIS(Web 地理信息系统)技术,我们能够以一种直观、生动的方式,将身份证首位数字所代表的地理区域进行可视化展示,从而揭开传统 6 大区域的神秘面纱。       中国地域辽阔,地理环境复杂多样。

手把手教程:用GLM-4.6V-Flash-WEB做文物智能问答

手把手教程:用GLM-4.6V-Flash-WEB做文物智能问答 你有没有试过站在博物馆展柜前,盯着一件青铜器发呆——想知道它叫什么、来自哪个朝代、为什么纹饰是这样?可导览牌只有短短两行字,语音讲解器又卡在上一个展厅。其实,只要一台能跑GPU的电脑、一个浏览器,再加上几分钟操作,你就能让文物“自己开口说话”。 今天这篇教程不讲原理、不堆参数,就带你从零开始,用 GLM-4.6V-Flash-WEB 搭建一个真正能用的文物智能问答系统。它不是演示项目,而是智谱AI最新开源的轻量级视觉语言模型镜像,支持网页直连+API调用,单张RTX 3090即可流畅运行,中文文物理解能力扎实,部署完就能拍图提问。 不需要你懂ViT或跨模态注意力,也不用配环境、装依赖、改配置。整个过程就像安装一个软件:下载、启动、打开网页、上传图片、输入问题——答案立刻出来。下面我们就一步步来。 1. 镜像准备与一键部署 1.1 硬件与系统要求 GLM-4.6V-Flash-WEB对硬件非常友好,