rea:轻量前端事件采集库,还原用户行为轨迹定位偶发Bug
发布时间:2026/10/13 20:43:26 锦皓数字建站

做前端时间久了最怕的不是复杂业务而是“复现不了”三个字。用户说某个页面有 bug你本地跑一万遍也正常监控平台一尘不染连 console.error 都没有。我去年在某项目里连续被这类问题折磨最后干脆写了个轻量采集库名字就叫 rea全称取的是 Reactive Event Analysis定位是实时事件分析与行为轨迹还原。它能自动捕获浏览器里几乎所有关键交互把事件时序、报错上下文、性能快照统一记下来按批次上报。之后碰到“偶发、难复现”的线上问题直接拉用户行为链路就能定位。如果你也在维护复杂后台页面、做前端基建或者经常被线上问题追着跑这篇文章里 rea 的设计思路、核心代码片段和踩坑记录应该能帮你少走不少弯路。内容不涉及具体业务平台纯讲个人实践可以直接参考这套思路去改自己的工具链。1. 为什么会有 rea一次事件驱动的排查复盘1.1 一次“无法复现”的线上事故当时我们维护的是一个带复杂配置项的页面用户可以在弹窗里编辑商品的销售参数。某天业务方反馈“保存按钮点了没反应有时候还会弹两次确认框。”开发组查了一周本地环境怎么点都正常测试环境也没复现。报错平台干干净净连一条接口异常都没有。后来是怎么找到原因的一位同事盯着 DevTools 手动点了十几遍终于在一次极快的双击操作里复现了用户在弹窗打开后一小段时间内连续点了两次“确定”第一次点击发出保存请求第二次点击触发了旧节点上的事件回调进而产生了竞态条件导致后续状态全部乱掉。问题的核心不是某个函数写错了而是用户行为轨迹完全不可见。我们缺的不是错误日志而是“操作上下文”——用户按了什么、什么时候按的、按之前页面处于什么状态。这些信息一旦缺失排查就变成了盲人摸象。1.2 定位不做一个庞杂的监控平台在动手写 rea 之前我明确划了几条边界避免做着做着又变成一个“又大又全但没人用”的平台只关注浏览器端的“事件链 现场快照”。告警、看板、数据分析这些不碰它们应该交给更专业的平台去承担。接入成本要低。单个 npm 包gzip 后控制在 10KB 以内初始化代码不超过 10 行普通业务页面 5 分钟就能接完。必须无侵入。不需要业务在每一个点击事件里写埋点代码而是基于事件委托自动捕获全局交互。可作为独立上报源也能作为其他链路的采集前置模块。我把这几条写在了 README 的第一屏每次有人往 rea 里加非核心需求就会先拿这几条去卡一下。一个工具最怕的不是功能少而是边界模糊、越来越重。1.3 三个容易被忽略的事件阶段浏览器事件流分成捕获阶段、目标阶段、冒泡阶段。绝大多数业务代码只关心冒泡阶段因为日常绑事件基本都在这个阶段。但如果你要做全局事件分析必须优先在捕获阶段监听。我举一个实际例子页面上有个弹窗内容区子组件在 click 回调里调了stopPropagation()阻止事件继续冒泡。如果 rea 只在 documen 的冒泡阶段监听这类事件就直接丢了。改成捕获阶段后事件会从 document 一路向下传播到目标节点中间即使有人停止冒泡捕获路径上的监听器也早已执行完。同时也要注意像 React 这类框架有合成事件机制框架内部会把事件委托到特定容器节点上但合成事件的根仍然来自原生 DOM 事件。rea 在 document 的捕获阶段先拿到事件本质上不会干扰框架的合成事件分发反而更可靠。2. 核心实现拆解浏览器端到底怎么“记录现场”2.1 事件采集委托监听与选择器路径rea 的事件采集采用全局委托。初始化后在document上以capture: true注册事件监听覆盖 click、dblclick、keydown、input、change、submit 等核心事件类型。高频事件如 mousemove、scroll默认不录或采用节流采样避免数据量和主线程开销都跑飞。每次事件到达时我关心的不是事件对象本身而是这几个字段事件类型与触发时间目标元素的稳定标识优先取>const events [click, dblclick, keydown, input, change, submit]; events.forEach((type) { document.addEventListener( type, (e) { // 只收用户真实触发过滤脚本 dispatchEvent 产生的假事件 if (!e.isTrusted) return; const payload { type, target: getSelectorPath(e.target), text: getShortText(e.target), url: location.href, x: e.clientX, y: e.clientY, time: Date.now(), ts: performance.now(), }; pushToQueue(payload); }, true ); });这里有个细节getSelectorPath里要尽量生成“可回溯”的选择器而不是随便取一个 class。刚接 rea 的时候我犯过这个错——直接把classbtn-123拿来当标识结果版本一更新样式类名变了历史事件全部对不上元素。后来改成优先匹配>function safeSerialize(obj, depth 0, seen new WeakMap()) { if (obj null || typeof obj ! object) { if (typeof obj bigint) return obj.toString() n; if (typeof obj function) return undefined; return obj; } if (seen.has(obj)) return [Circular]; if (depth 4) return [Deep]; seen.set(obj, true); const result Array.isArray(obj) ? [] : {}; Object.keys(obj).forEach((key) { const value obj[key]; if (typeof value function || value undefined) return; result[key] safeSerialize(value, depth 1, seen); }); seen.delete(obj); return result; }脱敏这件事我吃过教训。最初是在序列化完成后用正则直接匹配 JSON 字符串里的手机号、邮箱结果性能和准确性都很差。后来改成在序列化每一个字段时同步做脱敏遇到明显的电话号码、邮箱就替换成掩码。对于密码框、textarea这类涉及敏感输入的字段直接强制不记录 value任何配置都不能绕过。用户隐私这条线不要指望业务方自觉工具本身就得兜住。2.3 批量上报与空闲调度为什么不能一把梭很多人做埋点上报第一反应是用navigator.sendBeacon()页面关闭时它确实可靠但它有三个问题不适合带复杂请求头、payload 大小受限、在队列里优先级靠后部分情况下数据会排到最后甚至丢。rea 的主通道优先使用fetchkeepalive: true再降级到 sendBeacon最后兜底用new Image()。批量逻辑上做了一个队列两条触发条件队列里的条数达到maxQueueLength默认 30 条距离上次上报超过flushInterval默认 2000ms上报时机不要抢占业务主线程。rea 优先用requestIdleCallback在浏览器空闲窗口内执行 flush如果回调传入的timeRemaining()已经小于 10ms就说明当前主线程忙立即推迟到下一轮。function scheduleFlush() { if (flushing || queue.length 0) return; if (requestIdleCallback in window) { requestIdleCallback((deadline) { if (deadline.timeRemaining() 10 || queue.length MAX_BATCH) { flush(); } else { scheduleFlush(); } }); } else { setTimeout(flush, 2000); } }页面关闭时的数据需要用 pagehide 或 visibilitychange 兜底。rea 在 pagehide 里强制调用一次 sendBeacon因为此时 fetch keepalive 不一定来得及sendBeacon 是唯一能保证发出的方案。移动端切后台时 visibilitychange 也会触发同样做一次 flush。3. 密集实操把 rea 接进现有工程3.1 安装、初始化与最小可用配置rea 目前以 npm 包形式分发你可以在私有源里发布自己的构建版本也可以直接引用源码。安装命令和基础初始化如下npm install yourscope/reaimport { initRea } from yourscope/rea; initRea({ appId: admin-console, reportUrl: /api/rea/collect, sampleRate: 0.2, // 采样率0~1 maxQueueLength: 30, // 队列达到多少条立即上报 flushInterval: 2000, // 批量上报间隔 captureEvents: [click, keydown, input, submit], });配置项里最重要的几个字段我整理成了一张表配置项类型默认值说明appIdstring必填项目唯一标识reportUrlstring必填上报接口地址sampleRatenumber1事件采样率0 到 1 之间maxQueueLengthnumber30触顶立即上报的队列长度flushIntervalnumber2000最大上报间隔毫秒captureEventsArray内置列表需要监听的事件类型privacyObject默认开启脱敏开关及字段配置sampleRate的计算方式采用的是随机数判断每次会话开始前生成一个[0, 1)的随机数小于等于采样率则进入采样组否则整段会话不上报。这样比逐条事件轮询判断更省开销也能保证同一用户的会话数据不会被拆得七零八落。3.2 在 Vue 3 工程中集成Vue 项目接入点通常放在main.js。因为 rea 是全局事件委托不依赖 Vue 实例所以app.mount()之前还是之后调用都可以我习惯放在 mount 之前尽早开始采集首屏阶段的操作。import { createApp } from vue; import { initRea } from yourscope/rea; import App from ./App.vue; initRea({ appId: vue-admin, reportUrl: /api/rea/collect, }); createApp(App).mount(#app);如果需要在业务代码里做更精细的埋点可以注册一个自定义指令比如v-rea-trackapp.directive(rea-track, { mounted(el, binding) { el.addEventListener(click, () { track(binding.value || custom_event, binding.arg || {}); }); }, });在模板里使用时直接写v-rea-tracksave_btn_click即可。vue 的指令系统不会干扰 rea 的全局捕获两者是叠加关系而不是竞争关系。3.3 在 React 工程中集成React 项目接入时要特别注意 StrictMode。开发环境下组件会执行两次副作用如果不做幂等保护initRea会被重复调用导致监听器叠加、数据翻倍。rea 内部用模块级单例做了保护重复 initial 会直接返回已存在的实例但接入方最好还是把初始化放在入口文件最外层。// index.jsx import { initRea } from yourscope/rea; initRea({ appId: react-web, reportUrl: /rea, sampleRate: 0.1, });React 17 之后合成事件委托在 root 容器上基础事件仍然来自 document。rea 在 document 的捕获阶段先接管事件之后 React 合成事件再处理顺序上不会冲突也不会丢事件。3.4 手动埋点与性能指标接入全局事件自动捕获能解决大部分问题但某些关键业务链路还是需要主动标记。rea 暴露了一个track方法import { track } from yourscope/rea; function onCheckout() { track(checkout_clicked, { sku: sku_1234, price: 199, source: detail_page, }); }性能指标可以接入PerformanceObserver采集。如果你想统计 LCP 和最近非常受关注的 INP 交互延迟结构大概是const observer new PerformanceObserver((list) { const entries list.getEntries(); const last entries[entries.length - 1]; track(lcp, { value: last.startTime }); }); observer.observe({ type: largest-contentful-paint, buffered: true });注意 startTime 的单位是毫秒服务端存储时要统一单位避免指标混用。4. 调参与数据量估算别让埋点变成另一场事故4.1 采样率的数学题埋点工具接入后最直接的问题是数据量。假设你每天有 10 万独立访客人均触发 200 个事件单条事件 payload 平均 500B全量上报一天就是100000 * 200 20,000,000 条/天 20,000,000 * 500B ≈ 10GB/天这个量对后端存储和查询带宽都是压力。如果采样率调到 0.1一天数据降到 1GB大多数团队都能接受。但采样率不能盲目调低否则异常问题的现场很容易被丢掉。rea 做了一个动态采样策略平时按低采样率工作一旦监听到页面发生未捕获异常或资源加载错误立即把当前会话的采样率提升到 1并且补一条error_session标记。这样既能控制日常数据量又能在真正出问题时不丢失现场。这个策略强烈建议每个接入方都打开属于“花小钱办大事”的典型。4.2 队列与合并内存和请求数的平衡队列内存占用很低。按默认 30 条、每条 500B 计算队列峰值只有 15KB对现代浏览器可以忽略不计。真正需要注意的是请求数量。如果一天 2000 万条事件每批 30 条上报理论请求是 66 万个加上 2 秒间隔合并实际并发请求会大幅降低。把flushInterval从 2 秒调到 5 秒请求数能再降到原来的 40%但事件到达服务端的延迟也会增加 3 秒。实时性和成本需要根据业务场景取舍。我自己通常的区间是这样普通后台管理系统用 2 秒 30 条高并发 C 端页面用 5 秒 50 条如果后端清洗任务只能按分钟跑甚至可以放宽到 10 秒。4.3 弱网、断网与离线兜底上报接口总有失败的时候rea 重试策略采用指数退避第一次失败等 1 秒第二次 2 秒第三次 4 秒最多 5 次。同时把失败的事件重新插入队列头部而不是尾部避免旧数据一直积压。如果断网时间较长需要把数据临时存进 localStoragerea 默认上限 50 条或总大小 256KB超过后丢弃最老事件。这里不用 IndexedDB 是因为这个场景的数据量级很小localStorage 的同步 API 足够简单可靠也不引入额外的异步复杂度。5. 常见问题与排查技巧实录5.1 事件丢了一半被误伤的冒泡与 iframe场景一事件没报上来。最常见的原因是子组件点击后直接调用了e.stopPropagation()如果你是在冒泡阶段监听事件就没了。rea 统一在捕获阶段监听后这个坑基本被填平。场景二iframe 内事件完全收不到。这是浏览器安全模型决定的父页面无法直接捕获子 iframe 内的事件。唯一可行的方案是在 iframe 对应的嵌入页面里也接入一份精简版 rea数据各自上报由服务端通过 sessionId 关联。场景三初始化晚于用户首次点击。用户进入页面、rea 脚本还在加载时已经发生的点击肯定收不到。要缓解这个问题可以在初始化后回读performance.getEntriesByType(event)把最早的几次交互补录进去。我用一张表总结一下现象原因解决方案子组件点击丢失stopPropagation 阻断冒泡改捕获阶段监听iframe 点击丢失跨文档事件隔离iframe 内嵌入精简版首屏早期点击丢失脚本未加载完成初始化后回读 event timing页面关闭丢尾巴请求未发出pagehide 强制 sendBeacon5.2 序列化炸了循环引用与 BigInt接入 rea 后最常见的报错是Converting circular structure to JSON业务对象里有parent、ref这类指针直接套 JSON.stringify 就会炸。rea 内置的 safeSerialize 已经处理了循环引用但如果你把业务对象直接塞进track()方法一定要确认传进去的是可序列化数据或者先把 DOM 引用摘除。BigInt 是另一个坑。JSON 规范不支持 BigInt直接序列化会抛TypeError。rea 的处理是转成字符串加n后缀服务端解码时按字符串处理即可。还有一个隐藏问题WeakMap 在大量序列化场景下会保留键引用如果每次 serialize 都新建 WeakMap 而不清理长时间运行会带来内存增长。rea 内部会在一次完整序列化结束后主动清空标记避免长期驻留。5.3 跨域上报被拦截如果reportUrl配置的是不同域名的接口会触发跨域。后端需要返回 CORS 响应头Access-Control-Allow-Origin: https://your-domain.com Access-Control-Allow-Methods: POST Access-Control-Allow-Headers: Content-Type另外注意使用fetchkeepalive时请求体的总大小不能超过 64KB超过会直接失败。rea 的单批数据远小于这个阈值但如果业务在track()里塞了一个巨大的对象是需要自己拆分的。开发环境下推荐直接用 Vite 的 proxy 把/api转发到后端避免本地调试时被跨域问题干扰// vite.config.js export default { server: { proxy: { /api: http://localhost:8080, }, }, };5.4 性能开销实测不能比业务代码还重引入任何监控工具都会给页面带来额外开销关键是这个开销必须可控。我实测过 rea 在真实项目里的表现事件监听只有一个 document 级捕获监听器单次事件处理耗时约 0.2ms批量上报通过 requestIdleCallback 调度不在用户交互的关键路径上序列化在数据多时才执行平均耗时约 0.5ms。整体对 FPS 的影响接近于零。验证方式很简单在 DevTools Performance 面板里录制一段 10 秒的交互操作对比开启和关闭 rea 的 longtask 数量。longtask 是浏览器主线程被占用超过 50ms 的任务一个合格的埋点工具不应该触发额外的 longtask。6. 我踩过的一些坑和后面想做的方向6.1 容易忽略的细节先说几个我真实踩过、并且代码注释里特别标注过的点捕获阶段不要顺手调用preventDefault()或stopPropagation()。rea 只做记录不改写事件流程否则会引发业务逻辑连锁问题。脱敏要放在序列化字段级别不要等整个 JSON 串出来后再用正则匹配后者性能和准确率都很差。不要记录 textarea 和输入框的完整内容。我见过有同事在埋点里带上整段代码提交结果把密钥也送上去了这是很严重的安全事故。事件数据里一定要有“路由变化”上下文否则回放时不知道用户当时在哪一页排查效率至少减半。计算耗时用performance.now()别用Date.now()做差值后者受系统时钟调整影响数值不可靠。另一个容易被忽略的问题动态计算选择器路径时要避免过于脆弱的匹配方式。很多前端项目会频繁改 CSS 类名如果事件记录里存了类名下次版本升级后可能无法反查元素。我的建议是可以把>
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。