资讯动态

事件回放工具 rea 的设计与实战:轻量还原线上 Bug 操作路径

发布时间:2026/10/16 9:45:11 来源:尧图企业网站定制
先说个难堪的经历。上个月我被一个线上偶现问题折腾了两天用户每次进入某个页面、输入一段包含中文和特殊符号的关键词后页面就会白屏但本地怎么点都稳定复现不了。最初我以为是接口返回了脏数据后来才发现问题根本不在接口。这种场景做过前端的都懂靠用户描述去猜效率极低有时候用户自己都说不清楚点了什么。被折腾几回之后我顺手写了一个只做一件事的小工具 rea——Replay Events Automatically自动回放用户事件。它能把用户在一段时间内的点击、输入、滚动等操作按时间顺序记录下来生成一个紧凑的会话文件其他人只要把这个文件拖进同一个页面就能以原速或倍速看到当时的完整操作路径。这篇文章不打算写成工具文档而是想把我设计这个工具时反复权衡的核心细节、实际踩过的坑一次性讲清楚。做前端工程化、写 SDK或者正好在给团队搭监控体系的同学应该能直接用上。1. 为什么我会动手写一个叫“rea”的事件回放工具1.1 绝大多数线上 Bug 卡在“复现”这一步线上 Bug 和本地 Bug 最大的区别就是你看不到用户执行路径。日志只能记录到接口返回了什么、某个方法抛了什么错却不知道用户先是滚动到了哪里又点击了哪个按钮中间是不是有一次快速双击事件有没有被某个全局拦截器吃掉。给你一堆日志你只能猜。有段时间我为了复现一个偶发问题在页面上加了大量临时日志让用户把控制台内容发回来。用户当然配合了但复制过来的信息是碎片化的丢失了操作顺序而前端很多问题的触发条件恰恰就是顺序。比如一个表单校验组件先失焦再弹窗和先弹窗再失焦表现完全不一样。日志里根本看不出这个先后关系。我也考虑过直接录屏。录屏信息量确实是最大的像素级还原任何操作都有。但录屏文件很笨重一分钟的视频动辄几十 MB传到工作群再下载回来足够你喝一杯咖啡。而且视频没法被程序分析你想搜索“用户有没有点击过某个按钮”根本做不到只能人工一帧一帧去翻。这太原始了。所以我要的是一个介于两者之间的方案记录用户操作事件而不是记录画面。事件数据量小可以做结构化分析回放时又能按时间轴还原操作路径。这就是 rea 的出发点。1.2 rea 的定位轻量、临时、够用rea 不是那种重型的全链路监控 SDK它的定位非常明确在你需要复现一个具体问题时快速录一段操作轨迹然后原样放出来。你可以把它理解成页面上的“黑匣子”平时不工作需要的时候打开录制开关等出问题的操作再跑一遍关掉录制导出一份 JSON谁拿到这份 JSON 谁就能在同一个页面上把操作重演一遍。实际用下来我的一个典型工作流是这样的先在本地打开页面在控制台执行window.__rea.start()然后自己模拟可能出现问题的操作路径操作完执行window.__rea.stop()生成一个几十 KB 的会话文件接下来我把文件丢给同事同事把它拖动到页面上rea 会自动以两倍速回放操作。如果没有复现就在不同浏览器里再用同样的文件回放一次。整个过程不需要部署额外服务不需要上传任何用户数据就是复现问题时的一个轻量抓手。它适合谁适合每一个写过前端业务代码并且被“偶尔报错但复现不了”折磨过的人。测试人员也可以把它当作 bug 附件随提单一起提交开发者拿到手就能本地重现。产品经理还能用来做简单的用户路径分析不过这个我后面会讲到现阶段它最擅长的还是 Bug 复现。2. 整体设计录制与回放的关键思路2.1 为什么选择“事件流”而不是“视频录屏”很多人一听到“回放”第一反应就是录屏。但如果你认真比较过两者的成本就会理解事件回放的优势远大于录屏。对比维度视频录屏纯文本日志rea 事件回放信息密度像素级还原信息最全只看得到程序员预先埋的点覆盖主要交互事件关键场面可见数据体积一分钟几十 MB极小但丢失顺序一分钟操作通常在 100 KB 以内可检索性不可搜索可 grep可在会话文件里按事件类型/选择器搜索自动化分析需要视频理解和画面识别门槛高非常容易容易事件本身就是结构化数据接入成本需要浏览器插件或系统级录屏需要业务埋点维护成本高入门版几十行代码就能完成我用一个生活化的例子解释录屏像是给整条街道装监控画面逼真但你不能直接问监控“这辆车昨天有没有闯红灯”得靠人肉眼逐帧去看。事件回放则是让每个路口的路口机器记下“哪些车、什么时间、从哪个方向通过”数据很小但你可以用程序回答任何关于行驶路径的问题。对复现 Bug 来说我们真正需要的是“车的路径”而不是“天空的每一朵云”。2.2 事件模型要记录哪些信息才够重放做事件回放第一步是定义事件模型。我的第一个版本特别偷懒只记录type和target结果回放时发现点击事件的坐标偏了、输入框的值没还原、滚动位置对不上基本等于没用。你至少需要记录四类信息事件类型比如 click、dblclick、input、keydown、scroll、change、focus、blur。我建议用 PointerEvent 替换 mouse 系列统一处理鼠标、触控笔、手指触摸。事件目标要记录一个能唯一定位到 DOM 节点的选择器路径而不是直接存 DOM 引用。因为回放是一段新的会话原来的 DOM 节点早就不存在了。事件载荷比如 input 事件要记录输入后的值scroll 要记录 scrollTop 和 scrollLeftkeydown 要记录 keyCode。时间戳用于计算事件之间的间隔回放时才能还原真实的操作节奏。我最终采用的会话文件结构类似下面这样{ version: 1, url: /dashboard?tabreports, startedAt: 1710000000000, events: [ { type: click, target: div#app div.layout aside.card:nth-child(2) button.primary, x: 632, y: 148, ts: 0 }, { type: input, target: div.modal input[namekeyword], value: apple iphone价格, ts: 1200 } ] }看起来很简单但每一行背后都有坑。比如选择器路径直接用querySelector去定位很容易因页面结构调整而失效我用的是层级选择器从当前节点一路向上构建直到根节点这样即使页面新增了一部分内容只要目标节点的祖先链没变就能定位到。2.3 状态快照与时间轴如何配合只靠事件还不够。如果一个页面是异步渲染的用户点击了某个列表项回放时列表可能还没渲染出来事件目标根本不存在。针对这种情况我在 rea 里做了一个取舍不追求录制像素级 DOM 快照而是配合事件时间轴在关键节点做“轻量状态快照”。所谓状态快照我记录的是页面主容器的主要 DOM 结构简化描述而不是完整 HTML。每次在事件之间检查目标元素是否存在如果回放时找不到目标就暂停并提示用户当前页面结构和录制时不一致。很多前端错误其实是数据环境不一致导致的比如列表内容来自接口录制时能点第 3 项回放时接口返回了不同的数据第 3 项位置可能就变了。这类问题 rea 不强行处理我会建议回放时尽量用同一套 mock 数据或相同环境。时间轴方面我用 ts 字段记录事件距离录制起点的毫秒数。回放时不是简单匀速播放而是按照真实间隔原速推进这样能还原用户在某个输入框停顿思考的节奏。节奏对前端问题来说很重要因为若干框架的防抖和节流逻辑对操作时序敏感节奏变了问题就可能不出现了。3. 核心细节解析与实操要点3.1 事件采集端最容易出错的三件事第一件事监听事件必须在捕获阶段。这一点特别容易踩坑。如果你在冒泡阶段监听某些节点可能在事件触发后被框架移除等你拿到event.target时这个节点已经脱离文档甚至已经被销毁你就无法往上构建选择器路径了。捕获阶段的特点是事件从根部向下传播此时目标节点必然还在 DOM 树中可以安全地取到节点引用并生成路径。我在实现时统一用了document.addEventListener(type, handler, true)true就是开启捕获阶段。第二件事输入事件的值要滞后一拍。监听 input 事件时如果直接取event.target.value得到的往往是上一次的旧值因为 input 事件的默认行为还没完成。我一开始就栽在这个上面后来改成在事件记录时用target.value但因为事件在捕获阶段触发value 更新时机有点微妙稳妥做法是监听 input 事件后如果目标是输入类元素就读取一次 value同时通过requestAnimationFrame或setTimeout(0)再补读一次用后者的值。其实现在大多数浏览器在捕获阶段读 value 已经是更新后的值但为了兼容老内核我保留了双保险。第三件事滚动事件要做节流但不能丢失最终位置。滚动事件非常高频一秒可能触发几十次全部记录下来会把会话文件撑大好几倍。我的做法是每 100 毫秒最多记录一次滚动但每次记录时都取最新的 scrollTop/scrollLeft。这样既控制了事件量又能保证最终回放时停在正确位置。3.2 回放端的驱动方式与事件派发机制回放端本质上就是一个有时间轴的播放器。我用async/await做停顿每次根据事件时间戳计算下一步等待多少毫秒async function playback(events, speed 1) { let prevTs 0; for (const event of events) { const waitTime (event.ts - prevTs) / speed; if (waitTime 0) { await new Promise((resolve) setTimeout(resolve, waitTime)); } replayEvent(event); prevTs event.ts; } }其中replayEvent负责找到目标节点并派发一个对应的事件。派发事件要用dispatchEvent而且最好构造与原始类型一致的特定事件对象。比如原始类型是 click就派发MouseEvent或PointerEvent原始类型是 input就构造InputEvent。需要注意通过dispatchEvent派发的事件isTrusted属性是 false有些页面代码会判断isTrusted导致回放事件被当作无效操作忽略。这种情况我还没有找到完全绕过浏览器安全模型的方案通常的变通办法是在回放时给页面挂一个测试标记让业务侧在必要时放宽对 isTrusted 的限制。还有一点回放时如果目标节点找不到不应该直接抛错跳过而是先尝试几种备选策略根据事件坐标找到顶部元素、根据选择器去掉动态子级序号后重新匹配、如果还找不到就暂停并提示用户当前页面状态与录制时不一致。停顿而不是继续播放能避免后面所有事件因为目标错位而产生雪崩式误判。3.3 隐私与脱敏事件回放最容易翻车的软肋事件回放天然会采集用户在页面上的输入内容如果不做处理密码、身份证号、聊天消息就全被写进会话文件这是无论如何都不能接受的。rea 在设计时把脱敏当成一个独立模块而不是事后处理。默认规则包括所有input[typepassword]、input[typenumber]等敏感类型不记录 value只记录状态为***。对拥有>rea/ src/ types.ts # 事件、会话文件类型 selector.ts # 选择器生成与解析 record.ts # 录制器 playback.ts # 回放器 sanitize.ts # 脱敏规则 demo/ index.html app.ts # 一个测试用的 React 页面依赖方面我希望尽量轻所以主力只用原生浏览器 API。唯一可选依赖是状态管理库的中间件用于在录制时自动保存状态快照。如果你要接入 React可以用官方的useSyncExternalStore不需要额外依赖。整体控制在零依赖或一个轻依赖是它容易被团队接受的关键。4.2 录制器实现从事件监听开始先写出选择器工具函数。我这里用的是层级选择器暂时忽略 class 重复场景实际项目里要再增加更多定位策略。function getSelectorPath(target) { const path []; let node target; while (node node.nodeType Node.ELEMENT_NODE) { let part node.nodeName.toLowerCase(); if (node.id) { part #${node.id}; path.unshift(part); break; } if (node.classList node.classList.length) { part .${Array.from(node.classList).slice(0, 2).join(.)}; } let nth 1; let sibling node; while ((sibling sibling.previousElementSibling)) { nth; } part :nth-child(${nth}); path.unshift(part); node node.parentElement; } return path.join( ); }录制器的主体class ReaRecorder { constructor(options {}) { this.events []; this.running false; this.startedAt 0; this.sanitize options.sanitize || defaultSanitize; } start(url location.href) { this.events []; this.startedAt performance.now(); this.running true; document.addEventListener(pointerdown, this.onPointerDown, true); document.addEventListener(input, this.onInput, true); document.addEventListener(scroll, this.onScroll, true, { passive: true }); document.addEventListener(keydown, this.onKeyDown, true); return this; } onPointerDown (event) { if (!this.running) return; this.push({ type: event.type, target: getSelectorPath(event.target), x: event.clientX, y: event.clientY, }); }; onInput (event) { if (!this.running) return; const target event.target; if (!target.value) return; const value this.sanitize(target, target.value); this.push({ type: input, target: getSelectorPath(target), value, }); }; onScroll (event) { if (!this.running) return; const now performance.now(); if (now - this.lastScrollTs 100) { this.pendingScroll event.target; return; } this.flushScroll(event.target); }; push(event) { event.ts Math.round(performance.now() - this.startedAt); this.events.push(event); } stop() { if (!this.running) return null; this.running false; document.removeEventListener(pointerdown, this.onPointerDown, true); document.removeEventListener(input, this.onInput, true); document.removeEventListener(scroll, this.onScroll, true); document.removeEventListener(keydown, this.onKeyDown, true); return { version: 1, url: location.href, startedAt: Date.now(), events: this.events, }; } }这段代码很简单但已经具备一个可用录制器的大多数要素。注意stop()里返回的对象没有把 events 直接暴露出去是因为我后面在导出时还会做一次压缩和脱敏校验。4.3 回放器实现时间轴驱动与事件派发回放的最简实现用setTimeout就能做但为了支持暂停、倍速和进度跳转我建议用一个循环调度器class ReaPlayer { constructor(session, { speed 1, onPause null } {}) { this.session session; this.speed speed; this.index 0; this.paused false; } async play() { const events this.session.events; while (this.index events.length) { if (this.paused) { await new Promise((resolve) { this.resumeResolve resolve; }); continue; } const current events[this.index]; const prev events[this.index - 1] || { ts: 0 }; const delta Math.max(0, current.ts - prev.ts) / this.speed; if (delta 0) { await new Promise((resolve) setTimeout(resolve, delta)); } this.replayEvent(current); this.index; } } replayEvent(event) { const target document.querySelector(event.target); if (!target) { console.warn(rea: target not found, event.target); return; } if (event.type click || event.type dblclick) { target.dispatchEvent( new PointerEvent(event.type, { bubbles: true, clientX: event.x, clientY: event.y, pointerType: mouse, }) ); } else if (event.type input) { const setter Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, value )?.set; setter.call(target, event.value); target.dispatchEvent(new InputEvent(input, { bubbles: true })); } else if (event.type scroll) { target.scrollTop event.scrollTop; target.scrollLeft event.scrollLeft; } } }这里有个细节值得展开。回放 input 事件时直接设置target.value在很多框架里不生效因为 React 等库会维护一个“值追踪器”。我采用的方式是调用HTMLInputElement.prototype上的原生 value setter绕开框架对 property 的重写再派发 InputEvent。这是实测下来最稳的做法比“先用原生 setter 再模拟用户输入”那一套少了很多兼容分支。4.4 在 React 页面中接入的完整流程如果你只是临时用不用打包进项目也可以开发环境启动页面打开控制台。把 rea 的录制脚本通过script src注入页面。执行window.__rea new ReaRecorder()。在意的操作路径走一遍。执行window.__rea.stop()回放脚本会自动把 JSON 下载到本地。如果你要做成一个团队工具我更推荐用一个全局变量声明在入口文件里并配置NODE_ENV为 dev 时生效。接近零侵入。实际接入时我还会给录制器加一个快捷键开关默认是CtrlShiftR开始录制CtrlShiftE结束并导出。用快捷键的好处是当问题发生时你能第一时间启动录制而不用跳到控制台敲命令。这一个小差别在实际使用中很重要因为不少偶现 Bug 的窗口期只有几十秒。5. 常见问题与排查技巧实录5.1 高频问题速查表我把这段时间别人和自己在使用 rea 时踩过的典型问题整理成了一张速查表现象可能原因处理思路回放时点击无效dispatchEvent 的 isTrusted 为 false页面做了信任校验加上测试标记或在录制端记录事件坐标并在回放时直接触发全局监听器输入框回放后值没变化React 框架覆盖了 value 属性使用原生 value setter 触发 InputEvent目标节点一直找不到模拟接口数据与录制时不一致统一 mock 数据或在回放前抓取当前 DOM 的近似可匹配节点滚动位置不对滚动容器不是窗口或 document而是某个内部 div记录具体节点的选择器和滚动的 scrollTop/scrollLeftsession 文件太大鼠标移动或滚动事件记录太频繁对高频事件节流限制最大事件条数上限回放速度忽快忽慢事件之间间隔是真实用户体验不是匀速保留时间戳按原始间隔回放iframe 内容回放不了iframe 跨域无法访问内部 DOM同域 iframe 单独记录跨域 iframe 只记录外层容器操作遇到问题时我的排查顺序是先看会话 JSON 里有没有记录目标事件没有事件一定是事件监听没挂上有事件但回放不了优先怀疑目标选择器失效能回放但表现不同大概率是环境差异数据或时序不一致。5.2 一次基于 rea 的实际定位过程有一次我排查一个白屏问题用户反馈“从活动页跳到列表页再点某个按钮就白屏”。单看日志我只能看到点击按钮后组件抛了一个空指针错误但不知道为什么会出现空指针。我在本地开启了 rea 录制用同样路径操作一遍然后开启两倍速回放。回放过程中我注意到一个关键细节用户从活动页跳到列表页之后列表页的筛选栏会自动把“综合排序”变成“最新发布”这个变化是在 500 毫秒内由异步请求完成的。而 rea 记录显示用户点击按钮发生在异步请求完成之前此时列表还没有渲染出来按钮却被点击了于是处理器访问了一个尚未初始化的空引用。问题不在点击按钮本身而在异步时序。如果只看日志这个顺序关系几乎不可能发现。这就是事件回放和日志最本质的区别。日志是静态的、打点的、相互割裂的而 rea 还原的是动态的操作时间线很多 Bug 只有在时间线上才能真正暴露出来。6. rea 可以放在哪些工作流里以及几个扩展方向6.1 在 Bug 复现与回归流程里的使用方式rea 目前在团队里的典型用法是作为 Bug 单的“附件格式”。测试人员重现一个缺陷后导出 rea 会话文件随 Bug 单一起提交。开发拿到的不是一个含糊的“我点了按钮没反应”而是一份操作路径和节奏。这个变化带来的效率提升非常明显以前需要来回沟通三四轮才能定位的问题现在一轮就能开始排查。还有一种用法是做回归基线。每次发版前我会针对核心链路录制几段固定操作发版后在新环境里回放一遍。正常情况下回放可以通过“操作结束后的页面状态快照对比”来做断言rea 虽然没做成完整的 E2E 框架但它可以作为 E2E 用例录制的补充手段特别适合那些不适合用代码硬写的复杂交互路径。6.2 从回放走向行为分析的扩展思路事件回放的数据天然适合做行为序列分析。比如可以把所有用户的回放文件汇总聚合同一个页面里高频的点击路径、用户平均停留时间、操作卡点。因为这些数据是事件流不需要做视频识别的重活简单做一下事件序列挖掘就能得到很多有意思的结论。我可以简单说一个未来可扩展的方向把 rea 的会话文件当作行为数据训练一个轻量分类器判断用户是否在产品引导里迷路。这个方向目前还在探索阶段但它的基础就是这个简单的事把用户做了什么、在什么时候做完整记录下来。最后再分享一个小技巧。我在使用过程中发现回放时不要总是用一倍速遇到和异步请求相关的问题时把速度降到 0.75 倍更容易看出请求完成前后的事件顺序而遇到 UI 渲染类问题用 1.5 倍或 2 倍速反而抓问题更准因为慢速播放会产生大量视觉噪声。这个“倍速调参”的习惯是我用了很多次之后才总结出来的建议你下次复现问题时也试一下。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑