1. rrweb 是什么:把网页"录"下来
接触前端久了,你会遇到一个特别尴尬的反馈:"我刚点了那个按钮,页面就是不动",你自己打开页面怎么点都正常,最后只能靠客服截图或者远程桌面去排查。做产品分析的时候更头疼,想知道用户在第几屏产生了困惑、在哪个步骤反复犹豫,靠埋点只能看到点击坐标,完全还原不了当时页面长什么样。这就是 rrweb(Record and Replay Web)要解决的问题:它不是用摄像头录屏,而是通过浏览器 API 记录页面的状态变化,把一次真实访问"回放"成一段可交互、可检索、可定位的"网页录像"。我目前负责的 Web 端用户行为分析系统,底层就是基于 rrweb 实现的,这篇文章就把它的原理、接入方式、性能调优和踩坑经验完整讲一遍。
rrweb 适合三类人看:一类是做前端监控或用户行为分析的前端工程师,一类是需要用真实操作流程来复现 bug 的质量同学,还有一类是产品经理和数据分析师——哪怕你不写代码,了解一下它能做什么,也能知道怎么给研发提靠谱的"录屏需求"。
2. 录制原理拆解:快照与增量组成的"时间线"
2.1 序列化 DOM:把页面变成可传输的数据
很多人第一次用 rrweb 会误以为它在录视频,其实不是。视频走的是getDisplayMedia或者 WebRTC,采集的是屏幕像素,文件大、没法检索,也无法定位到具体 DOM 元素。rrweb 的思路完全相反:它把页面 DOM 转成 JSON 节点树,连样式、属性、文本节点、SVG、Canvas 状态都序列化下来,回放的时候再把这些数据重新挂回浏览器。
具体到实现,rrweb 内部有三个包:rrweb-snapshot负责生成与序列化快照,rrweb负责录制事件,rrweb-player负责回放界面。快照就是某一时刻页面的完整静态描述,相当于一张"Word 文档的纯文本备份",回放时按快照重建整个 DOM 树,后续通过增量事件去修改它。这样数据量远小于视频,一条用户访问记录往往几十 KB 就能搞定。
要注意序列化并不是简单outerHTML那样把 HTML 扔出来。rrweb 需要处理 CSSStyleSheet 跨域主文档的访问权限问题,样式规则写法不一样,还要对input的 value、canvas的像素做特殊处理。比如<input type="password">如果不做屏蔽,会被明文记录成事件,这就是隐私事故了。好在 rrweb 默认不记录密码值,我们后面会讲到配置项。
2.2 MutationObserver 与增量事件:只记录变化的"补丁"
页面是动态的,如果每隔几十毫秒拍一次全量快照,数据量会爆炸。rrweb 靠浏览器原生的MutationObserver来监听 DOM 变化,DOM 发生了增删改,就生成一个 JSON 格式的增量事件。同时,用户操作行为(鼠标移动、鼠标点击、触摸、滚动、键盘输入)通过事件监听器记录成另一类增量事件,最后所有事件都是带时间戳的,时间戳就是回放的"播放进度条"。
这里面有个很关键的补丁逻辑:快照初始化之后,页面某个class变了、文本变了、子节点删了,rrweb 会通过before和id来定位是哪个节点,再应用修改。它内部给节点维护了一套索引,触发 MutationObserver 的 record 会携带nextId等信息来保持顺序。因为 Node 的序列化 id 是全局唯一的,即使回放时页面状态和真实访问完全同步,也不会找错对象。
用户操作对应的事件种类很多:MouseInteraction里细分了 Click、DblClick、MouseUp、MouseDown、Hover 等;Input事件记录了输入值和是否合成键盘事件;TouchInteraction对应移动端的 touch 行为;Scroll事件记录的是滚动容器的 scrollTop 和 scrollLeft 变化值,而不是屏幕坐标。设计上尽量用语义信息代替像素坐标,这样换屏回放也不会错乱。
2.3 回放引擎与虚拟时间:让事件的"播放"可控
回放听起来简单:按时间戳一个个执行事件。但浏览器没有"暂停事件流"这么底层的 API,rrweb 在回放引擎中自己实现了虚拟计时器。它会把所有事件按照timestamp排序,然后通过requestAnimationFrame在主循环里判断当前应该执行到哪个事件。暂停、跳转、倍速播放本质上都是修改虚拟时间轴。
这里有一个容易踩的细节:真实用户访问可能在某一步停了十分钟才继续操作,如果回放时真按时间差去播,中间就会一直白屏。所以 rrweb 设了一个阈值,当事件间隔超过一定值(默认是 5 秒),回放时会跳过等待,直接播放下一个事件。如果你想按真实时间轴回放,可以配置useVirtualTime或回调自己控制,但在默认的 rrweb-player 里,长时间空白会被压缩掉,这也符合"想看用户行为,不想看发呆"的诉求。
3. 动手集成:从零开始接入 rrweb
3.1 安装 rrweb 与基础录制代码
先用 npm 安装核心库和播放器:
npm install rrweb rrweb-player引入样式与录制代码:
import * as rrweb from 'rrweb'; import 'rrweb-player/dist/rrweb-player.css'; let events = []; rrweb.record({ emit(event) { events.push(event); // 实际项目中会在这里按批上报,而不是一条条发 }, });这个emit就是每产生一个事件都要执行的回调。我在开发环境第一次跑通时,最大感受是它非常"不动声色"——页面该干嘛干嘛,录制的所有东西都被塞进events数组里。要停止录制就调用rrweb.stopRecord(),它会返回最后产生的 event 快照,确保结尾有完整快照。
不过千万别为了省事把数组一直存着不处理。我们线上实践下来,一次二十分钟的访问,如果不压缩不上报,events数组的体积可能到几十 MB。建议的方案是:
- 每 10 到 30 秒或者每 1000 个事件,就把这批次事件序列化、压缩后发到后端。
- 最后用户关闭页面或进入休眠时,调用
stopRecord()把剩余事件发完。
3.2 回放器的三种姿势
官方提供了rrweb-player,最省心:
import rrwebPlayer from 'rrweb-player'; new rrwebPlayer({ target: document.getElementById('player'), props: { events, width: 1024, height: 600, autoPlay: false, }, });回放器自带 UI,有播放/暂停、进度、倍速、实时模式切换,基本够用。如果想完全自定义界面,可以直接用rrweb底层提供的Replayer类,这个类不依赖 UI,你只给它一个挂载节点,它负责把事件应用到 DOM 上。我们内部做行为分析的看板就是基于Replayer自绘了一个极简回放器,方便在回放页面右侧同时展示错误堆栈、网络请求和日志。
第三种是服务端回放。因为事件流本身就是数据,你完全可以在 Node.js 里把events重新构建成 HTML 片段或截图,用于生成自动化报告。不过服务端没有完整浏览器 API,很多 Canvas、字体、动画的序列化逻辑跑不起来,实际价值有限。我建议除非你有特殊需求,否则还是用浏览器端回放。
3.3 数据存储与上报:别让录屏把服务器压垮
事件数据是 JSON,可以存在 MongoDB、PostgreSQL 的 JSON 字段,也可以直接存对象存储。关键是要做压缩,因为批量事件里存在大量重复的 node 数据和空格。我们用 gzip 压过之后,体积能降到原来的 20% 左右,效果非常明显。
为了减少网络开销,还可以通过 service worker 离线暂存,在用户网络空闲时批量上报。我在一个低流量项目里试过直接在emit里发sendBeacon,虽然上报不阻塞页面,但高频场景特别是鼠标移动事件非常多,每一条都发会导致浏览器卡顿和服务器压力飙升。后来改成"事件累积 + 定时器批量上报 +sendBeacon兜底"后,整个链路稳定多了。
事件数量本身可以通过采样配置降低。rrweb 提供了sampling参数:
rrweb.record({ emit(event) { ... }, sampling: { mousemove: 50, // 50ms 内最多记录一次鼠标移动 mouseInteraction: false, // 是否录制 mouse interaction scroll: 100, }, });这个参数的本质是按事件频次而不是按事件量做截流。mousemove: 50意思是 50ms 内最多采集一次,既保留轨迹大概轮廓,又不至于每个像素点都生成一个事件。实测在普通页面上,能把录制事件数量减少一半以上。
4. 进阶实践:让录屏真正好用起来
4.1 敏感信息脱敏:密码、手机号与数据合规
录制页面最忌讳的就是把用户输入的密码、身份证、银行卡号明文记下来。rrweb 在快照序列化和增量事件中提供了多个脱敏入口:
rrweb.record({ emit(event) { ... }, maskAllInputs: true, maskInputOptions: { password: true, email: true, }, maskText: true, });maskAllInputs会默认把所有 input 控件的值替换成*。如果你的页面里有非密码但含敏感数据的输入框,比如验证码、身份证输入框,可以通过maskInputOptions配合maskInputFn自定义屏蔽逻辑。需要注意的是,maskText会把所有文本都打码,这会破坏对用户行为的内容分析。我们最终采用的方式是:把 mask 范围限定在输入控件和带特定 class 的文本节点上,而不是全局 maskText。
还有一个必须处理的点:如果页面嵌入了第三方脚本,很多第三方脚本会产生动态 DOM,比如某个客服聊天浮窗每 5 秒刷新一次,如果不屏蔽,录制里会混入大量与用户操作无关的垃圾事件。rrweb 提供了blockClass:
blockClass: ['ads', 'chat-widget'],给不需要关注的元素加上rr-block类,或者把类名传给blockClass,连序列化和录制一起忽略。这个配置对降低事件噪音帮助特别大,尤其是页面里挂了一堆统计脚本和推荐位内容的场景。
4.2 错误堆栈与录屏联动:从"用户说卡"到"直接定位"
只有录屏还不够,一定要和前端错误监控打通。常规错误上报只能拿到堆栈和 UA,但页面当时长什么样没人知道。我们在接入 rrweb 后,设计了一套联动方案:
- 在
window.onerror和unhandledrejection里捕获异常。 - 错误发生时,从 rrweb 事件列表里取出当前录制时间戳
lastEvent.timestamp。 - 将错误 ID 与录屏 Session ID 绑定,上报到监控平台。
排查问题时,直接打开这条错误关联的回放链接,把进度拖到错误时间点,页面状态一目了然。有一次线上反馈"弹窗关闭后列表空白",正常复现不出来,结果回放里看到用户先快速滚动列表,再点击弹窗按钮,触发了某个列表插件的virtual-offset计算 bug。没有录屏,这类交互时序问题很难留意到。
时间点对齐还有一个细节:业务自己上报错误时,最好用Date.now()和服务端时钟对齐,不要依赖设备时区。回放时按事件的时间戳偏移量来定位,否则你跳到错误点会发现对不上。
4.3 性能优化与踩坑记录:Canvas、字体与内存
在 PC 端中低端机器上录制,最怕的是持续采样导致页面掉帧。这里记录几个我实测有效的优化手段:
- 关闭不需要的高耗录制能力:
recordCanvas: false,如果页面不是重图形场景,默认不要开 Canvas 录制。Canvas 录制会周期性地对 canvas 截图,非常费 CPU。 - 控制 CSS 动画录制:高频 transform 动画、滚动容器里的 position 变化,会增加 MutationObserver 回调触发次数。最好的办法是给无关紧要的动画区域加
rr-block类,让它完全不进录制。 - 使用
plugins注册自定义插件,按业务场景决定哪些节点需要特殊快照。
内存侧最典型的问题是"录久了越用越卡"。原因很简单:events数组一直持有所有事件引用,而且回放器的 Replayer 实例也会保留虚拟 DOM 状态。如果用户单次访问超过 1 小时,建议做成会话分段,比如按 10 分钟一段生成一条录屏记录,中间用stopRecord()截断,再重新record()。
字体收集collectFonts: true也会导致大量 CSSOM 操作,默认一定要关掉,除非你的页面设计强依赖特殊字体且需要回放时样式一致。我们开启过一次测试,在字体较多的页面上录制,首屏耗时直接翻倍。
5. 常见问题与排查技巧
5.1 录制成功但回放空白?先查这三个环节
回放空白是最常见的初级问题。我排查下来,90% 的原因出在初始快照和后续事件不是同一份数据:
- 录制之前 DOM 没有完全加载:在
DOMContentLoaded之前就调用record(),导致初始快照只录了半页。解决办法是确保在页面 ready 之后启动录制,或者在emit里拿到首个Meta事件之后再开始后续逻辑。 - 初始快照丢失:服务端存取时按 Batch 存,但回放时只加载了最后一个 Batch,中间断档。rrweb 回放依赖第一条 document 级别的 full snapshot 事件,建议在存储时单独维护 full snapshot 事件,回放时保证第一条完整快照完整存在。
- PNG 强缓存导致 Canvas 数据失效:开了 Canvas 录制后,部分浏览器会阻止 canvas 转 dataURL,回放时 Canvas 完全黑屏。遇到这种问题,优先关闭
recordCanvas,或者给相关 canvas 元素加rr-block。
5.2 性能占用过高?调整四个参数立竿见影
如果你的页面录制一开启就掉帧,按照下面顺序调整:
| 参数/场景 | 推荐设置 | 原因 |
|---|---|---|
sampling.mousemove | 100 以上 | 低频记录鼠标轨迹,减少高频事件 |
sampling.scroll | 50 以上 | 滚动事件非常高频,可以大幅截流 |
recordCanvas | false | Canvas 录制是最重的开销之一 |
collectFonts | false | 字体收集会遍历 FontFaceSet,耗时长 |
blockClass | 给广告位/图表加rr-block | 直接不进录制,彻底降低 CPU 开销 |
还有一个非常容易忽视的问题:不要在每次emit回调里做JSON.stringify(events)。每个事件都是一次完整的序列化,叠加大对象会产生 GC 压力。正确做法是先累积数组,定时批量序列化,或者直接用structuredClone转移数据。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 处理办法 |
|---|---|---|
| 回放中没有鼠标轨迹 | sampling.mousemove设置过大 | 改为 20-50ms |
| 密码框显示为明文 | 没有开启maskInputOptions.password | 开启 mask |
| iframe 内内容无法播放 | iframe 跨域/默认不录制 iframe | 同源 iframe 可使用recordCrossOriginIframes,跨域无法完全支持 |
| 事件时间轴严重跳变 | 用户长时间停留在页面,事件间隔超阈值 | 调整smoothPlay和虚拟时间配置 |
| 回放器和原页面样式不一致 | CSS 资源异步加载、字体未收集 | 开启collectFonts,或确保无关外部 CSS 可用 |
| 首屏录制导致页面卡顿 | 启动录制时同步序列化了大量 DOM | 延迟启动 +emit异步上报,让快照生成不吃主线程 |
| 录屏数据占用存储过大 | 没有压缩、没有采样 | gzip 压缩 + 开启sampling配置 |
符合真实场景的录屏数据,价值远高于视频截图。搭好基础链路之后,可以继续扩展的方向很多:比如用rrweb事件流做 AI 行为聚类,识别出"用户在填写订单页反复删除又重填"的高风险路径;也可以结合 Playwright 做端到端测试的录屏审计,所有失败用例自动附带回放链接。这套录制回放能力,本质上把"页面发生了什么"从一个只能肉眼观察的黑盒,变成了可分析、可检索、可复现的数据资产。我在实际项目里的体会是:接入 rrweb 本身不难,难的是为业务定制脱敏规则和降噪策略。先跑通最小链路,再把录制能力嵌入监控和客服工单系统,价值会比单纯录屏大得多。