REA 全称 Runtime Error Analysis,也就是运行时错误分析。它不是某个商业监控平台,也不是必须引入的重量级框架,而是一套可以落地到任何组件化前端项目里的错误采集、上报和分析方案。最初我是在维护一个长期迭代的业务后台时,被线上问题反复折腾之后才整理出这套东西的。它要解决的核心问题就一句话:当用户说“页面白屏了”“按钮点了没反应”的时候,你不再需要追着用户要控制台截图,自己也能大致还原出错误发生的现场。
这套方案适合谁?如果你手头有一个已经上线的前端项目,线上错误基本靠用户反馈,出了问题要远程指导用户开控制台,那 REA 这套思路正好能填补空白。它也适合对数据隐私比较敏感、不想把用户报错信息直接吐给第三方服务的团队。只要项目跑在浏览器里,有 JavaScript 运行时,REA 的采集和分析思路就能直接用。
下面我把这套方案从设计到实现,再到踩坑记录完整拆开来讲。
1. 为什么需要 REA:线上错误不等于“报错”
很多团队对错误监控的认知还停留在“用户说有问题,然后开发打开页面复现”。但线上环境的复杂性决定了,复现不出来的 bug 才是大多数。
1.1 传统错误排查的三大盲区
第一个盲区是用户环境差异。开发机上的浏览器版本、网络状况、屏幕尺寸、插件环境,和用户的环境几乎没有交集。尤其是那些只在特定机型、特定浏览器版本下触发的错误,靠本地复现效率极低。我曾经遇到一个点击按钮无反应的 case,查了三天,最后发现是某浏览器内核在低内存情况下主动丢弃了部分异步任务,跟业务代码完全无关。这种问题如果只看业务逻辑,永远找不到答案。
第二个盲区是错误信息本身被压缩。浏览器控制台里看到的报错,往往只有一行错误消息和一个堆栈。但用户实际操作的路径、当前页面的状态、请求参数、接口返回数据,这些才是判断问题根因的关键信息。单纯的“xxx is not defined”没有任何上下文,等于只给了一个结果,没给证据。
第三个盲区是堆栈和源码脱节。生产环境上线前一般会做代码压缩混淆,报错堆栈里的函数名和行号对应的是压缩后的文件,阅读价值极低。没有 source map 映射,开发只能根据报错位置反推出大概模块,碰上混淆严重的代码,基本靠猜。
1.2 为什么选择自建,而不是直接用第三方监控
我们不是没评估过第三方监控服务。坦白讲,市面上的服务在采集能力、告警通知、可视化面板上都很成熟,开箱即用。但最终选择自建 REA,主要是三个原因。
第一是数据隐私。业务后台里会有用户手机号、订单信息、内部权限标识,这些一旦被错误上报带出去,就存在数据外泄的风险。自建方案可以把所有信息留在自己的服务器和数据库里,访问控制自己说了算。
第二是定制空间。第三方平台通常只能按自己的数据模型存取信息,我们想额外记录“用户从哪个菜单进入页面”“当前表格的筛选条件是什么”这类业务上下文,就得依赖平台的自定义字段,限制很多。自建之后,想在错误上报里塞什么就塞什么,非常自由。
第三是成本。团队同时维护多个项目,按错误量计费的第三方服务,一旦某个项目出现异常抖动,当月费用就不可控。自建方案只需要一台轻量服务器和一张表,成本稳定且可预期。
当然,自建也要接受现实:数据看板、告警规则、权限管理这些基础设施都得自己造轮子。对于追求全面监控体系的大团队,直接上成熟方案更省事。但如果你和我一样,核心诉求只是“快速定位线上问题”,REA 的投入产出比会非常高。
2. REA 的整体设计:把错误变成可分析的“现场记录”
在设计 REA 之初,我给自己定了一个原则:不要做一个什么都往里面塞的大杂烩,而是把错误当作一起需要还原的“现场事件”来处理。
2.1 四层结构总览
整个 REA 大致可以拆成四个层面,分别负责不同阶段的工作。用表格看会比较直观:
| 层级 | 主要职责 | 核心产出 |
|---|---|---|
| 采集层 | 在运行时捕获未处理错误、Promise 异常、资源加载失败 | 原始错误对象、堆栈字符串 |
| 富化层 | 给错误补充环境、路由、操作路径、业务上下文 | 一份结构化的“事件描述” |
| 传输层 | 将事件批量上报到服务端,处理重试和去重 | 稳定可靠的数据写入 |
| 分析层 | 对堆栈做源码映射,按错误指纹聚类,还原排查路径 | 可直接定位问题的线索列表 |
四层之间通过一个统一的事件对象串起来。也就是说,在采集层拿到的原始错误,经过富化、传输、分析之后,最终会落成一条类似“用户在某个页面、通过某个操作、触发了一个发生在某个源码文件的某个函数里的错误”这样的完整记录。
2.2 关键设计决策
第一个决策是上报策略采用“批量 + 本地缓冲”。浏览器端的请求如果每捕获一个错误就立即发送一次,遇到短时间内大量报错的情况,会给服务端造成压力,也可能把浏览器网络线程打满。REA 的做法是把上报请求放到一个队列里,积累一定数量或者到达固定时间间隔后统一发送。同时在页面卸载前尝试把未发送的数据用同步请求补发一次,避免用户关闭页面的瞬间丢失最后一批错误。
第二个决策是保留原始堆栈的同时也生成一份“源码映射后堆栈”。源映射文件很可能会因为隐私或构建产物清理而丢失,所以 REA 在采集时会把压缩后的原始堆栈原样保存到服务端,然后由分析层异步处理映射。这样即使映射解析失败,原始堆栈信息也还在,不至于两头落空。
第三个决策是服务端分析尽量做成离线任务。分析层不参与实时写入链路,而是通过定时任务去拉取新产生的错误记录,解析映射、计算指纹、更新聚类状态。这样分析逻辑即使出 bug,也不会影响错误数据的正常采集和存储。
3. 核心实现:一步步搭出一个可用的 REA
讲完设计,接下来看看实际代码。我会按照采集到分析的主线,把核心模块逐一实现。
3.1 全局错误捕获
浏览器里能捕获运行时错误的地方主要是两个:window上的error事件和unhandledrejection事件。前者负责常规代码执行异常,后者负责未处理的 Promise 异常。
REA 的采集模块核心代码是这样的:
// rea-core.js (function () { function captureError(source, error, extra) { const event = { type: 'error', source: source, message: error && error.message ? error.message : String(error), stack: error && error.stack ? error.stack : '', time: Date.now(), page: location.href, extra: extra || {} }; // 交给队列处理,不直接上报 window.__REA_BUFFER__ && window.__REA_BUFFER__.push(event); } window.addEventListener('error', function (e) { // 区分资源加载错误和脚本执行错误 if (e.target && e.target !== window) { captureError('resource', new Error('Resource load failed'), { tagName: e.target.tagName, src: e.target.src || e.target.href }); } else { captureError('script', e.error || new Error(e.message), { line: e.lineno, col: e.colno, filename: e.filename }); } }, true); window.addEventListener('unhandledrejection', function (e) { const reason = e.reason; const error = reason instanceof Error ? reason : new Error(String(reason)); captureError('promise', error, { promise: e.promise ? 'exists' : 'none' }); }); // 给业务代码留一个手动上报入口 window.__REA_REPORT__ = function (error, extra) { captureError('manual', error, extra); }; })();这里有一个容易踩的坑:捕获error事件时,必须判断e.target是否等于window。资源加载失败(比如图片、脚本)也会触发这个事件,如果不加区分,会把一堆资源 404 当成脚本错误收集起来,污染后续的错误聚类。
另外,捕获逻辑里不应该继续抛出异常。REA 自身如果出错,最多静默失败,不能影响页面原本的逻辑。这一点在后面的模块里也要保持。
3.2 堆栈解析与源码映射
堆栈信息拿到手之后,需要把它转换成“人话”。这里的核心问题是:压缩后的文件里,报错位置通常长这样:
at t (bundle-abc123.js:1:3521)这个1:3521对应的是压缩文件里的第 1 行第 3521 列。要找到它对应的源码位置,靠的是构建工具生成的 source map 文件。
REA 在分析层获取原始源码位置的方式如下:
// rea-sourcemap.js async function resolvePosition(payload, sourceMapUrl) { const response = await fetch(sourceMapUrl); const mapData = await response.json(); // mapData.mappings 是按生成文件的行号分组的编码内容 const lines = mapData.mappings.split(';'); const segments = (lines[payload.line - 1] || '').split(',').filter(Boolean); // 这里做了大幅简化:真实场景应使用成熟的 source map 解析库 // 核心思路是把 VLQ 编码解码后,得到每个段的 [生成列, 源码索引, 源码行, 源码列] // 然后找到生成列 <= 目标列 的最后一个段 for (let i = segments.length - 1; i >= 0; i--) { const seg = decodeSegment(segments[i]); // 简化方法,略 if (seg.generatedColumn <= payload.column) { return { source: mapData.sources[seg.sourceIndex], line: seg.sourceLine + 1, column: seg.sourceColumn + 1, sourceContent: mapData.sourcesContent && mapData.sourcesContent[seg.sourceIndex] }; } } return null; }提醒一下,上面代码里的decodeSegment我做了省略,因为 VLQ 编码的完整解码过程相当枯燥,而且生产环境建议直接用成熟的 source map 解析方案,不需要自己造轮子。真正重要的设计是:REA 在采集时不需要提前下载 map 文件,分析层只在需要解析某条具体错误时才去加载对应的 map。这样构建工具生成的 map 文件可以不上传到浏览器端,避免源码泄露。
source map 文件本身可能会很大,几 MB 的 map 并不罕见。所以在分析层接入时需要做缓存,同一个文件的 map 只加载一次,避免每次排查错误都重新拉取。
3.3 上下文信息聚合
如果说堆栈是事故的“导火索”,那上下文信息就是事故现场的“全景照片”。REA 会为每条错误聚合三类信息。
第一类是环境信息,包括浏览器 userAgent、语言、屏幕分辨率、当前在线状态、网络类型。这些信息可以帮助判断问题是否只出现在特定环境。
第二类是页面状态,包括当前路由地址、页面标题、URL 携带的参数、当前时间。如果是单页应用,最好还能记录用户从哪个入口进入当前页面。
第三类是操作路径,也是我觉得价值最高的一项。REA 会在全局监听几个关键的用户操作事件,并把最近十次操作按时间顺序记录下来:
// rea-context.js const operationStack = []; const MAX_STACK_SIZE = 10; function recordOperation(op) { operationStack.push({ name: op.name, detail: op.detail, time: Date.now() }); if (operationStack.length > MAX_STACK_SIZE) { operationStack.shift(); } } document.addEventListener('click', function (e) { const el = e.target; recordOperation({ name: 'click', detail: el.tagName + (el.className ? '.' + el.className : '') }); }, true);把操作路径和错误堆栈放在一起看,很多问题立刻就有了解释。比如用户报“列表页打开就白屏”,错误堆栈指向某个数据格式化函数,而操作路径显示用户是先点了“筛选”按钮,再进入下一页。那你基本可以断定,问题发生在带筛选参数的数据解析逻辑里,而不是列表页的初始渲染。
3.4 上报与本地分析
采集到的错误先存在浏览器的内存队列里,REA 不会每条都立即上报。上报模块会按两个条件触发发送:队列长度超过 10,或者距离上次发送超过 15 秒。
// rea-transport.js const buffer = []; const MAX_BUFFER_SIZE = 10; const SEND_INTERVAL = 15000; let lastSendTime = 0; function push(event) { buffer.push(event); flushIfNeeded(); } async function flushIfNeeded() { const now = Date.now(); if (buffer.length >= MAX_BUFFER_SIZE || now - lastSendTime >= SEND_INTERVAL) { await send(buffer.splice(0, buffer.length)); lastSendTime = now; } } async function send(list) { try { const res = await fetch('/api/rea/collect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ events: list }) }); if (!res.ok) { // 简单重试一次,避免影响主流程 setTimeout(() => send(list), 5000); } } catch (e) { // 网络异常时静默丢弃,避免造成二次错误 } }这里注意,上报接口失败不能反复重试无上限。REA 只在发送失败后延迟重试一次,再失败就放弃。理由很简单:错误监控系统本身不应该成为新的错误来源。如果网络都断了,再执着地上报也没有意义。
分析层拿到服务端存储的错误记录后,会做两个核心动作:一是按错误消息和堆栈地址计算一个指纹,把相同问题的错误聚合到一组,方便统计影响用户数和发生率;二是做源码映射,给开发一个可读的源码定位地址。
指纹计算可以用简单的字符串哈希。REA 把错误类型、错误消息、首个堆栈帧的源码位置三项拼在一起,做一次哈希,作为该错误的唯一标识。这样同一个函数在不同时间重复报错,都能被聚到同一条记录下。
4. 在真实项目里接入 REA 的完整步骤
设计再好,落不了地就是空中楼阁。这里我把 REA 在一个真实业务项目里的接入过程完整梳理了一遍,按步骤来。
4.1 接入前需要准备什么
第一个准备是确定上报接口。REA 采集端只是把数据发到一个固定地址,服务端接口需要自己实现。最简单的做法是准备一个后端接口接收 JSON 数组,存到数据库里。字段可以很宽松,因为不同错误携带的上下文信息不一样,所以服务端表结构建议设计成“主表 + 扩展字段 JSON”的形式。
第二个准备是约定 source map 的存放路径。这一步最关键。构建工具在生成压缩文件时,可以同时生成对应的 map 文件,REA 分析层需要知道去哪下载。通常的做法是把 map 文件单独上传到一个内部静态文件服务,或者放在构建产物的固定目录下,但不部署到线上用户可访问的位置。分析层解析时,通过错误事件里的文件名匹配到对应的 map 地址。
第三个准备是确定要忽略的错误类型。不是所有错误都值得记录。社区里常见的“Script error.”就是典型的跨域脚本错误,浏览器出于安全策略不会暴露具体信息,捕获到也是个空壳,直接过滤掉。还有用户主动取消请求、广告插件注入导致的报错,这些噪音会在聚类时严重影响判断。
4.2 最小可运行示例
假设你只是想在现有项目里快速验证 REA 的效果,不需要完整服务端,可以先做本地版本。把前面采集模块和上下文模块合并成一个rea.js,然后在页面入口的位置引入:
<script src="/static/rea.js"></script>页面运行起来之后,故意在业务代码里抛一个错误:
window.__REA_REPORT__(new Error('模拟错误:订单数据格式异常'), { orderId: 'DEMO-001', userId: 'U-10086' });等队列到达发送条件,打开浏览器开发者工具的 Network 面板,能看到一个 POST 请求把错误数据发到了本地服务。请求体大致长这样:
{ "events": [ { "type": "manual", "message": "模拟错误:订单数据格式异常", "stack": "Error: ...\n at <anonymous>:1:17", "time": 1700000000000, "page": "http://localhost:8080/order/list", "extra": { "orderId": "DEMO-001", "userId": "U-10086" } } ] }到这里,一条最简的错误采集链路就算跑通了。后续再逐步接入页面路由变化监听、source map 解析、指纹聚合,就是充实细节的过程。
4.3 上线后的排查流程示例
REA 接入之后,我处理线上问题的流程从“打开 IDE 看代码”变成了“先看 REA 错误聚类,再定位问题”。
具体步骤是:收到用户反馈后,先在分析页面按时间段筛选错误聚类结果,看影响用户数和错误指纹。如果某个指纹的影响面很大,点进去看最近一条错误详情。详情里有完整操作路径,能在不打扰用户的情况下还原现场。接着看源码映射后的堆栈位置,直接打开对应源文件,重点看报错位置附近最近改动过的代码。
这个流程帮我解决过不少“偶现”问题。最典型的一次,用户反馈某个表单保存时偶发失败。本地复现完全正常,REA 的错误详情显示,用户先上传了一张超过 2MB 的头像,再点击保存,然后报错发生在格式化表单数据的函数里。顺着操作路径检查才发现,上传完成回调里修改了公共状态,但没有同步更新表单引用,导致保存时读到了空数据。这个问题的根因和触发条件,如果没有操作路径记录,几乎不可能在不打扰用户的情况下定位出来。
5. 踩坑记录与排查速查表
REA 本身是排查错误的工具,但它自己也会出错。我把它在开发和使用过程中遇到过的典型问题整理了出来。
5.1 我踩过的几个坑
第一个坑是 source map 映射文件跨域不可访问。分析层去加载 map 文件时,如果 map 文件放在别的域名下且没有配置跨域响应头,浏览器会拦截加载。这个问题让 REA 在很长一段时间里只能展示压缩后的堆栈,解析功能形同虚设。解决办法是在内部静态文件服务里为.map文件加上允许跨域的响应头,并且确认 map 文件不被浏览器缓存策略干扰。
第二个坑是循环上报。REA 捕获到一个错误,在尝试上报时如果上报接口本身报错,会把错误也捕获进采集队列,形成“错误->上报->报错->捕获->再上报”的死循环。这个问题在开发阶段就遇到过,当时监控服务端的错误日志被刷了几万条。我在上报模块里做了全局标记,一旦进入上报流程就暂时关闭全局捕获,避免 REA 自身的异常回流。
第三个坑是过早调用flush导致浏览器主线程卡顿。一开始我把队列长度阈值设置得很小,错误稍微多一点就立即发送,而且用的是同步 XHR。页面在高频操作时会出现明显卡顿。后来改成批量异步发送,并把序列化操作放到微任务里,问题才消失。
第四个坑是 Promise 错误丢失。早期版本只监听了unhandledrejection,没有考虑已经被 catch 但错误对象被吃掉的情况。很多团队在业务代码里习惯写catch (e) { // 忽略 },这种错误 REA 是永远捕不到的。严格来说这不算是 REA 的 bug,但它提醒我,错误采集只能覆盖“未被处理”的异常,业务侧的错误规范还是得靠代码评审和规范约束。
5.2 常见问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 只看到 Script error | 跨域脚本错误,浏览器屏蔽了详情 | 为静态资源添加跨域属性,或直接过滤该错误 |
| 上报数据中没有操作路径 | 上下文模块未在错误采集模块之前初始化 | 调整脚本加载顺序,先加载上下文模块 |
| source map 映射结果错误 | map 文件未与压缩文件同步更新 | 构建时强制生成同版本 map,并检查缓存 |
| 错误重复上报多次 | 上报失败后无限重试 | 限制重试次数,增加唯一请求 ID 去重 |
| 采集数据太多,存储成本高 | 忽略了大量无意义错误 | 增加忽略规则,按 URL 或错误类型过滤 |
| 用户设备上报时间戳不准 | 设备本地时间被修改 | 服务端统一记录接收时间,同时保留客户端时间 |
排查速查表的意义在于,新人接手 REA 之后不用重新踩我踩过的坑。团队内部有这份表,很多问题看一眼就知道怎么处理。
最后分享一点我自己的体会。REA 的核心不是那些技术细节,而是它改变了团队排查问题的视角。以前是“这个报错在哪个文件”,现在变成了“用户当时在做什么、页面处于什么状态、代码里哪里出了问题”。这套思路即便不用在我的实现上,换成任何监控平台也成立。如果你正准备给项目加错误监控,先别急着接 SDK,花一晚上把你想让错误数据长什么样想清楚,比选什么工具重要得多。