rrweb 底层设计简要总结

rrweb可以进行屏幕录制和回放,用它做一个用户自助反馈功能,可以大幅度降低 bug 反馈成本。 这里简要介绍它的核心内部设计,后续会研究它的源码以增加一些定制功能。

rrweb并不是真的在录制视频,而是将页面DOM序列化后记录下来,再利用反序列化还原成DOM来回放。

代码组成

主要分为 3 个部分(参照官方 README):

  • rrweb-snapshot: 包含 snapshot 和 rebuild 功能。snapshot 用于将 DOM 及其状态转化为可序列化的数据结构;rebuild 则是将 snapshot 记录的数据结构重建为对应的 DOM。
  • rrweb: 包含 record 和 replay 两个功能。record 用于记录 DOM 中的所有变更(mutation);replay 则是将记录的变更按照对应的时间一一重放。
  • rrweb-player:为 rrweb 提供一套 UI 控件,提供基于 GUI 的暂停、快进、拖拽至任意时间点播放等功能。

对于一个自助反馈功能来说,需要rrweb+rrweb-snapshot来生成一组序列化的snapshot,并发送给回放后台;后台回放页面拿到snapshot后,利用rrweb-snapshot还原为DOM,结合replay功能后就可以在rrweb-player中”播放”了。

内部设计

这个部分主要也是阅读了rrweb官网的 4 篇文章后,进行的一个简要总结,原始链接如下:

序列化

并不需要将每时每刻的 DOM 都全量序列化,这样一来数据量很大,另外会有很多重复数据。rrweb 会在初始时进行一次全量序列化,然后将各种页面活动比如按钮点击转变为增量序列化。

全量序列化

DOM树转为”虚拟 DOM“树形数据结构。例如:

1
2
3
4
5
<html>
<body>
<header></header>
</body>
</html>

会被序列化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
{
"type": "Document",
"childNodes": [
{
"type": "Element",
"tagName": "html",
"attributes": {},
"childNodes": [
{
"type": "Element",
"tagName": "head",
"attributes": {},
"childNodes": [],
"id": 3 // 唯一id
},
{
"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
}

基于这样的树形结构后,假如要记录某个按钮的点击,对于这个操作就可以序列化为类似如下的结构:

1
2
3
4
5
type clickSnapshot = {
source: 'MouseInteraction',
type: 'Click',
id: Number, // 按钮id
};

增量序列化

在完成一次全量快照之后,需要基于当前视图状态观察所有可能对视图造成改动的事件,目前在 rrweb 中已经观察了以下事件:

  • DOM 变动
    • 节点创建、销毁
    • 节点属性变化
    • 文本变化
  • 鼠标移动
  • 鼠标交互
    • mouse upmouse down
    • clickdouble clickcontext menu
    • focusblur
    • touch starttouch movetouch end
  • 页面或元素滚动
  • 视窗大小改变
  • input输入

rrweb基于MutationObserver来观察所有这些变更,MutationObserver的一个示范:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Select the node that will be observed for mutations
const targetNode = document.getElementById('some-id');

// Options for the observer (which mutations to observe)
const config = { attributes: true, childList: true, subtree: true };

// Callback function to execute when mutations are observed
const callback = function(mutationsList, observer) {
for (let mutation of mutationsList) {
if (mutation.type === 'childList') {
console.log('A child node has been added or removed.');
} else if (mutation.type === 'attributes') {
console.log('The ' + mutation.attributeName + ' attribute was modified.');
}
}
};

// Create an observer instance linked to the callback function
const observer = new MutationObserver(callback);

// Start observing the target node for configured mutations
observer.observe(targetNode, config);

// Later, you can stop observing
observer.disconnect();
新增节点

由于MutationObserver在触发时,callback收到的是一批操作记录,这个特性会影响rrweb的序列化过程,比如

1
2
3
body
n1
n2
  1. 创建节点 n1appendbody 中,再创建节点 n2appendn1
  2. 创建节点 n1n2,将 n2 appendn1 中,再将 n1 appendbody 中。

这两种操作的最终结果是一致的,不过在增量序列化时,前者会产生两条记录,后者只有一条记录,rrweb需要区分开来。最终采取的方案是:

在新增节点时,所有 mutation 记录都需要先收集,再新增节点去重并序列化之后再做处理。

节点属性变化

对于节点属性的变化,比如resize textarea时宽高会发生多次变更,这会导致增量记录大大增加,经权衡只记录最终的值。

鼠标移动

与节点属性变化的处理类似,记录鼠标轨迹也需要尽量减少增量记录。有两层节流:

  • 20 ms 最多记录一次鼠标坐标
  • 500 ms 最多发送一次鼠标坐标集,主要是做的一个分段
input输入变更
  1. 界面交互引起的,主要靠监听 inputchange 两个事件
  2. js代码设置引起的,比如设置input DOMvalue属性,这种主要是利用Object.defineProperty拦截DOM属性的setter,类似于Vue中的响应式数据

沙盒

在拿”录屏“数据后的回放界面中,需要禁用被录制页面中的所有js,同时还有很多其他交互,比如表单提交、window.open打开新窗口、内联脚本等等。 所以rrweb在回放系统中会将所有重建后的DOM放到一个iframe中,并设置相关sandbox 属性来禁用。

a链接跳转也是需要禁止的,通过事件代理来preventDefault掉所有a链接的click事件。

回放播放器

每个变更记录都带有时间戳,所以是可以做一个”播放器“来按时间顺序”播放“变更记录的。rrweb利用requestAnimationFrame来模拟随时间变化的回放。

从任意时间点开始播放:当在播放器中拖动到指定进度后,将进度之前的变更记录一次性同步执行掉,进度之后的再按照requestAnimationFrame异步播放。

倍数:播放器还可以支持例如2倍、4倍数播放,这个也比较好做,相当于此前在一个raf回调中执行一个变更记录,现在是执行2个、4个变更记录。