☰
手写前端轻量监控工具rea:用过程数据回放排查线上性能问题
2026/10/15 17:15:22 网站建设 项目流程

1. 为什么我会自己动手写 rea 这个工具

1.1 一次线上事故把我逼到了墙角

事情是这样的。半年前我做的一个前端项目在发版后的第三天,线上陆续出现首页白屏、按钮点了没反应、接口偶发超时报错的反馈。奇怪的是,我在本地开发环境里怎么折腾都复现不出来,切换网络节流、清缓存、换浏览器,一切正常。当时团队里又没有部署完善的可观测体系,我只能在线上环境打开浏览器控制台,手动刷新几十次去碰运气。

那个下午我刷了四十多遍页面,终于看到一条Uncaught TypeError: Cannot read properties of undefined的报错,但报错堆栈指向的是一个压缩混淆后的文件,几乎没法定位。更痛苦的是,这种偶发问题在用户那边出现过好几次,每次都是刷新之后又好了,用户根本不愿意配合慢慢排查。那一刻我非常清楚地意识到一件事:缺少一个能常驻在真实环境里、记录关键过程数据、事后可回放的轻量工具。大多数情况我们只关注"请求有没有失败",但真正有用的信息是"这个请求到底慢在哪一步""资源加载顺序有没有问题""JS 执行是不是阻塞了渲染"。

所以后面我专门花了两周多时间,自己写了一个内部代号叫rea的小工具。它的全称我也没纠结太多,项目里就写的是 Real Environment Assistant,天天看板子上都挂着这个代号。核心目标就三个:在真实环境下采集页面运行过程数据、把数据整理成看得懂的记录、在问题发生时能快速导出分析。这篇文章就把 rea 从设计到落地再到踩坑的完整过程整理出来,希望给那些同样没有重可观测平台、又经常被线上诡异问题折磨的小团队一点参考。

1.2 团队现有的工具覆盖不到的盲区

可能有人会说,这年头监控工具不是一抓一大把吗?市面上开源的、商业的,从 APM 到前端监控都有,为什么还要自己写一个。我承认这些方案成熟、功能全面,但在真实接入过程中,总会碰到几种尴尬情况。

第一种是结果指标多,过程数据少。多数监控平台能告诉你"某页面 JS 错误率 3.2%""接口成功率 97%",但当你想知道"某个接口在弱网状态下 DNS 查询花了多少毫秒""首屏图片加载是不是比脚本优先""用户操作后主线程阻塞了多久",这些细节拿不到。第二种是接入成本高,要装 SDK、配 token、建账号、设告警规则,对于只有两三个前端、后端可能还是兼职的小团队来说,运营成本明显偏高。第三种更关键,很多监控工具本身也会影响页面性能,包体积大、上报频繁,反而给生产环境又添了一层压力。 所以不同工具有不同的视野盲区。我之前用过一套按"错误率、崩溃率、耗时中位数"来告警的方案,事情发生之后确实能收到通知,但对于定位问题帮助有限。而 rea 的思路是反过来的:不追求大而全,只做过程记录的数字化捕捉器,把关键行为的时间戳、顺序、耗时、状态码、资源大小、堆栈片段都留下来,问题没发生的时候它安静蹲着,问题发生了之后资料都在。

1.3 rea 的定位与边界

在动手写之前,我先给自己定了几条规矩,避免做着做着变成一个四不像项目:

  • rea 不是一个全链路 APM,不做分布式追踪、不采集后端链路,只管浏览器端到接口返回这个区间。
  • rea不做自动告警,它更像一个行车记录仪,等你主动查看。
  • rea优先本地存储,数据默认存在浏览器里,需要的时候再一键导出,不强制上报到服务器。
  • rea必须保持轻量,整个压缩后体积不打算超过 15KB,依赖必须是零,用原生浏览器 API 完成所有采集。

把这些边界写清楚之后,开发方向就明朗了。rea 适合的场景是:中小团队的前端项目、没有完整监控体系、排查线上偶发问题时需要关键现场数据。它也可以作为现有监控体系的补充层,在出问题时能下钻到更细的过程级别。

2. rea 的设计骨架:三层管道加一个全局缓冲

2.1 采集层:能拿到的数据先拿到

rea 的第一层是采集层,任务很简单也很朴素:把浏览器运行时能暴露的数据尽量采集下来。我选用了几个原生 API 作为数据来源,这一层不需要任何库,性能也足够好。

对于资源加载,我用PerformanceObserver监听resource类型的条目,能拿到每个 JS、CSS、图片、字体、XHR/fetch 请求的名称、耗时、传输大小、协议版本、域名明细。它的优势在于能结合浏览器内部的性能时间线,比我们手动包一层计时更精准。

对于网络请求,我包装了window.fetch与XMLHttpRequest,在请求发起时记录时间戳、URL、请求方式,在响应返回时记录状态码、耗时、响应体大致大小。为什么不只靠 resource timing?因为它拿不到业务状态码,也无法把接口和业务参数对应起来,所以必须在上层再做一层包装。

对于页面交互体验,我用PerformanceObserver监听longtask,把超过 50ms 的长期任务记录下来,同时配合MutationObserver观察页面 DOM 变化造成的耗时。这一块主要是为了排查"点了没反应"这类交互卡顿问题。另外所有事件的监听都会带上一个会话 ID 和自增序列号,方便后续把不同来源的数据在时间轴上对齐。

2.2 加工层:把原始数据变成能看懂的记录

原始数据采集回来之后,绝大部分是零散的、包含冗余信息的。比如一条资源记录里可能会有十几个字段,但真正重要的是name、duration、decodedBodySize、initiatorType;一条 longtask 记录也需要把它的开始时间换算成相对页面加载完成的偏移量。

rea 的加工层做了几件事。第一是归一化,把所有时间值统一成毫秒,并把 URL 上的查询参数按规则裁剪,避免同一接口因为带了个随机时间戳参数而被当成两条记录。第二是关联,用自增序列号和会话 ID,把一次操作触发的事件串联起来。第三是优先级排序,把各类型记录按时间合并成一个统一的线性日志流,这样在看报告时能清晰知道"用户点了按钮 -> 50ms 后发起接口请求 -> 300ms 后接口返回 -> 400ms 后执行了一个 longtask"。

加工层的设计看起来简单,但非常关键。没有这一层,你拿到的就是一堆割裂的表格;有了这一层,才能还原出问题发生前后的完整场景。这也是 rea 与"直接打印 console 日志"最本质的区别。

2.3 缓冲层与输出层:环形队列与一键导出

rea 默认把数据存在一个全局缓冲队列里,挂在window.__REA_BUFFER__上,使用一个环形数组,默认容量 2000 条记录,超过之后自动丢弃最旧的数据。这样做的好处是内存占用是固定的,不会因为页面长时间运行而无限膨胀。

需要注意的是,我没有一开始就把数据写入localStorage,因为持久化的成本比较高,而且在真实环境里可能每秒产生几十条记录,连续写入会引发性能问题。rea 的策略是:内存缓冲为主,定期快照为辅。默认每 30 秒或者缓冲满时,会压缩一次数据并写入到localStorage的rea-storage键里,容量上限约 3MB,超出时优先淘汰旧数据。同时 rea 暴露一个上报回调接口,如果你在项目里配置了自定义上报,也可以把数据直接推送到自己的服务端。

在数据展示方面,rea 会在控制台输出一个格式化面板,通过一个特殊的前缀展示最近 N 条关键事件列表,同时在页面上提供一个悬浮入口,点开就是"导出报告"按钮,会把缓冲数据导出成 JSON 文件。这样在出问题时,直接把这个 JSON 发回给开发同学,用 rea 自带的解析器一打开就能还原整个现场。

// rea 控制台面板的简化调用示例 window.__REA__.exportReport().then((blob) => { // blob 就是完整的 JSON 报告文件 console.log("[rea] 报告已生成,可下载后交给开发人员分析"); });

3. 接入 rea 的实操过程与配置细节

3.1 部署方式:复制一个 JS 文件,还是走构建插件?

rea 在设计初期就决定不给使用者增加构建链复杂度。它提供了两种接入方式,你完全可以按项目情况选择。

第一种是脚本直插,直接把编译压缩好的rea.min.js复制到项目静态目录,然后在 HTML 的<head>里用<script src="/rea.min.js" async>引入。这种方式的好处是业务代码零侵入,不加任何打包配置,但缺点是不能自动使用业务项目的环境变量。

第二种是构建插件注入,我用一个简单的 Webpack/Vite 插件把 rea 的初始化代码注入到入口 chunk 里。这样可以在构建时读取环境变量来预设采样率、服务地址、白名单等配置。对于用 NPM 管理依赖的工程化项目,这种方式更干净一些。

我的建议是:本地开发和测试环境可以使用脚本直插做验证,正式项目建议走构建插件注入,因为它能保证你拿到的是与业务构建产物一致的配置。如果你觉得引入额外插件很重,也可以直接在项目入口的第一行调用初始化方法,只要保证它比任何业务逻辑都先执行即可。

3.2 配置文件字段与常见取值

rea 的初始化配置非常简单,下面是一个 Vite 环境下通过构建插件注入的完整示例:

{ "enabled": true, "sampleRate": 3, "ignorePaths": ["/api/health", "/api/heartbeat"], "ignoreDomains": ["hm.baidu.com", "cnzz.com"], "maxBufferSize": 2000, "storageKey": "rea-storage", "storageQuota": 3072000, "exposeGlobal": true, "onReport": null }

这里面几个字段值得说明一下。sampleRate是采样率,默认 3,表示一次会话最多记录 3MB 原始数据,不是按百分比采样。ignorePaths和ignoreDomains是一个黑名单,用于过滤掉健康检查、心跳检测和第三方统计脚本的请求,避免这些噪音数据把真正的问题淹没掉。maxBufferSize决定了内存里的记录条数,默认 2000 通常够用,如果你的页面在极短时间内产生大量事件,可以调大这参数,但要注意内存开销。

storageQuota是本地存储的上限,单位是字节。这块我建议控制在 2-3MB 左右,因为localStorage在多数浏览器下的总配额只有 5MB,你还要给业务数据留空间。onReport是一个回调函数,如果你想把数据上报到自己的服务器,可以在这里设置一个异步发送函数,rea 会定期以 chunk 的方式把压缩后的数据传给它。

3.3 从 rea 报表到行动:一次真实的瓶颈定位

接入 rea 之后不久,我就在模拟项目 X 上抓到过一次典型的性能问题。

用户的反馈是这个页面上传文件时接口偶尔会卡在"已暂停"状态,进度条长时间不动。我用 rea 导出的报告往回分析,发现时间轴上有两条记录非常可疑:

  • 记录编号 1172:POST /api/upload/chunk,status 200,耗时 2310ms,TCP 连接建立耗时 822ms
  • 记录编号 1175:resource 腾讯地图脚本,耗时 1180ms,加载期间有 300ms 的长任务阻塞脚本执行

这两条记录单独看都不算致命,但放在一起就解释了用户遇到的问题:文件上传是分片上传,每片之间要用 JS 处理数据并构建 FormData,恰好在一片中需要加载并执行地图相关的第三方脚本,这个脚本把 JS 主线程阻塞了 300ms,导致上传请求的发起步被延后,进度条自然就显示"卡住"。之后我的处理方式是给第三方地图脚本加了async加载属性,并把它移到上传组件初始化完成之后再加载,问题立刻缓解。

这个案例让我更确信一点:很多真实环境下的性能怪相,不是单个请求慢,而是多个事件撞在一起。没有一个按时间线排列的过程记录工具,这种问题很难复现,也更难验证修复效果。rea 的核心价值就是它能让你在事后用"回放"代替"复现"。

4. 一个月使用下来踩过的坑

4.1 懒加载图片引发的误报风暴

第一次把 rea 装上线,第二天我就发现报告里全都是某个轮播图接口的请求错误记录。正常来看应该是偶发的,结果数据里显示每分钟都有一堆失败请求。我一开始还以为是后端接口出了问题,后来仔细看报告才发现,这些请求的initiatorType全是img,而且请求的目标地址是/api/user/avatar?userId=...。

原因其实很简单:页面头像图片在用户滚动到可视区域之前,浏览器不会真正发起请求。我的采集逻辑监听的是资源加载事件,当滚动事件触发大量懒加载图片时,rea 把"图片尚未加载"误判成了"请求报错"。后来我在采集逻辑里增加了状态判断:只把那些真正开始加载且加载失败的资源记录为错误,而不是把"未加载资源"当作错误。这个问题的教训是:处理采集数据时,要区分"没有发生"和"失败",否则异常率会被虚假数据极度放大。调整之后,误报比例直接从 40% 降到了 0.5% 以下。

4.2 跨域资源 timing 数据缺失

resource timing 虽然强大,但它有一个硬限制:如果你的页面请求了跨域资源,且该资源没有返回Timing-Allow-Origin响应头,那么除了name、duration、transferSize这些基本信息之外,DNS、TCP、TLS、响应体大小等详细字段全部会被浏览器隐藏。这意味着你在分析第三方 CDN 脚本、字体文件、地图脚本时,看到的耗时数据基本是黑盒状态。

rea 之前完全依赖这些 timing 字段,导致跨域资源慢的时候根本不知道慢在哪一段。后面我在采集层加入了 fallback 机制:当某个字段缺失时,用performance.now()在 fetch/XHR 包装层做一个粗略计时,至少能看到"发起请求到拿到响应"的总耗时和响应状态。另外在报告里会给跨域资源打上一个"受限数据"的标签,提醒查看者谨慎解读。

4.3 多标签页共用本地存储导致的数据竞争

rea 用localStorage存储快照时,我踩了一个比较隐蔽的坑。用户在同一个站点下可以打开多个标签页,每个标签页都会运行 rea,也都尝试往同一个rea-storage键里写数据。结果就是后写的标签页会直接覆盖先写的标签页的数据,而每个标签页都认为自己保存的是完整记录,最终导出的报告会莫名缺少前半段内容。

我修复的办法是改用BroadcastChannel来协调多标签页:在同一时间只允许一个"主标签页"负责持久化,其他标签页把数据通过 channel 传给主标签页统一写入。同时存储结构从单键改成多个分片键,每个分片键带上标签页 ID 和写入时间戳,读取时合并所有分片。这个问题在单页应用不常见,但如果你的项目是那种"用户可以开多个标签页操作"的后台系统,一定要考虑多标签页的存储隔离问题,否则数据会互相覆盖。

4.4 长任务误判与异步时序错乱

longtask 的监听本身没问题,但一开始我把"长任务期间发生的所有事件"都归因于"某个用户操作"时,出现了不少假关联。原因在于 JS 的异步执行模型:一个 async 函数里的代码会被拆成多个微任务,中间穿插着宏任务。当一个长任务触发时,真正导致该任务堆积的代码可能在前几百毫秒已经执行完了,这时候采集到的事件会产生时序错乱。

rea 的关联机制一开始是"按时间就近匹配",后来我改成"按任务边界分组":用一个任务栈记录当前同步执行上下文,异步任务入队时先记录当时的栈信息,事件发生时就找最近的栈帧归属。这样虽然实现上多了几十行代码,但关联准确率显著提升,尤其在排查"点击按钮后 800ms 才发起请求"这类问题时,能清楚看到中间那 800ms 到底发生了什么,而不只是看到一个孤零零的延迟。

5. 关于 rea 的一些后续想法

5.1 从观测工具迈向体检工具

现阶段 rea 给我的感觉更像一个"事后回放机"。虽然它解决了我很大的问题,但回放终归是被动的。我接下来的计划是,在回放数据基础上增加"体检报告"能力:每次会话结束或者定期离线聚合时,自动根据数据生成一份类似体检单的概览,里面直接列出性能隐患项。

比如检测到某些资源持续超过 500ms 且还未设置cache-control,就标记为"缓存缺失";如果某个接口的耗时中位数是 800ms,但在慢速网络下跃升到 3000ms,就会标记为"弱网表现不佳";再比如长任务出现频率超过某个阈值,就标记为"主线程负载过高"。这实际上是在把开发者的排查经验变成规则集,让工具从"记录异常"进化到"提示隐患"。

目前实现这层需要在前端统计上积累一段时间的基线数据,才能定出相对合理的阈值,所以还在小范围试跑。

5.2 我建议的接入范围与实际体会

一直在团队内部做推广时,我比较推荐的接入场景有三类:一是外部依赖比较多的页面,比如嵌入了地图、客服、统计、广告这类三方脚本的站点,真实环境里的加载顺序问题非常常见;二是涉及文件上传、长列表渲染、WebSocket 通信等复杂交互的页面,过程长、状态多、问题难复现;三是上线窗口小、没有专职测试的小团队项目,rea 可以作为一种廉价的质量观测兜底。

不太推荐所有页面无脑接入。有些访问量巨大的落地页,资源和请求数量极其庞大,即使 rea 做了采样和容量控制,仍然会带来持续的开销。这种情况下更合理的做法是只在灰度环境或者调试参数触发时启用,而不是常驻所有用户。

5.3 一些个人经验整理

最后分享几个我在实际使用中沉淀下来的经验,不一定都写在代码里,但每个都是真金白银换来的。

第一,如果要做成本地数据方案,一定要设置总配额并写好淘汰策略,不然用户浏览器存储被你的日志塞满,早晚会被投诉。第二,在数据导出格式上尽早统一 JSON Schema,因为 rea 的分析器和生成器需要对齐字段,改版本号很容易,但历史报告的兼容性必须提前考虑。第三,别把 rea 做成一个人的工具,我有几次排查问题后,是让后端同事也打开导出 JSON 看的,发现对方也能快速定位一些接口侧的问题。把数据格式设计得通用、清晰,工具的受众就会超过前端团队本身。

接下来我会继续把 rea 的离线聚合和规则判断补完,如果你也在做类似的东西,或者在排查线上问题时有什么独门思路,欢迎看完文章后自己去折腾一个适合自己的版本。排查问题的工具不在多,关键是在出事那一刻,你手里有足够的过程数据可供回放。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询