做前端这些年,我一直有个执念:用户嘴里描述的 bug,和真实发生的 bug,往往不是同一个东西。一句“我那个页面就是突然白屏了”,背后可能是网络抖动、接口异常、用户某个骚操作、甚至是一段不被任何测试覆盖的交互路径。为了解决“无法复现”这个老大难问题,我尝试过各种录屏工具、埋点方案,直到在 GitHub 上翻到 rrweb(Record 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, }, });如果想更深度定制,就直接用 Replayer:
import { 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 不是一个“装上就能用”的库,而是一块需要融入现有可观测体系、业务特征和存储架构的拼图。它的学习曲线主要在原理理解与性能调优上——只有弄懂全量快照、增量补丁和事件流之间的关系,你才能在面对白屏、事件丢失、空间膨胀时搞清楚该调哪个参数。
最后分享一个实在的经验:新项目接入时先压到灰度流量,只做内部会话录制,把事件流格式、上报链路和回放体验调通,观察一周的数据量与成本,再逐步放开采样比例。别一上来就全量录制所有用户、所有页面,数据量不是省出来的,是选出来的。