前端错误捕获与监控:从原理到自研工具rea的工程实践

发布时间:2026/10/20 17:29:30
前端错误捕获与监控:从原理到自研工具rea的工程实践
前阵子有个线上项目出了个诡异的问题某个页面的按钮偶尔没反应本地联调和测试环境都复现不出来用户反馈也是断断续续。打开生产环境的控制台只见一片空白——没有报错、没有警告仿佛一切正常。这种“静默失败”最折磨人。也就是那阵子我决定动手写一个专门用于前端运行时错误捕获和分析的小工具代号叫 reaRuntime Error Analyzer。它要解决的事情很朴素把线上环境里看不到的报错自动抓到、整理清楚、在需要的时候告诉我。下面这篇记录里有完整的设计思路也有接入真实项目后的坑和教训做前端监控、做工具链的同学可以参考。1. 为什么我会动手写一个叫“rea”的小工具1.1 一个让我反复折腾的线上故障那次出问题的模块本身不复杂一个列表页用户点击某一行的“详情”按钮弹层展示详情数据。问题在于这个弹层在部分用户那里能打开在另一部分用户那里点了毫无反应。因为没有报错信息我根本不知道是点击事件没绑定上还是接口返回的数据结构不对还是弹层组件渲染时抛了异常。几轮排查下来唯一的收获是“在某一台测试机上偶尔能复现”但一开 DevTools 就恢复正常典型的“调试时它就好关掉就出问题”。这种问题靠本地日志根本没法追。我意识到生产环境的应用绝对不能依赖“用户帮你打开控制台”这种排查方式必须在代码层面自动把错误信息留下来。这个痛点很普遍业务代码一上线就是各种压缩混淆、异步加载、第三方脚本、不同浏览器内核的组合报错信息失真严重。与其继续被这个问题反复折磨不如自己做一个轻量级的错误捕获工具把线上错误“挖”出来。1.2 为什么没直接套用现成的监控平台你可能想问行业里成熟的监控平台那么多直接用不就好了我当时也认真评估过最后放弃了理由有几个很现实配置链路太长。要注册账号、建应用、配域名白名单、加 SDK小项目根本不想搞这么重的仪式感。数据出域问题。有些项目的业务数据比较敏感错误堆栈里可能夹带 URL 参数、接口字段团队对数据上报到第三方平台有顾虑。免费额度对高频项目不友好。错误监控按量计费一个稍微活跃点的前端应用错误上报量很容易把免费额度跑穿。定制空间有限。现成平台通常只给你“上报—展示”的功能想按自己的思路做错误聚类、按业务线过滤、自定义面包屑都很麻烦。自研不一定能做得比大平台更广但可以做得更贴自己的业务。rea 的定位从一开始就很明确轻量、自托管、能细化定制只做“错误采集—清洗—上报—查询”这条链路。1.3 rea 的功能需求清单动手之前我把需求写成了一张清单后面所有开发和迭代都以它为准捕获全局未处理异常window error捕获未处理的 Promise 拒绝unhandledrejection捕获资源加载失败脚本、图片、样式等正常提取错误堆栈并对堆栈做清洗降噪记录用户的关键操作路径点击、路由跳转、接口请求用于还原出错现场批量上报并支持失败重试对业务代码的性能影响降到最低支持 sourcemap 映射把压缩后的堆栈还原成源码位置。有了这份清单我才好确定架构边界不至于一上来就设计得过度复杂。2. rea 的架构设计与模块划分2.1 整体设计思路把错误当数据流处理rea 的核心并不复杂简单说就是“全局布点、统一收集、集中处理”。整个工具在运行时是一个单例对象页面初始化时注册各种全局监听器然后把捕获到的错误统一塞进一条处理管道里。我把这条管道分成四层各管各的捕获层负责监听各种来源的错误事件把浏览器原始的 error 事件、rejection 事件、资源加载失败事件转换成统一的内部数据结构解析层对原始错误信息做清洗、格式化过滤掉框架内部调用帧提取关键堆栈帧必要时做 sourcemap 还原存储层维护一个内存队列积攒一定量错误后统一上报同时用本地存储暂存失败的上报请求上报层把错误数据通过接口发送到自建日志服务支持批量、节流、去重。你可以把这四层理解成一条流水线捕获层是摄像头先把现场录下来解析层是剪辑师把没用的画面剪掉存储层是临时仓库货攒够了再发车上报层是快递员负责把包裹安稳送到目的地。这样分层的好处是每一层都可以独立替换或扩展比如上报层想从走接口改成走日志网关只需要改一个模块。2.2 错误记录的数据结构设计数据模型是错误分析工具的地基。如果一开始字段设计得不够用后面加字段会连带改一堆模块。我在 rea 里定义了一条错误记录的核心结构interface REAErrorRecord { id: string; // 唯一标识用于去重 time: number; // 错误发生时间戳 level: error | warn | info; type: script | resource | unhandledrejection | custom; message: string; // 错误信息 stack: string; // 清洗后的堆栈 filename: string; // 出错文件 lineno: number; colno: number; breadcrumbs: REABreadcrumb[]; // 用户操作轨迹 device: { userAgent: string; language: string; screen: string; }; url: string; // 出错页面地址 custom?: Recordstring, unknown; // 业务自定义字段 }这份结构里有两个地方容易被忽略。一是breadcrumbs和device必须和错误本身一起上报因为同一个错误在不同设备、不同操作路径下的根因可能完全不一样二是custom字段一定要放开因为业务方经常需要额外带上“当前用户身份”“订单号”这类上下文信息。没有这个扩展位工具用起来会很别扭。2.3 模块间通信轻量事件管道rea 内部没有引入复杂的状态管理模块之间通过一个极简的发布订阅通道通信。捕获层产生一条新错误时调用rea.push(record)解析层监听push事件做清洗上报层监听解析结果做批量发送。这个通道只做一件事——把数据从源头送到下游没有多余动作。// 简化版事件通道 const listeners new Map(); function on(eventName, handler) { if (!listeners.has(eventName)) listeners.set(eventName, []); listeners.get(eventName).push(handler); } function emit(eventName, data) { const handlers listeners.get(eventName) || []; handlers.forEach((handler) { try { handler(data); } catch (e) { // 处理函数自身出错时不能影响主流程 } }); }这里有个很重要的设计取舍监听器内部必须各自捕获异常。因为错误分析工具本身的代码如果抛错绝不能再抛回业务环境里去。否则就是“本来想治病的药把病人吃坏了”。这也是这类工具的基本自觉。3. 关键实现细节从捕获到上报的完整链路3.1 全局错误事件监听onerror 与 addEventListener 的取舍捕获全局脚本异常最常见的方式有两种window.onerror和window.addEventListener(error, handler, true)。很多人直接选一个用但两者有本质区别。window.onerror能拿到message、source、lineno、colno、error对象信息比较完整但它是一个覆盖式的属性赋值如果项目里其他代码也设置了window.onerror就会被顶掉。比如有些第三方 SDK 或老代码喜欢劫持onerror很容易互相覆盖。addEventListener(error, handler, true)注册的是标准的 DOM 事件监听可以并存多个处理器不会被覆盖。而且第三个参数用true开启捕获阶段能比冒泡阶段更早接住错误也更容易区分错误来源。但直接从 event 对象上解析信息会比较绕不如onerror直观。rea 的实际做法是统一使用addEventListener(error, handler, true)在 handler 内部从事件对象上提取信息。这样做的好处是接管了全局注册权不会和业务代码、第三方库冲突代价是自己多写几行解析逻辑。核心代码如下window.addEventListener(error, (event) { // 区分资源加载失败和脚本运行错误 const target event.target; if (target (target.src || target.href)) { // 资源加载失败例如 script src... rea.push({ type: resource, level: error, time: Date.now(), message: ${target.tagName.toLowerCase()} 加载失败: ${target.src || target.href}, stack: , filename: target.src || target.href, lineno: 0, colno: 0 }); return; } // 脚本运行错误 rea.push({ type: script, level: error, time: Date.now(), message: event.message, stack: event.error ? event.error.stack : , filename: event.filename || , lineno: event.lineno || 0, colno: event.colno || 0 }); }, true);判断资源加载失败靠的是event.target是否存在src或href。因为资源加载出错时event.message往往为空但 target 会指向具体的 DOM 元素能拿到实际的加载地址。这个细节特别适合排查“某个 JS 文件没加载出来导致功能白屏”的问题。3.2 Promise 拒绝的捕获异步错误的兜底网window.onerror和error事件有一个共同短板捕获不到未处理的 Promise 拒绝。比如一句await someAsyncFunc()内部抛异常但外层没有 try/catch在大多数浏览器里只会触发unhandledrejection事件不会触发全局 error。类似问题在现代前端项目里占比越来越高尤其是接口请求相关的异常。rea 单独监听unhandledrejection事件window.addEventListener(unhandledrejection, (event) { const reason event.reason; let message ; let stack ; if (reason instanceof Error) { message reason.message; stack reason.stack ? cleanStack(reason.stack) : ; } else { // 有些 promise 拒绝抛出的不是 Error 对象而是普通对象或字符串 message typeof reason string ? reason : JSON.stringify(reason); } rea.push({ type: unhandledrejection, level: error, time: Date.now(), message, stack, filename: stack ? extractFileNameFromStack(stack) : , lineno: 0, colno: 0 }); });这里我踩过一个坑很多拒绝原因并不是Error实例而是一个普通对象比如Promise.reject({ code: 403, msg: forbidden })。如果直接读reason.message会得到 undefined如果直接JSON.stringify(reason)又可能遇到循环引用导致序列化失败。所以要先判断类型再决定怎么取值非 Error 对象尽量用安全序列化处理能拿多少信息拿多少。另外捕获 unhandledrejection 是有时间窗口的。浏览器会在 Promise 被标记为无法处理时才触发这个事件如果代码里某个 promise 在后续才被 catch则不会触发。实际项目中我遇到过“报错记录里出现了一条 unhandledrejection但业务代码明明有 catch”的情况就是因为 catch 调用晚于事件派发。遇到这类误报需要在业务侧规范写法尽量在 promise 链中同步 catch。3.3 堆栈清洗与格式化让报错不再是一大坨噪音浏览器原生的error.stack是一长串文本里面混合着业务代码、框架内部代码、第三方库代码直接看非常痛苦。特别是在压缩后的生产包里堆栈还可能是完全没意义的混淆名称。rea 的解析层要做三件事第一按行解析堆栈帧。每一行对应一个调用帧提取出文件名、行号、列号、函数名。以 Chrome 为例典型的调用帧长这样at handleClick (app.js:123:45) at HTMLButtonElement.dispatch (vendor.js:45:12)我用正则把这几种常见格式统一解析成结构化对象再拼成可读字符串。第二过滤框架与库的内部帧。框架内部的调度代码、事件绑定代码、渲染代码对定位业务错误帮助不大反而干扰视线。rea 维护了一个“噪音关键词”列表凡是调用栈里出现这些关键词的帧一概过滤掉。这个列表可以按项目动态配置。const defaultNoisePatterns [ /node_modules\//, /webpack\/runtime/, /react-dom/, /vue\.runtime/, /zone\.js/, /core-js/ ]; function cleanStack(stack, extraPatterns []) { const patterns [...defaultNoisePatterns, ...extraPatterns]; return stack .split(\n) .map((line) line.trim()) .filter((line) line !patterns.some((p) p.test(line))) .slice(0, 10) // 最多保留 10 帧避免无用的长堆栈 .join(\n); }第三控制堆栈长度。有些嵌套很深的报错堆栈能有几十上百行全部上报既费流量也不好阅读。截取前 10 个有效帧足够定位绝大多数问题还能大幅降低上报数据体量。3.4 面包屑记录还原用户出错前的操作路径光有一条错误堆栈往往只能告诉你“代码在哪一行炸了”但无法告诉你“用户是怎么走到这一步的”。rea 里加入了一个简单的面包屑记录机制类似路口监控用户点击、路由切换、接口请求发生时都往循环队列里记一条小记录。出错时把最近 30 条操作轨迹和错误记录一起上报。const breadcrumbs []; function addBreadcrumb(category, message, data) { breadcrumbs.push({ time: Date.now(), category, // click | route | request | custom message, data: data ? safeStringify(data) : }); if (breadcrumbs.length 30) breadcrumbs.shift(); // 只保留最近 30 条 } document.addEventListener(click, (e) { const el e.target; addBreadcrumb(click, ${el.tagName}${el.id ? # el.id : }${el.className ? . el.className : }.slice(0, 80)); }, true);注意两个细节一是面包屑队列必须固定容量否则站点访问时间长了内存越占越多二是记录内容要做截断不能把超长的选择器或接口响应都塞进一条面包屑里。面包屑在实际排障中价值极大。曾经有一条线上错误的堆栈完全指向一个工具函数单看堆栈没人知道为什么触发。后来翻面包屑发现用户先在一个输入框键入内容然后快速连点了两次提交按钮第二个请求在第一个请求返回前就发出去收到了一个非预期状态码引发后续渲染报错。这条路径光靠堆栈根本看不出来。3.5 上报策略批量、防抖、重试错误上报如果每发生一条就发一个请求既浪费连接也容易被打到日志接口限流。rea 的上报层做了三层控制批量发送。错误先进入内存队列当队列长度达到阈值比如 10 条或距离上一次上报超过一定时间比如 5 秒统一发送一次。这样既保证实时性又显著减少请求次数。防抖与节流。如果某个瞬间连续触发几十条同类错误比如某个公共模块加载失败导致连锁报错rea 会按错误指纹去重合并。指纹用“错误类型 文件名 行号 消息前 50 字符”生成一个哈希同一指纹在短时间窗口内只上报第一条。失败重试。上报请求可能因为网络波动或服务器短暂异常而失败。rea 会把发送失败的批次序列化后写入localStorage下次页面加载或下一次定时器触发时重新尝试。下面是最核心的上报逻辑const queue []; let timer null; const MAX_BATCH 10; const FLUSH_INTERVAL 5000; function flush() { if (queue.length 0) return; const events queue.splice(0, queue.length); const payload JSON.stringify({ events, appId: REA_APP_ID }); if (navigator.sendBeacon) { // sendBeacon 在页面卸载时也能可靠发送 navigator.sendBeacon(REA_REPORT_URL, payload); } else { fetch(REA_REPORT_URL, { method: POST, headers: { Content-Type: application/json }, body: payload, keepalive: true }).catch(() { // 失败暂存等下次重试 try { const saved JSON.parse(localStorage.getItem(REA_SAVE_KEY) || []); saved.push(...events); localStorage.setItem(REA_SAVE_KEY, JSON.stringify(saved)); } catch (e) { /* 本地存储写入失败就丢弃避免无限循环 */ } }); } }这里值得说一句在页面关闭、跳转这种场景下fetch可能还没发出去连接就被中断了但sendBeacon是专门为“页面即将销毁时上报”设计的所以优先用sendBeacon。rea 在移动端 H5 里接入时这一个选择直接决定了很多关键错误会不会在用户离开页面时丢失。4. 接入真实项目后我踩过的坑4.1 “Script error.” 空报错被浏览器屏蔽的跨域异常rea 上线跑了一周后我发现上报里有相当一部分错误信息只有孤零零的一行Script error.没有堆栈、没有行列号根本没法用。查了半天这是浏览器对跨域脚本的安全限制当脚本通过script src//other-domain.com/lib.js加载时如果目标服务器没有返回正确的 CORS 头浏览器会把运行时的具体报错信息隐藏起来只留一句毫无意义的 “Script error.”。定位这类问题有两个层面。第一层是让脚本本身支持跨域捕获在script标签上添加crossoriginanonymous属性同时要求服务器在响应头里带上Access-Control-Allow-Origin: *或对应域名。这样浏览器才肯把具体的错误信息传给全局事件。第二层是兜底处理对于实在无法加头的第三方脚本rea 会把Script error.单独标记不进入常规错误聚合避免噪音干扰。这类错误虽然拿不到具体堆栈但它至少提示了“有一个外部脚本在某个页面上炸了”有线索总比没线索好。4.2 重复上报一组错误出现两条记录调试时我又发现一个怪现象同一条错误经常出现两条记录一条来自error事件一条来自unhandledrejection。后来弄明白了某些浏览器把异步函数中的异常既派发为 rejection又在某些场景下触发了全局 error也有的是因为我在window.addEventListener(error)里对这种错误做了处理但框架内部自己也注册了一套错误处理器两边同时捕获又都往 rea 里塞。解决思路是加一个“已处理指纹”的集合。rea 在 push 一条错误前先计算唯一指纹如果短时间内该指纹已经存在就只更新计数不上报重复内容。const recentKeys new Set(); function makeFingerprint(record) { return ${record.type}:${record.filename}:${record.lineno}:${record.colno}:${record.message.slice(0, 50)}; } function push(record) { const key makeFingerprint(record); if (recentKeys.has(key)) { return; // 重复错误直接忽略 } recentKeys.add(key); setTimeout(() recentKeys.delete(key), 60000); // 60 秒内去重 emit(record, record); }这个 60 秒去重窗口足够覆盖绝大多数重复捕获场景。加了这层之后错误列表瞬间清爽了很多。4.3 压缩混淆后的堆栈还原sourcemap 的正确用法生产环境跑的是压缩混淆过的代码报错堆栈常见长这样at t (app.9f8a2b.js:1:12345) at n (app.9f8a2b.js:1:23456)这种堆栈只能证明“代码在 app.js 里炸了”但完全定位不到源码。要解决这个问题必须做 sourcemap 还原。首先在构建阶段生成 sourcemap 文件。以常见的前端构建工具为例配置里开启 sourcemap 输出得到app.js.map这样的文件。然后rea 的服务端在接收到带行列号的错误堆栈后用 sourcemap 解析库把压缩位置映射回源码位置。// 伪代码源码位置还原逻辑 const { SourceMapConsumer } require(source-map); async function resolveStackFrame(stackFrame, mapFiles) { const consumer await new SourceMapConsumer(mapFiles[stackFrame.file]); const pos consumer.originalPositionFor({ line: stackFrame.line, column: stackFrame.column }); return { source: pos.source, // 原始文件名 line: pos.line, // 原始行号 column: pos.column, // 原始列号 name: pos.name // 原始函数名 }; }用这个方案我成功定位过好几个之前完全看不懂的报错最后发现都是压缩后代码逻辑顺序导致的兼容性问题。这里必须提醒一个安全红线sourcemap 文件绝不能发布到公网静态目录。因为 sourcemap 会暴露完整源码等于把项目的业务逻辑全部公开。rea 的方案是sourcemap 只在服务端留存且只在错误解析链路中使用静态服务器上做一个“禁止访问”规则。还有另一个折中方案不上传整个 map 文件只把构建产物中“源码路径、变量名”的映射关系以脱敏形式存入内部服务。这一步千万不能省。4.4 误报与噪音业务主动抛错也算 error接入一段时间后我们收到一批“看起来不太对”的上报错误信息是一串业务提示比如“订单状态异常”“无权限访问”“数据为空”它们不是代码 bug而是业务逻辑主动抛出的异常或进行某种流程控制时触发的提示。这些报错如果原样上报会把错误列表搅浑让人分不清哪些是真故障。rea 为此设计了忽略规则和自定义分级机制。对于已知的业务抛出场景运营方可以把特定的错误信息模板加入忽略列表也可以把某些业务异常标记为 warn 级别而不是 error 级别不进入核心告警。在 rea 的配置面板上我用“错误指纹模板 匹配规则”的简单方式管理比如按错误信息前缀匹配、按文件名匹配、按自定义标签匹配。实际使用下来这里的配置需要和业务团队充分沟通。有些业务主动抛错确实需要关注比如“下单失败”就不只是流程控制可能是接口异常的前置信号。所以 rea 的级别标记不是简单地屏蔽而是分类处理。4.5 性能开销错误工具不能变成新的性能瓶颈错误分析工具自己跑在业务页面上如果设计得不谨慎会让页面变得更卡这就是本末倒置。rea 在性能上有几条铁律捕获和推送分离。监听器只负责把原始事件转成结构化数据、塞进队列绝不直接在事件回调里做耗时操作比如JSON.stringify大对象、同步写入 localStorage、请求发送。堆栈清洗延迟化。清洗和 sourcemap 解析这类计算量大的操作尽量放到requestIdleCallback里去执行浏览器空闲了再处理。上报批量与节流。前面说过攒够 10 条或者 5 秒一次避免高频请求。错误对象安全序列化。有些错误对象里附加了巨大对象字段直接序列化会卡主线程。rea 用自定义的safeStringify只保留可枚举的、浅层的、长度受限的字段。有一次我实测在低端安卓机上反复触发 50 条错误页面帧率几乎没有波动说明这种“延迟化 批量”的设计对性能影响足够小。自研工具最怕的就是“监控代码比业务代码还慢”这一点必须作为核心指标来盯。5. 日常接入 rea 后的真实效果与后续扩展5.1 上线几周后我看到了这些变化rea 接入到真实项目后的效果比我想象中明显。最直接的改变是排查问题从“打开日志盲猜”变成“拿着错误路径还原现场”。有两条记录让我印象很深。一条是只在某品牌低端安卓机上出现的报错堆栈指向某个数组方法在当前系统 WebView 中不受支持。这种问题如果靠用户反馈几乎不可能定位因为用户也不知道怎么描述。另一条是页面跳转后老页面定时器没有清理在特定操作顺序下会调用一个已卸载组件的状态更新最终抛错。这个 bug 在面包屑里清晰显示为“用户从商品列表页快速切换到详情页再切回5 秒后报错”定位过程不到十分钟。这些案例证明了一点错误上报工具真正有用的地方不在于告诉你“哪一行代码炸了”而在于把“用户路径 环境信息 错误堆栈”三者拼成完整现场。单纯一个堆栈很多时候什么也证明不了。5.2 后续扩展方向聚类、告警、与环境结合rea 当前版本满足了我的核心需求但要说完全够用还早。我整理了几个明确想加的扩展方向按优先级排第一是错误聚类。现在错误列表还是按时间排的如果某个报错每天出现几百次需要人工分辨它的规律。下一步准备按堆栈指纹做自动聚类把同类错误合并成一条“问题”并统计影响用户数和影响页面数。第二是告警通知。rea 已经接入了简单的告警逻辑某个“问题”在 5 分钟内的上报量超过阈值就通过内部协作机器人的 webhook 推一条消息。这一步做好之后线上故障就不需要等用户反馈了。第三是与性能数据结合。错误经常不是孤立发生的它背后可能是接口超时、资源加载慢、内存占用过高等。把错误记录和页面性能数据放在同一条时间线上看能更容易发现“先有性能劣化、后有报错”的因果链。5.3 给想自研错误分析工具的人几条实在建议如果你也想动手写一个类似的内部工具这几条经验应该能帮你少走弯路先跑通最小闭环。不要上来就设计监控平台、告警体系、可视化大盘。先把“捕获—清洗—上报—查询”这条链路跑起来哪怕上报后只在日志平台里查也算闭环。功能是长出来的不是设计出来的。配置项要给默认值。项目成员接入时没人会愿意逐个配置参数。rea 的所有内部参数都有合理默认值比如面包屑容量 30 条、上报间隔 5 秒、堆栈截取 10 帧。想改的再单独覆盖。敏感字段必须脱敏。错误堆栈和面包屑里很可能夹带用户手机号、订单号、token。rea 在序列化时对明显敏感字段做正则替换避免敏感数据落入日志系统。不要试图捕获一切。捕获所有错误的最直接后果是噪音淹没信号。rea 的设计哲学是“关注用户能感知的异常”比如页面白屏、功能无响应、接口数据渲染失败而不是把所有 console 输出都收上来。保证工具自身可观测。rea 本身也会出错所以它的内部处理函数都有 try/catch 兜底不会影响业务。但与此同时我也给它留了一个 console 级别的调试开关方便在开发环境排查 rea 自身的问题。最后再分享一个小技巧。rea 上线后我把它的上报接口单独做了每日“0 数据”巡检如果连续一段时间一条错误都没有那往往说明不是页面真的没有错误而是监听器失效了比如某个第三方库把全局事件劫持走。这种“无事发生”的警报反而是最值得警惕的。写 rea 这件小事的收获远不止一个工具本身。它逼着我把“错误”当成系统的一等公民去设计从捕获、标准化、存储到分析的每一步都在加工数据而不是被动接收数据。如果你所在的团队也经常被线上问题折磨真的建议找一个周末照着最小闭环自己试一版。工具未必一定要多复杂能把“看不见的失败”变成“看得见的记录”就已经值回票价了。