第一次看到 REA 这个名字,你可能会觉得是个生造的缩写。其实它来自我手头一个很具体的痛点:线上前端项目报错之后,后台看到的堆栈永远是一堆压缩混淆后的字符。某个周五晚上,用户反馈一个表单页面点保存没反应,群里发来的错误截图只有一行at t (bundle.min.xxxx.js:1:34567)。这种状态大概持续了小半年,直到我决定写个小工具,REA 就这么出来了。
REA 全称 Runtime Error Analyzer,作用就是把这类运行时错误从“只有压缩代码认识”洗成“人人都能看懂”的定位报告。它做三件事:错误堆栈的反混淆、基于 source map 的原始代码位置还原、同类错误的自动聚类。不管你是想接了私有化的错误监控后台,还是本地开发时手动分析一段日志,它都能接手。
这篇文章不聊大而全的可观测平台,只围绕 REA 这个工具,说说它怎么设计、怎么实现、踩了哪些坑。适合被线上堆栈折磨过的前端开发,也适合想自己搭一套错误分析管线的同学。
1. REA 想解决的三个问题与整体设计
1.1 线上堆栈的“黑话”到底有多难读
先来看一个真实到不能再真实的堆栈片段:
TypeError: Cannot read properties of undefined (reading 'map') at t (bundle.8f3k2.js:1:56789) at s (bundle.8f3k2.js:1:48981) at h (bundle.8f3k2.js:1:12035) at HTMLButtonElement.e (bundle.8f3k2.js:1:9033)这段信息在浏览器里确实能定位到是按钮点击触发的,但问题来了:t、s、h、e对应源码里的哪个函数?第 1 行 56789 列在源码里到底是哪一行哪一列?undefined又是从哪里传进来的?稍微复杂一点的项目,构建产物都是合并加压缩的,函数名直接变成单字母,所有代码挤在一行里。这时候除了把整个 bundle 格式化后逐个搜,没有任何高效办法。
REA 要处理的第一个痛点,就是这个“黑话翻译”问题。它把压缩后的行列号,结合构建时生成的 source map,还原成源码文件、原始函数名、原始行号列号,再把出错位置附近的源码片段截出来。于是上面那段堆栈就变成:
销售列表组件 / src/pages/sale/list.js:42:5 at init (src/pages/sale/list.js:42:5) at handleClick (src/pages/sale/list.js:88:19) at submitForm (src/pages/sale/form.js:210:6)一眼就能看出,问题出在列表页的init函数,第 42 行调了map,而那个变量是undefined。
1.2 产品定位:不是监控系统,是一条后处理管道
动工之前我认真地想过,REA 会不会做成了一个“四不像”。市面上的错误监控产品已经很多,为什么还要自己写?看了一圈之后发现,它们大多数重在这个链路:采集、上报、存储、告警、图表。但具体到一个报错“在哪个源文件、哪一帧业务代码、附近写了什么”,这些细节往往被压缩成一个列表,或者需要你点开好几个层级才能看到。
REA 的定位不是抢占这些平台的位置,而是做一条独立的后处理管道。它接收的输入是一段原始堆栈文本,加上对应的 source map,输出是一份结构化报告:
- 每一帧都还原成可读的源码位置
- 出错行附近有代码上下文
- 相同原因的错误被聚合成一种指纹
- 报告可以直接展示给排查人
架构上我刻意保持轻量。核心解析逻辑是一个独立的 JS 模块,核心部分不依赖任何第三方框架,source map 的解析和读取交给一个专注做映射的底层库,存储层则用一个普通的 JSON 文件就好,完全没有数据库和消息队列。这样无论嵌入现有监控后端,还是在本地命令行跑,都可以直接用,不需要为引入它而重构系统。
1.3 技术选型背后的取舍
选 JavaScript 而不是 Python 或 Go,原因很简单:REA 的天然运行环境在前端生态。它要解析的堆栈来自浏览器,source map 也是构建体系产出的,前后端同一种语言让接入成本最低。尤其当我想在错误捕获函数里直接调用它,或者在 Node 后端服务里作为中间件处理上报数据,一份代码两边跑,最省事。
source map 解析本身是个很成熟的问题,没必要自己重新发明轮子。我用了一个专注做 source map 映射的解析模块,它支持originalPositionFor这个核心方法:传进生成文件的行列号,返回原始文件的行列号和原始函数名。至于为什么不选完整监控产品,一是因为很多平台不允许从内部直接接入,二是自己写对数据有完全可控权,堆栈清洗规则、聚类算法、输出格式都可以按团队习惯调整。
2. 堆栈解析与源码还原的核心实现
2.1 一个标准堆栈帧里到底藏了哪些信息
浏览器抛出的错误堆栈,每个帧通常长这样:
at 函数名 (文件路径:行号:列号) at 文件路径:行号:列号 at 函数名 (地址)完整格式里有几个关键字段:
- 错误类型和消息,一般是第一行,比如
TypeError: Cannot read properties of undefined (reading 'map') - 函数名,压缩后会变成单字母,也可能没有,匿名函数就是空
- 文件地址,本地开发时是
http://localhost:8080/src/index.js这种东西,生产环境是 CDN 上的 bundle 地址 - 行号列号,这是还原源代码时最关键的坐标
还需要注意一种特例:某些浏览器对跨域脚本的堆栈只有Script error.,没有任何帧;某些框架会把顶层错误对象重新包装,原始 stack 被塞进detail或cause属性里。解析器不能只盯着stack字符串,还得做一层容错。
2.2 把堆栈切碎:清洗、拆帧、过滤
我的实现分三步走。先清洗整个堆栈文本,去掉空行和一些浏览器自带的信息。再按换行符拆成一行一帧,用一条正则把函数名、文件路径、行列号分别提取出来:
function parseStackFrames(rawStack) { const lines = rawStack.split('\n').map((s) => s.trim()).filter(Boolean); const frames = []; const frameRegex = /at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/; for (let i = 1; i < lines.length; i++) { const match = lines[i].match(frameRegex); if (!match) continue; frames.push({ functionName: match[1] || '<anonymous>', source: match[2], line: Number(match[3]), column: Number(match[4]), }); } return frames; }第三步是过滤。很多堆栈前几帧都在某个组件库或工具函数内部,对定位业务代码并没有帮助。我会把含有node_modules、runtime.js、bundle.js这类特征的帧自动降级,在最终报告里弱化展示,但不会删除,否则会丢失嵌套调用链。
2.3 用 source map 把压缩坐标还原成源码坐标
构建工具打包时,会在每个生成文件旁边生成一个.map文件,它记录的就是生成代码和目标源代码之间的坐标映射。REA 拿到原始堆栈里的生成文件路径后,找到对应的 source map,然后把行列号丢进去:
function resolvePosition(sourceMapConsumer, generatedLine, generatedColumn) { const original = sourceMapConsumer.originalPositionFor({ line: generatedLine, column: generatedColumn, }); return { source: original.source, line: original.line, column: original.column, name: original.name, }; }这里有个容易被忽略的点:source map 里的sources字段存放的是相对路径,比如webpack://my-project/src/pages/...,直接展示会带一长串协议前缀。我会在配置里维护一个sourceRoot,把webpack://my-project这层剥掉,只保留项目里能看到的路径,这样报告里的“源码位置”才真正可点击、可跳转。
构建工具还分 dev 模式和 production 模式。开发模式通常会生成带sourcesContent的 source map,可以直接拿到每行源码;生产模式为了减少体积,可能不内嵌源码内容,这时候 REA 就需要在后台额外接收一份源文件快照。
2.4 上下文代码怎么拼才对排查最有帮助
光有行列号,报告还是浮在空中。真正让人一眼看懂的,是出错位置附近的代码长什么样。我一般在报告里拼出这么一块:
src/pages/sale/list.js:42 37: const list = saleItemService.fetchList({ page }); 38: 39: const summary = { 40: total: orders.length, 41: firstItem: orders[0], > 42: names: orders.map((item) => item.name), 43: }; 44: 45: return summary;实现思路是用sourcesContent里的源码字符串,按换行切成数组,然后取出line - contextLines到line + contextLines这一段。默认前后 5 行,如果出错行靠近文件头或文件尾,要自动截断,不能越界。最后用>标记出错行,这样任何人拿到报告都能一眼看懂“这行代码在干什么”。
我在实际使用中还加了一个小改进:如果某帧解析出来的originalPosition指向sourcesContent不存在,就退回去展示压缩后的位置,并标记generated,避免出现一堆空落落的内容。
3. 错误指纹归一化与聚类排序
3.1 同一个 Bug,为什么监控里会被记成很多条
监控后台最让人头疼的不是堆栈读不懂,而是同一个底层原因的错误,因为错误信息里夹带了随机变量、时间戳或者内存地址,被拆成几千条不同记录。例子里一个接口返回数据类型不对,可能在几十个页面触发同样的错误,每条消息却长得不一样:
Cannot read properties of undefined (reading 'length') at n (bundle.js:1:123) Cannot read properties of undefined (reading 'length') at n (bundle.js:1:456) Cannot read properties of undefined (reading 'length') at n (bundle.js:1:789)如果只按 message 分组,这三条会被当作三个不同问题。但稍微看一下压缩前的位置就知道,它们很可能是同一个按钮点击函数,只是用户操作的时间点不同,导致内部变量序列化后略有差异。
3.2 归一化策略:把“同一个坑”聚成一类
REA 的指纹算法不依赖完整错误消息,而是取解析后的关键栈帧。我的规则是这样:
- 第一,只取业务代码的帧,过滤掉框架内部帧
- 第二,取从顶层算起的前三个有效帧
- 第三,每帧只保留“文件路径 + 原始函数名”,去掉行号和列号
- 第四,把这三帧拼接,再和错误类型拼接成一个字符串
- 第五,对这个字符串做一次简单 hash,作为指纹
举个例子,两个报错如果都指向src/pages/sale/list.js: init里的TypeError,即使它们的列号不同、消息里的变量名不同,也会被归成同一个指纹。行号为什么不保留?因为只要源码文件有微小改动,行号就会整体偏移,导致本来同一个问题被拆开;保留文件和方法名,是把粒度控制在“同一个函数里出的同一类错”,这个粒度对人工审视是最合适的。
当然这种策略有一个边界:如果同一个函数里确实存在两个完全不同的异常点,它会被粗暴合并。所以在报告里我会把“首次出现时间”和“最近出现时间”都带上,排查时可以结合时间再拆分。
3.3 排序权重:先处理最痛的错误
聚类之后该处理哪一条?不能简单地按数量排序。一个错误出现一万次,但只影响一个角落页面;另一个错误出现一千次,却阻塞了登录流程,后者的优先级显然更高。REA 的排序默认结合三个指标:
- 触发次数:反映影响频率
- 影响用户数或会话数:反映波及范围
- 最近出现时间:反映是不是一个还在活跃的错误
计算公式我调成了score = 0.4 * 触发次数 + 0.4 * 影响用户数 + 0.2 * 最近活跃归一化值。参数不是拍脑袋定的,早期我先试着只按触发次数排,结果一个测试环境的历史错误天天挂在最上面,真实用户遇到的反而藏在后面。加进“最近活跃”之后,排序立刻符合直觉了。
这套权重在工程上很简单,但它让报告更接近“当前最该看的错误”这个目标,而不是一个冷冰冰的统计列表。
4. 实操:从一段线上堆栈到分析报告
4.1 最快的接入方式:一个本地脚本
不接任何平台,REA 也可以当命令行脚本用。把线上拿到的原始堆栈复制到一个文本文件里,运行一个脚本,就能输出结构化报告。下面是一个最小可用的核心逻辑,我用它做过本地联调:
import fs from 'fs'; // REA 的核心解析函数 function analyze(rawStack, sourceMap) { const frames = parseStackFrames(rawStack); const resolvedFrames = frames.map((frame) => ({ ...frame, original: sourceMap ? resolvePosition(sourceMap, frame.line, frame.column) : null, })); const mainFrame = resolvedFrames.find((f) => f.original) || resolvedFrames[0]; const context = extractContext(sourceMap, mainFrame.original); return { fingerprint: buildFingerprint(mainFrame, rawStack), type: getErrorType(rawStack), message: getErrorMessage(rawStack), resolvedFrames, context, }; } const raw = fs.readFileSync('./stack.txt', 'utf8'); const sourceMap = loadSourceMap('./dist/bundle.js.map'); console.log(JSON.stringify(analyze(raw, sourceMap), null, 2));跑完结果是一份 JSON,里面resolvedFrames是每一帧的解析结果,context是源码片段。我是在早期还不想接后台时,用这个脚本处理了一周的错误日志,才确定 REA 确实能把定位时间降下来。
4.2 接入监控后台的部署配置
如果是接到团队内部已有的错误监控系统,需要做四件事。
第一,在构建流水线里把source map按版本号上传到后台。上传时一定记录当时的构建号,否则后面无法判断堆栈对应的版本。
第二,前端捕获异常的地方,把完整error.stack连同当前版本号上报。如果只是上报 error.message,REA 拿不到堆栈,就白干了。
第三,后台拿到上报数据后,先按版本号找到对应的 source map,再走一遍解析和聚类,把结果写到一个独立存储里。
第四,配置忽略规则。有些第三方脚本产生的堆栈并不需要关心,比如某个埋点 SDK 内部抛的错误,配置一个忽略列表,报告立刻就干净了。
我建议的最小配置项这样写:
| 配置项 | 默认值 | 作用 |
|---|---|---|
| maxStackFrames | 10 | 最多解析多少帧,避免超大堆栈拖慢性能 |
| contextLines | 5 | 出错行前后展示几行源码 |
| ignorePatterns | [] | 匹配到的帧不参与指纹聚类 |
| cacheSize | 200 | 保存多少个 source map 解析结果,避免重复解析 |
| fingerprintDepth | 3 | 指纹计算时取几层关键帧 |
这些参数全部可以在运行时调整,不需要改代码。
4.3 报告应该长什么样
一份理想的分析报告,我的目标只有三屏:第一屏是错误概览,包括指纹、类型、消息、影响用户数、触发次数;第二屏是解析后的关键堆栈,每一帧都可以点击到源码位置;第三屏是出错位置附近的代码片段。不再需要开发同学去后台一层层翻原始堆栈。
我在实际项目里给团队输出的报告示例:
指纹: 7f12ab3c 错误类型: TypeError 消息: Cannot read properties of undefined (reading 'map') 首次出现: 2025-01-12 14:03:22 最近出现: 2025-01-12 20:44:10 触发次数: 328 影响用户数: 41 关键堆栈: src/pages/sale/list.js:42:5 (init) src/pages/sale/list.js:88:19 (handleClick) src/pages/form.js:210:6 (submitForm) 源码上下文: > 42: names: orders.map((item) => item.name),团队拿到这份报告,排查入口从“面对压缩代码”变成了“面对源码位置”,后面的定位工作基本就是打开文件、看逻辑、修复,非常直接。
5. 实战中踩过的坑与排查技巧
5.1 source map 版本对不上,位置全乱
最典型的问题:线上已经发版,后台还留着旧版本的 source map,新堆栈解析出来的行列和源码完全对不上。我一度以为 REA 的解析算法写错了,后来发现是版本匹配的问题。解决方法是强制要求上报数据里带一个版本字段,REA 只拿同版本的 source map 解析;没有同版本 map 时,明确标记“该堆栈无法解析”,而不是给一个错误的原始位置。
另外一个细节:某些构建工具会生成带sourceRoot的 map,内部路径是/src/...,直接拼接会多出一层根目录。统一在配置里指定sourceRoot,让报告路径干净可跳转。
5.2 跨域脚本只有Script error.,堆栈完全拿不到
浏览器出于安全策略,跨域脚本抛出的错误,error.stack里经常只有Script error.,没有任何帧。我排查过很多次才发现,需要在引入第三方脚本时加上crossorigin属性,并且让服务器返回Access-Control-Allow-Origin响应头,浏览器才肯把完整堆栈暴露出来。
如果不是自己控制的第三方脚本,这个能力有限。倒不是 REA 的锅,是浏览器设计如此。遇到Script error.时,我会直接把这类事件过滤掉,不参与聚类,避免生成一堆毫无价值的“未知错误”。
5.3 解析性能开销比想象中大
source map 文件动辄好几兆,把整个 map 加载进内存再逐帧查询,单次是几毫秒到几十毫秒。如果上报量很大,每条错误都做一次完整解析,服务端压力不小。
我用的优化方案很朴素:给 source map 的解析结果加一层 LRU 缓存,指纹相同的错误直接复用缓存结果;只有新指纹才触发完整解析。另外只在有真实栈帧需要还原时才读取 source map,而不是对每条上报都做全量解析。实测下来,99% 的重复错误都不会再走完整链路,服务端吞吐立刻上去了。
5.4 几个容易被忽略的堆栈怪象
- 错误对象不一定是
Error实例。有的代码直接throw "something broke",此时error.stack是 undefined,REA 要兜底处理。 - 错误消息本身可能多行,比如请求返回的 HTML 错误页被塞进消息里。正则解析时,不能只按第一行取 message,要用堆栈第一帧的位置往回截。
- 某些框架的
componentDidCatch或者全局错误处理器,会把错误重新包装成新对象,原始堆栈藏到detail里。REA 需要递归往上找一层cause或detail。
6. 一点个人体会与后续还可以做的事
把 REA 真正跑起来之后,我有一个挺深的体会:错误分析工具的价值不在代码多复杂,而在能不能把“从报错到定位”的时间缩短。以前一个线上问题,从堆栈粘贴到群里,到开发拍板说“谁有空看下”,再到最后定位,常常是半小时起步。接上 REA 之后,报告里直接就有源码位置和上下文,很多问题五分钟内就能明确下一步动作。
再分享一个小技巧:如果 source map 很大,可以把它转成按行号做索引的中间格式,只保留行列映射关系,不要保存完整源码内容。这样文件体积能减少一大半,解析速度也会明显提升。REA 里我默认在后台缓存这种索引格式,而不是直接拿着原始 map 反复读。
后面我计划给它加上两个能力:一是接入服务端的错误日志目录,自动摘要常见异常;二是把聚类结果导出成可关联的链接,直接跳到代码仓库对应位置。目前这个版本做一个自包含的轻量工具已经够用,如果你也经常被压缩堆栈折磨,可以直接照这套思路试着写一个,成本不高,收益却很直接。