☰
rea:轻量前端事件采集库,还原用户行为轨迹定位偶发Bug
2026/10/13 20:43:23 网站建设 项目流程

做前端时间久了,最怕的不是复杂业务,而是“复现不了”三个字。用户说某个页面有 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 的时候我犯过这个错——直接把class="btn-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 的主通道优先使用fetch+keepalive: 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/rea
    import { 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-track:

    app.directive('rea-track', { mounted(el, binding) { el.addEventListener('click', () => { track(binding.value || 'custom_event', binding.arg || {}); }); }, });

    在模板里使用时,直接写v-rea-track="'save_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 次。同时把失败的事件重新插入队列头部,而不是尾部,避免旧数据一直积压。

    如果断网时间较长,需要把数据临时存进 localStorage,rea 默认上限 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 强制 sendBeacon

    5.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

    另外注意,使用fetch+keepalive时,请求体的总大小不能超过 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 类名,如果事件记录里存了类名,下次版本升级后可能无法反查元素。我的建议是可以把>

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

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

立即咨询