ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

rrweb 实战:用 DOM 快照与事件流还原网页操作全过程

rrweb 实战:用 DOM 快照与事件流还原网页操作全过程 做前端这些年我一直有个执念用户嘴里描述的 bug和真实发生的 bug往往不是同一个东西。一句“我那个页面就是突然白屏了”背后可能是网络抖动、接口异常、用户某个骚操作、甚至是一段不被任何测试覆盖的交互路径。为了解决“无法复现”这个老大难问题我尝试过各种录屏工具、埋点方案直到在 GitHub 上翻到 rrwebRecord and Replay Web这个项目才感觉“网页操作也能像看回放一样查案”这件事终于有了一个正经答案。rrweb 本质上是一套 Web 会话录制与重放工具它不是浏览器自带的录屏 API而是通过“DOM 快照 增量更新”的方式把页面运行过程记录成结构化事件流再用自己的回放引擎把整段过程还原出来。它解决的痛点是没有任何测试能覆盖真实用户的所有操作但你可以在用户报障后直接把事故发生前几分钟的页面状态完整调出来逐帧观察。它适合三类人被线上 bug 折腾到秃头的前端工程师、想要量化用户行为的体验设计师与产品经理以及希望提高复现效率的 QA 团队。这篇文章我会把接入、原理、性能坑和项目实践全部讲透不是官方文档复述而是我在真实业务里跑通整个流程后的完整复盘。1. 为什么我不选录屏而是盯上 rrweb 这套方案1.1 录屏方案的三个硬伤大部分人听到“网页回放”第一反应是“直接录视频不就行了”。我也这么干过拿 canvas.captureStream() 加 MediaRecorder 录一段 WebM确实能实现“所见即所得”。但跑了一个月后我总结出三个绕不过去的硬伤第一是体积爆炸。录一段 10 分钟的操作视频画质稍微调高点就是几十 MB画质调低又看不清页面细节。视频是像素级数据别说存一个月一天的会话量就能把存储打穿。第二是隐私裸奔。视频里全是像素用户输入了什么账号密码、看到了什么敏感数据全都以“可见明文”的形式留在记录里做脱敏处理非常麻烦。你不能靠模糊滤镜一个个去遮因为敏感内容的位置不固定。第三是不可交互。录下来的视频是死数据只能播放没法跳转、没法搜素、没法定位到某一个具体 DOM 节点。分析人员想看“用户在第几秒点了哪个按钮”视频回放里只能靠肉眼去盯。这三个问题指向同一个结论业务的真实需求不是“录一段画面”而是“还原一次页面状态变化的过程”。录音频视频是给眼睛看的rrweb 是给逻辑看的后者才适合做数据分析与缺陷定位。1.2 rrweb 的核心思路把网页变成事件流rrweb 的思路完全跳出了“像素”框架。它的底层逻辑是页面就是一个不断变化的 DOM 树我只要把 DOM 树的变化记录下来回放时重建它就行。具体来说rrweb 会做三件事首次记录时生成一份“全量快照”Full Snapshot把当前页面的 HTML 结构、CSS 样式、输入状态全部序列化成 JSON。运行过程中通过 MutationObserver 监听 DOM 变化把每一次节点新增、删除、属性修改、文本变化记录成“增量快照”Incremental Snapshot。把用户交互事件鼠标移动、点击、滚动、输入也封装成带时间的结构化数据与 DOM 增量合并成一条事件流。回放器拿到这条事件流后先用全量快照重建初始页面再按时间轴依次应用增量快照和交互事件就可以完整还原整段操作。这种方案天然规避了录屏的三个问题纯 JSON 数据可以搞 gzip 压缩和采样体积小一个数量级记录的是结构化数据可以精准屏蔽指定类名、指定输入框的内容回放是动画式重建能跳转、能暂停、能定位到任意时间点。1.3 真实业务里的收益一个 bug 的复现周期从两周变成半天我自己的项目里接入 rrweb 之前最典型的一次事故是这样的用户反馈在某个任务列表页偶尔点“详情”会白屏但看日志、看接口全正常。研发组排查了两周加了很多日志都抓不到现场。最后抱着试试看的心态上线了 rrweb 录制三天后就抓到了复现记录——用户的操作路径里先在一个弹窗里改了筛选条件然后快速点开另一条数据此时组件内部有一个竞态条件把列表的 key 指向了未加载完的数据源。这个 bug 在正常测试流程里很难走到但回放里一目了然。从那以后rrweb 就成了我这边前端监控体系里不可替代的一环。它不是替代错误监控而是补上了错误监控缺的那块“现场还原”。错误监控告诉你 line 15 报错了但用户为什么触发 line 15只有回放能回答。2. 核心原理拆解它怎么做到“记住每一次交互”2.1 全量快照把一棵 DOM 树变成可传输的 JSON要理解 rrweb 的记录原理得先看它如何处理“页面初始状态”。浏览器里的一切都源于 DOM 树但 DOM 树本身是活的伪元素、CSS 规则、iframe、影子 DOM 都可能藏在结构之外。rrweb 的全量快照做的不是简单地element.outerHTML一把梭而是深度遍历 DOM 树为目标节点生成一个稳定的 ID 映射并序列化以下信息节点类型、标签名、文本内容元素在父节点中的位置、节点的唯一 ID内联样式、class、id以及它能捕捉到的计算样式比如宽高、颜色、字体form 元素的当前值input、textarea、select 等输入的 type 等关键属性。这些信息会被组织成一颗虚拟节点树回放器拿到后可以直接用 document.createElement 等 API 在内存里重建出一模一样的页面。为什么不用 outerHTML因为 outerHTML 丢失了节点标识和样式附着的完整性后续增量更新无法精准定位“哪个节点变了”。rrweb 这种“带身份 ID 的树”是关键后续每次 MutationRecord 里的 target 节点都要靠 ID 找到老树上的位置再替换成新状态。2.2 增量快照MutationObserver 只记录“变化的那一瞬间”页面运行时DOM 会频繁变化如果每次都做全量快照事件流会变成天文数字。rrweb 的解决方案是依靠浏览器原生 API MutationObserver。MutationObserver 能监听三类变化childList子节点增删、attributes属性变化、characterData文本变化。rrweb 注册了一个观察者把每次回调里产生的 MutationRecord 批量打包对于新增节点递归生成它的完整快照相当于一个小型全量快照对于删除节点记录其在父节点中的位置和目标 id对于属性变化记录属性名、旧值、新值对于文本变化记录旧文本和新文本。关键点是“批量”和“去重”。浏览器在同一帧内可能触发多次 DOM 操作rrweb 会把它们合并到同一个增量快照里减少事件频率。以我自己的经验看如果一个页面频繁操作 DOM但每次操作集中在同一帧内产生的增量数据并不会线性膨胀靠的就是这个合并机制。与此同时rrweb 还监听了一组“自定义交互事件”鼠标移动、鼠标交互点击、双击、右键、滚轮、页面滚动、输入事件、媒体播放事件。这些事件不改变 DOM 快照的形态而是作为独立的时序事件记录。回放时鼠标光标会按记录的坐标运动输入框会按记录的内容逐字键入媒体会按官方时间轴板播放。这样就让回放过程看起来“像真的有人在操作”。2.3 虚拟时间轴回放器怎么把事件流还原成动态页面回放器拿到的是连续事件数组但每个事件都带有 timestamp。rrweb 构造了一个虚拟时间轴根据事件间的间隔推进回放进度。这里有个容易忽略的设计细节rrweb 没有用 sessionStorage 之类的“播放进度”来做同步而是把回放过程建模成“从第 N 个事件到第 M 个事件之间永远只处理这一批增量”。回放器内部有虚拟 DOM 节点树每一次前进就是把当前时刻的所有增量应用到这棵树上然后重绘到真实容器里。这个设计的优势是极强的可操作性和跳转能力。因为页面状态完全由事件流“推到”当前时间点所以回放器可以随时快进、慢放、跳转到任意事件索引——这和各种视频播放器是同一个交互模型。Replayer 配置里常见的 speed 参数就是通过缩短事件间隔来实现的如果事件间隔本身很大用户停顿了很久rrweb 还提供了“跳过空闲期”选项避免你看着一个静止页面干等。这套“虚拟 DOM 驱动时间轴”的架构也是 rrweb 做全量快照时强调节点 ID 的原因回放时的“diff 更新”不是重新渲染整棵真实 DOM而是在虚拟 DOM 树里定位节点、替换补丁真实 DOM 只做对应的小范围修改回放性能因此有保障。3. 从零接入录制、回放、存储的完整实操3.1 一段代码开启录制rrweb 的接入并不复杂核心 API 就是 record 函数。我在业务里的最初落地版本只有不到 20 行代码import { record } from rrweb; let events []; const stop record({ emit(event) { events.push(event); }, }); // 页面卸载前停止录制并上报 window.addEventListener(beforeunload, () { stop(); reportToServer(events); });这段代码跑了之后events 数组里就是一个接一个的快照事件。你可能想问record 里的 emit 为什么是回调形式而不是直接返回数组因为录制是一个持续性过程浏览器侧使用回调模式可以把事件实时传给任何消费方——你可以直接推给后端也可以存内存里做临时缓冲。这个设计很朴素却很实用。实际项目里我不会一次性把所有用户都录进来而是先在后端配置一个“灰度白名单”比如只录制登录用户、只录制 5% 的流量。控制录制比例比控制信息采集容易得多后续再逐步放开。3.2 回放器接入与常用参数回放部分有两种接入方式自己封装 Replayer或用官方现成的 rrweb-player UI 组件。如果只是内部看回放用 player 组件最省事import rrwebPlayer from rrweb-player; import rrweb-player/dist/style.css; new rrwebPlayer({ target: document.getElementById(player), props: { events, showControls: true, autoPlay: true, width: 800, height: 450, }, });如果想更深度定制就直接用 Replayerimport { Replayer } from rrweb; const replayer new Replayer(events, { root: document.getElementById(player), speed: 1, skipInactive: true, showWarning: true, mouseTail: true, }); replayer.play();几个参数我说下实际感受speed回放倍速我经常用 2 倍速看日常 bug用 4 倍速快速扫长会话skipInactive跳过用户空闲期这个强烈建议开启否则动不动就是几分钟静止画面mouseTail鼠标轨迹尾巴分析用户操作路径时非常好用showWarning会显示录制端的一些警告信息排查“为什么这个交互没录上”时很关键。3.3 事件流的传输与存储经验采集到事件流后最需要考虑的是传输和存储设计。rrweb 事件流本质上是 JSON 数组如果是高流量页面直接上报会产生很大的网络开销。我总结出的经验是三步第一步本地压缩缓冲。事件流数组不要在内存里无限增长我通常设置一个阈值比如 500 条或 1MB达到后就打包一次压缩数据上报而不是每条事件单独发一个请求。第二步用 gzip 压缩传输。JSON 文本的压缩率非常可观实测 5MB 未压缩会话数据gzip 之后能到 700KB 左右压缩率普遍在 75% 以上。如果你对体积特别敏感可以先跑一遍 rrweb 的 pack 逻辑再做双倍优化。第三步冷热分离存储。最近 7 天的回放数据放热存储用于快速检索更早的数据转存到对象存储或者归档并且按“是否有错误事件”建立索引。用户报障时优先查错误关联的会话切片这个检索方式效率最高。3.4 性能监控与采样策略rrweb 本身会带来运行时开销主要来自 MutationObserver 回调、全量快照深度遍历和自定义事件的频繁触发。官方在 record 配置里给了 sampling 参数用来控制部分事件的采集频率record({ emit(event) {}, sampling: { mousemove: true, mouseInteraction: true, scroll: true, media: true, input: last, }, });这里我特意说一下 input 参数。input 有两个可选题值last表示只在输入结束时记录最终值all表示记录每次按键变化。对绝大多数业务来说 last 就够了数据量和渲染压力都小很多。鼠标移动也是性能大敌默认开了的话事件量非常密集建议在非关键页面上直接把 mousemove 采样关掉或者用节流逻辑自己控制。性能监控方面具体指标有两条录制侧脚本执行时间占比以及单个会话事件流的大小。我给自己定的及格线是录制过程导致页面 FPS 下降不超过 3 帧正常 5 分钟会话的事件流压缩后不超过 500KB。超过这个规模的页面就该考虑按用户比例降流量。4. 实操中踩过的坑与排查清单4.1 回放白屏但录制没报错多半是快照不完整接入一个月后我遇到最诡异的问题是录制端一切正常事件流也有数据但回放器一片空白。排查后发现了原因我的页面里有大量通过 JS 异步渲染的内容录制启动得太晚导致首屏全量快照是在某个异步节点还没插入 DOM 的时候生成的。到了回放阶段事件流头部没有相关节点信息后续增量更新找不到对应父节点整个树就挂了。解决办法很直接把 record 的启动时机挪到 SPA 路由初始化之前或者在 DOMContentLoaded 前完成录制器注册。如果页面结构实在复杂还有一个兜底策略——在关键路由切换后强制做一次全量快照这样即便早期快照丢失也能恢复一棵新树。从坑里的体会是全量快照是回放的地基地基缺了任何事情都可能发生。4.2 iframe、canvas、字体和图片的录制盲区rrweb 默认不录制 iframe 和 canvas 内部内容。这不是产品偷懒而是这两者本质上是独立的渲染上下文。跨域 iframe 的内容根本不归当前页面 DOM 管canvas 则直接操作像素缓冲区不走 DOM 路径。解决方案分两个方向。iframe 场景如果 iframe 是同一个域可以通过配置 recordCrossOriginIframes 和跨域属性来录制但要注意跨域权限与 CSP 限制canvas 场景官方提供了 recordCanvas 选项内部会定期抓取画布内容生成图片快照代价是事件流体积上升——如果 canvas 上有游戏或高频动画最好只在低流量场景下用。我自己的经验是先确认业务里有没有这两类元素如果有在方案设计阶段就要提前评估数据膨胀程度。字体和图片也有类似问题。回放时如果目标页面上的字体文件或图片资源没有缓存外观会和录制时不一致严重时版面布局乱掉。处理方式要么把关键静态资源做预加载要么在回放容器里注入一段基础样式表保证基础字体与颜色一致。4.3 隐私脱敏要早做别等事件流跑到服务器之后再后悔录制用户会话会天然采集到大量敏感数据比如账号、手机号、搜索关键词甚至聊天内容。rrweb 提供了几个屏蔽入口我用起来最顺的配置是下面这套组合record({ emit(event) {}, blockClass: rr-block, ignoreClass: rr-ignore, maskAllInputs: true, maskInputOptions: { password: true, email: true, tel: true, }, maskTextClass: rr-mask-text, });对需要整体隐藏的模块加rr-block类对不需要录制的区域加rr-ignore类对输入框统一打 mask 标记回放时这些内容会变成占位符或星号。这块我最想提醒的是脱敏一定要在“录制侧”完成而不是打算在后端清洗。事件流已经是结构化快照后端清洗的工程量远高于前端配置而且后端永远不知道哪些是用户敏感数据、哪些是正常业务字段。另外如果一个页面加载前就有敏感数据渲染比如邮箱地址已经写在 HTML 里那 mask 选项帮不了你——这些内容会被全量快照直接抓走。这类元素必须提前打上rr-block类或者在后端渲染阶段就把内容隐藏。4.4 事件流太大先用“切段”代替“全程”很多团队接入 rrweb 的第一个问题就是用户可能连续操作一两个小时事件流随随便便几个 GB根本没法存。我的思路是不需要、也不应该录制用户的全程操作。大多数线上问题发生在一个活动时间窗口内与其把整段会话持久化不如把录制过程切段。切段逻辑有两种实现一是时间切段按设定的窗口长度比如 2 分钟或 5 分钟生成独立的事件流文件二是事件切段按事件数量或数据量切分。切段之后检索只需要定位到“错误发生前的那一段”成本大幅下降。同时我还会在关键操作比如点击结算、提交表单、切换路由处主动插入一个自定义标记事件回放索引时就能精准跳到标记点附近。还有个重要但容易忽略的操作回放时不需要保留所有事件。可以对仓储侧做一次二次过滤把不影响页面结构的 mouse 事件和低效用的 scroll 事件清掉保留 DOM 增删改和键交互会话体积可以再瘦一圈。5. 它不只是一个回放器进阶玩法与扩展场景5.1 与 Sentry 等错误监控系统做事件关联rrweb 最大的价值不在于“单独录像”而在于和其他可观测性数据形成闭环。我在项目里主要做了两处联动第一把 tokenId 关联到错误上报。发生异常时先通过 rrweb 拿到当前事件流长度把当前录制 token 传给前端监控 SDK错误堆栈上报带上 sessionId。后端拿到错误后就可以直接关联对应会话一键跳转回放。第二把回放作为告警触发的高阶信息。现有告警大多只给一个文本消息——“接口失败率超过 5%”配合 rrweb 后可以加一条“查看首个失败用户会话回放”的链接值班人员不用干瞪眼猜原因了。联动的前提是打点规范。要在业务代码里统一暴露获取当前录制上下文的方法保证后端异常信息与事件流索引是同一套 ID 体系否则两边数据各查各的这件事就白做了。5.2 用户行为分析从“看日志”升级成“看操作”回放数据天然适合二次加工。拿鼠标轨迹和点击事件来说我利用 rrweb 的事件流做了一张简易热力分析图按时间窗口聚合 click 事件的坐标叠加到页面截图上面就能直接看出用户高频点击区域和低效交互区。相比第三方埋点的采样率限制rrweb 的点击坐标是 100% 结构化数据处理起来灵活得多。输入事件经过脱敏后可以用于搜索词统计与表单弃置分析。用户在哪个输入框停留最久、在哪个字段前犹豫然后放弃这类信息对转化率优化极有价值。但要注意的是做行为分析必须遵守数据规范采集粒度按需最小化不能为了“分析方便”就放弃脱敏。5.3 往协议级录制演进从视觉回放到自动化测试rrweb 回放的是“视觉状态变化”但事件流本质上是一种协议只要你能记录 WebSocket、fetch 等交互动作的入参出参就能把回放升级成“接口级回放”。我在尝试的一个方向是把 rrweb 事件流与接口日志合并形成一条完整的时间线——用户操作、页面变化、网络请求、后端响应四个维度全部对齐。这个能力对未来做端到端回归测试特别有用。目前 Cypress、Playwright 都需要手写测试脚本但用户手动操作被 rrweb 记录后理论上可以转译成自动化测试脚本。虽然 rrweb 官方没有直接提供录完即跑的完整工具链但社区已经有一些实验性方案把事件流映射到 Playwright 的上层操作。这个方向在“用户踩到 bug 之后立刻生成可复现的回归用例”这个场景上非常诱人。我也在持续关注虚拟 DOM 节点在“高保真还原”上的边界。现在 rrweb 对常见业务系统支持得相当好但对 WebGL、重音频应用、大量 canvas 绘制的页面还很吃力。如果性能允许你是可以取巧地把 canvas 录制和视频录制混在一起用的但我的建议是先用 rrweb 抓核心 DOM 链路的操作再用其他兜底手段补足特殊渲染场景别指望一套工具通吃所有页面形态。前面这套完整方案跑下来之后我自己最大的体会是rrweb 不是一个“装上就能用”的库而是一块需要融入现有可观测体系、业务特征和存储架构的拼图。它的学习曲线主要在原理理解与性能调优上——只有弄懂全量快照、增量补丁和事件流之间的关系你才能在面对白屏、事件丢失、空间膨胀时搞清楚该调哪个参数。最后分享一个实在的经验新项目接入时先压到灰度流量只做内部会话录制把事件流格式、上报链路和回放体验调通观察一周的数据量与成本再逐步放开采样比例。别一上来就全量录制所有用户、所有页面数据量不是省出来的是选出来的。
返回列表