rrweb
背景
rrweb
是 record and replay the web
,是当下很流行的一个录制屏幕的开源库。与我们传统认知的录屏方式(如 WebRTC)不同的是,rrweb 录制的不是真正的视频流,而是一个记录页面 DOM 变化的 JSON 数组,因此不能录制整个显示器的屏幕,只能录制浏览器的一个页签(录屏)。
- rrweb事例展示
- 流程图
意义解决问题
-
用户分析(常规的指标数据,只能做到一个统计。如果能通过录屏,我们能完整分析某个客户的行为。)
-
重现bug(客户说有bug,但是复线不了,环境不一样,数据不一样。我们只能推断,但是有了录屏,我们就能很好的还原现场,知道本质操作)
-
代替视频录制 (录制体积更⼩、清晰度⽆损的产品演⽰。(纯粹是html,不用装插件))
基本使用
安装
npm install rrweb
录制
通过 rrweb.record
方法来录制页面,emit
回调可接收到录制的数据。
import rrweb from 'rrweb';
// 1.录制
let events = []; // 记录快照rrweb.record({emit(event) {// 将 event 存入 events 数组中events.push(event);},
});
回放
通过 rrweb.Replayer
可回放视频,需要传递录制好的数据。
// 2.回放
const replayer = new rrweb.Replayer(events);
replayer.play();
原理透析
基本概念
rrweb-snapshot 快照的生成
将页面中的dom转化为可序列化的数据结构并添加唯一标识
例如以下的 DOM 树:
<html><body><header></header></body>
</html>
会被序列化成类似这样的数据结构:
{"type": "Document","childNodes": [{"type": "Element","tagName": "html","attributes": {},"childNodes": [{"type": "Element","tagName": "head","attributes": {},"childNodes": [],"id": 3},{"type": "Element","tagName": "body","attributes": {},"childNodes": [{"type": "Text","textContent": "\n ","id": 5},{"type": "Element","tagName": "header","attributes": {},"childNodes": [{"type": "Text","textContent": "\n ","id": 7}],"id": 6}],"id": 4}],"id": 2}],"id": 1
}
这个序列化的结果中有两点需要注意:
- 我们遍历 DOM 树时是以 Node 为单位,因此除了场景的元素类型节点以为,还包括 Text Node、Comment Node 等所有 Node 的记录。
- 我们给每一个 Node 都添加了唯一标识 id,这是为之后的增量快照做准备。
在完成一次全量快照之后,我们就需要基于当前视图状态观察所有可能对视图造成改动的事件,在 rrweb 中我们已经观察了以下事件(将不断增加,增量序列化):
-
DOM 变动
- 节点创建、销毁
- 节点属性变化
- 文本变化
-
鼠标移动
-
鼠标交互
- mouse up、mouse down
- click、double click、context menu
- focus、blur
- touch start、touch move、touch end
-
页面或元素滚动
-
视窗大小改变
-
输入
类似git ,先提交一个版本,每次再追加追加。
记录的方法: MutationObserver
- MutationObserver 是一个用于监听 DOM 变化的 JavaScript 接口。触发方式为批量异步回调,一系列dom 变化之后,通过其回调函数开始接收通知。MutationObserver 可以监听节点的添加、移除、属性变化等操作。
序列化中的特殊处理(只是对dom变化做了记录)
之所以说我们的序列化方法是非标准的是因为我们还需要做以下几部分的处理:
去脚本化
。被录制页面中的所有 JavaScript 都不应该被执行,例如我们会在重建快照时将 script 标签改为 noscript 标签,此时 script 内部的内容就不再重要,录制时可以简单记录一个标记值而不需要将可能存在的大量脚本内容全部记录。记录没有反映在 HTML 中的视图状态
。例如<input > 输入后的值不会反映在其 HTML 中,而是通过 value 属性记录,我们在序列化时就需要读出该值并且以属性的形式回放成 。
相对路径转换为绝对路径
。回放时我们会将被录制的页面放置在一个<iframe>
中,此时的页面 URL为重放页面的地址,如果被录制页面中有一些相对路径就会产生错误,所以在录制时就要将相对路径进行转换,同样的 CSS 样式表中的相对路径也需要转换。尽量记录 CSS 样式表的内容
。如果被录制页面加载了一些同源的 样式表,我们则可以获取到解析好的 CSS rules,录制时将能获取到的样式都 inline 化,这样可以让一些内网环境(如 localhost)的录制也有比较好的效果。
序列化
任何语言,数据都是由数据结构来表示的,我们将数据结构转换成二进制,字符串的过程就是序列化
rebuild
- 将snapshot 记录的数据结构重建为对应的DOM
rrweb-player
为rrweb 提供的一套UI 控件,提供基于GUI的
录制原理
MutationObserver
播放阶段
在序列化设计中我们提到了“去脚本化”的处理,即在回放时我们不应该执行被录制页面中的 JavaScript,在重建快照的过程中我们将所有 script
标签改写为 noscript
标签解决了部分问题。但仍有一些脚本化的行为是不包含在 script
标签中的,例如 HTML 中的 inline script、表单提交等。
脚本化的行为多种多样,如果仅过滤已知场景难免有所疏漏,而一旦有脚本被执行就可能造成不可逆的非预期结果。因此我们通过 HTML 提供的 iframe 沙盒功能进行浏览器层面的限制(隔离环境,嵌入其他应用,兼容性考虑)。
rrweb 的播放器是在一个 iframe 上回放录屏的,为了阻断 iframe 上的用户交互需要做一些特殊处理,比如在 iframe 标签上设置 CSS 属性:
pointer-events: none;
为了去脚本化,将 <script> 标签替换为 <noscript> 标签,另外将 iframe 的 sandbox 属性设置为 “allow-same-origin”,可以防止任何脚本的执行。
- 高精度计时器
- 补全缺失节点
- 模拟hover
- 从任意时间开始播放
高精度计时器
之所以强调回放所⽤的计时器是⾼精度的,是因为原⽣的 setTimeout 并不能保证在设置的延迟时间之后准确执⾏,例如主线程阻塞时就会被推迟。
对于我们的回放功能⽽⾔,这种不确定的推迟是不可接受的,可能会导致各种怪异现象的发⽣,因此我们通过 requestAnimationFrame(根据设备的屏幕刷新率来调整动画的帧率)
来实现⼀个不断校准的定时器,确保绝⼤部分情况下操作的重放延迟不超过⼀帧。
同时⾃定义的计时器也是我们实现“快进”功能的基础。
补全缺失节点
在 rrweb
中,当进行页面重放过程中,如果发现了缺失的节点,它会通过补全缺失节点的方式来还原页面的完整状态。
页面重放的过程是通过按照操作的记录序列
逐步还原页面状态的。记录的操作序列包括了用户在页面上执行的各种操作,比如点击、输入等。这些操作会导致页面上的节点发生相应的变化,包括添加、删除、修改等。
然而,在记录操作序列的过程中,可能会发生节点变化的时机比较复杂的情况,比如动态插入节点、使用 Shadow DOM、异步加载等。这些情况会导致在记录过程中无法捕获到节点变化的信息,导致记录的操作序列中缺失了节点的变化信息。
为了解决这个问题,rrweb
会使用 MutationObserver
监听页面上节点的变化。当发现有节点被添加或删除时,rrweb
会将这些节点的信息记录下来,并结合之前记录的操作序列进行分析。通过分析节点的变化情况,以及操作序列中的操作类型和位置
,rrweb
可以推断出缺失的节点应该是什么,并进行补全。
模拟 Hover
从任意时间点开始播放
除了基础的回放功能之外,我们还希望 rrweb-player
这样的播放器可以提供和视频播放器类似的功能,如拖拽到进度条至任意时间点播放。
实际实现时我们通过给定的起始时间点将快照链分为两部分,分别是时间点之前和之后的部分。然后同步执行之前的快照链,再正常异步执行之后的快照链就可以做到从任意时间点开始播放的效果。
播放器的进度条是如何控制与每个增量快照发生的时间对应上呢?
比如在播放时用户点击进度条上的某一点,这一点距离初始时间点是 timeOffset 长度,点击的这个点可以叫做基线时间点 baselineTime,rrweb 会根据这个点将所有的事件分成两部分:前一部分是在基线时间点前已经发生的事件队列,后一部分是待回放的事件队列。把前一部分事件同步还原构建完成,作为后面队列的全量基准 DOM 树,再继续异步地按照正确的时间间隔构建后面的增量快照。
问题扩展
如何将rrweb 转化为视频
- rrvideo
puppeteer 在服务端运行无头浏览器,在无头浏览器中回放录制的数据,然后每秒截取一定数量的图片,最后通过 ffmpeg 合成视频。下面是大致的流程图