rrweb 是 'record and replay the web' 的简写,用来录制并回放 web 界面中的用户操作。
一、包结构分析
- rrweb-snapshot:包含 snapshot 和 rebuild 两个功能。 snapshot 用于将 DOM 及其状态转化为可序列化的数据结构并添加唯一标识; rebuild 则是将 snapshot 记录的数据结构重建为对应的 DOM,并插入文档中
- rrweb:包含 record 和 replay 两个功能。 record 用于记录 DOM 中的所有变更(mutation); replay 则是将记录的变更按照对应的时间一一重放。
- rrweb-player:为 rrweb 提供一套 UI 控件,提供基于图形用户界面的暂停、快进、拖拽至任意时间点播放等功能。
- rrdom:为node平台mock浏览器的dom,event等api
二、调试
web录屏和重放的功能主要在 rrweb package
中,rrweb-snapshot
是rrweb的依赖包。所以我们只需要调试这两个包即可。
rrweb项目采用 lerna + yarn workspace 的 monorepo 架构维护的;查看官网的指南 进入项目根目录运行 yarn install
yarn dev
2个命令,项目打包完成了,最后执行命令
cd packages/rrweb
npm link
创建一个rrweb-demo
,在项目根目录执行 npm link rrweb
,就可以应用本地打包的rrweb的源码了。
ps:可以先 npm install rrweb,这可以可以把 rrweb 加入 dependencies依赖,这样可以避免rrweb找不到的情况。
source Map
但是还有一个问题,rrweb没有打包出source map,所以我们在调试的时候看到的是rrweb打包后的文件,这样就比较影响可读性,所以我们需要构建出rrweb的source map文件,并在rrweb-demo
项目中使用。
rrweb项目所有的子包都是使用了typescript
,所以我们需要修改packages/rrweb
packages/rrweb-snapshot
下的tsconfig.json
"compilerOptions": {
"sourceMap": true
}
packages/rrweb/rollup.config.js
从配置中看默认会输出 commonJs, esm, iife几种格式的包。在dev模式下,为了加快打包速度,我们可以只输出esm格式的包。
packages/rrweb/package.json
dev默认情况下只会输出iife格式的包,具体查看process.env.BROWSER_ONLY;为了调试方便,我们在项目中使用esm格式的包
rollup.output增加souremap配置
注释iife格式
commonjs 和 brower minify 模式依次注释即可。然后每次打包就只输出esm格式的包了,source map文件也生成了
rollup-plugin-rename-node-modules
rrweb package打包为esm的时候 使用了rollup-plugin-rename-node-modules 插件将 node_moudules重命名为 ext 在发布到npm时候会忽略nodule_modules但是ext文件夹是可以发布的,保证rrweb不论在本地还是成功发包后都能正常运行;但是这样会影响souremap的生成,sourcemap文件的sources
原文件路径为空,这样调试的时候也无法找到原文件,具体可查看 usage
rrweb-snapshot 要修改的配比较简单,增加suorcemap配置就好,可参考 rrweb packages
到这里rrweb源码的编译工作就完成了,接下可以看看rrweb_demo项目的配置
rrweb_demo
配置 alias 指向本地构建的esm格式的rrweb:
启动rrweb_demo项目后,打开devtool,发现rrweb的source map没有加载出来,这时候需要配置 source-map-loader了。
这样就结束了么?No,No。
因为在rrweb_demo项目中是通过软链接找到 /Users/username/Documents/group_share/rrweb/packages/rrweb
路径下的打包产物,这就导致虽然已成功加载了 rrweb的source map文件,但是依旧无法通过sourcemap中的sources字段
找到原始的ts文件,可以看到 web-map-loader也有类似的issues
嗯嗯,喝口水,压压惊!在一段漫长时间的google之后,发现配置 tsconfig 的[soureRoot](https://www.typescriptlang.org/tsconfig#sourceRoot)
,配置为绝对路径。
{
"compilerOptions": {
"sourceRoot": "/Users/username/Documents/group_share/rrweb/packages/rrweb/src"
}
}「」
重新打包rrweb,再次启动rrweb_demo就可以愉快的debugger了。
至此,大功告成,终于可以进入正题了。
总体工作流程
为了实现web界面录制与回放的功能,rrweb着重实现 dom元素的 序列化、增量快照、回放 和 沙盒 。
从上图可以看到rrweb录制的入口是 record方法,然后会序列化doucment
完成一次全量快照;然后通过浏览器提供的MutationObserver监听dom元素的创建、删除和属性的变化,同时监听会监听鼠标移动,点击和页面交互的等事件。不论是全量快照还是增量快照 均会emit event data,以json的格式保存在服务器;需要还原用户界面的时候,从服务器拉取json,调用replay方法开启节目的重放过程。
全量快照event data
// 全量快照的data类型
{
type: EventType.FullSnapshot,
data: {
node: {
type: NodeType.Document;
childNodes: serializedNodeWithId[];
compatMode?: string;
id: number
},
initialOffset: {
left: window.pageXOffset ,
top:
window.pageYOffset,
},
}
}
mutation event data
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation, // 0
texts: textMutation[];
attributes: attributeMutation[];
removes: removedNodeMutation[];
adds: addedNodeMutation[];
isAttachIframe?: true,
},
}
滚动 event data
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Scroll, // 3
id: number;
x: number;
y: number;,
}
}
录制(record)
record方法内部会根据事件类型去初始化事件的监听,例如 DOM 元素变化、鼠标移动、鼠标交互、滚动等都有各自专属的事件监听方法;MutationObserver Api监听dom元素的变化。
dom序列化
从官网的序列化文档中,可以知道:
如果仅仅需要在本地录制和回放浏览器内的变化,那么我们可以简单地通过深拷贝 DOM 来实现当前视图的保存,然后在用requestAnimationFrame播放就好 demo;但实际场景都是需要进行数据传输的,所有必须将web状态序列化文本格式(如josn)。
截一段官网的文档说明:
针对序列化的特殊处理,聊聊我个人的理解
- 去脚本化。一是为了一个安全的沙盒环境;二是也没必要执行script,因为无论是用户怎么点击,事件的逻辑处理但体现在dom的更新上,而这都可以通过MutationObserver API 监听到,类似于 dom diff。
- 处理form表单;input 的值根据type类型 可能会设置在value/checked/selected等属性上,需要单独记录
- 资源路径转换为绝对路径;这个也比较好理解,我们无法确认客户端的文件目录结构,所以只能转化为绝对路径;前面我们在生成source map文件的时候,也设置了绝对路径哈。
- 样式内联;避免额外请求资源,保证回放体验。
dom的序列化是在Snapshot方法中完成的:
snapshot方法的逻辑还是比较清晰,序列化的主体逻辑在serializeNode方法中,它会根据nodeType序列化不同的节点;值得注意的是这里还维护了一个 序列化Id -> dom的映射
。
可以看到在增量 event data中,有parentId, id, nextId,根据这3个id就能确认dom元素的位置,这样在后续重新绘制界面的时候,能确认dom的位置。
serializeNode
serializeNode 方法依据nodeType判断节点的类型,依次序列化节点。
document节点:compatMode判断文档的渲染模式是否为标准模式;
{ type: NodeType.Document,childNodes: [] }
文档类型 DocumentType 节点: 直接返回节点的基本信息,如 name,type,publicId 和 systemIdd。
{ type: NodeType.DocumentType, name: (n as DocumentType).name, publicId: (n as DocumentType).publicId, systemId: (n as DocumentType).systemId, rootId }
注释节点
{ type: NodeType.Comment, textContent: n.textContent }
- 文本节点
学习到了CSSStyleSheet 这个操作css的api。
- 元素节点
- 调用transformAttribute方法 把标签上的属性转换为绝对路径。
- 将link 引入的远程样式,转换为inlineStylesheet。
- 表单元素处理。input, textarea, select 等元素,记录它们的value 或者 checked;option元素记录selected属性。
- 占位元素。只需记录dom元素的宽高,重绘时用空div占位。
最后处理完成得到下图中的结构,如document._sn属性
监听Dom变化
通过MutationObserver可监听dom元素的变化,通过批量异步的方式去触发回调,并将 DOM 变化通过 MutationRecord 数组传给回调方法。
- type === 'attributes': 代表 DOM 属性变化,所有属性变化的节点会记录在 this.attributes 数组中,结构为 { node: Node, attributes: {} },attributes 中仅记录本次变化涉及到的属性;
- type === 'characterData': 代表 characterData 节点变化,会记录在 this.texts 数组中,结构为 { node: Node, value: string },value 为 characterData 节点的最新值;
- type === 'childList': 代表子节点树 childList 变化,比起前面两种类型,处理会较为复杂。
rrweb为实现增量快照,使用set结构;addedSet、 movedSet、 droppedSet,对应三种节点操作:新增、移动、删除,这点和 React diff 机制相似。
再次截取官方文档的说明:
总结起来:
- 为避免节点遗漏,需遍历子节点,然后用set结构去重。
- DOM 的关联关系是通过 parentId 和 nextId 建立起来的,方便绘制界面;但是现在节点时统一序列化的,那就会有dom节点的父节点、或下一个兄弟节点尚未被序列化,拿不到id的问题;所以需要维护一个双向链表,遍历addedset中节点依次添加到链接中。最后倒序遍历链表,依次序列化节点。
新增节点mutation event
双向链表的维护逻辑
- 若 DOM 节点的 previousSibling 已存在于链表中,则插入在 node.previousSibling 节点后
- 若 DOM 节点的 nextSibling 已存在于链表中,则插入在 node.nextSibling 节点前;都不在,则插入链表的头部。
通过这种添加方式,可以保证兄弟节点的顺序,DOM 节点的 nextSibling 一定会在该节点的后面,previousSibling 一定在该节点的前面;addedSet 中的节点全部添加到链表后,会对 addList 链表进行倒序遍历,这样可以保证 DOM 节点的 nextSibling 一定是在 DOM 节点之前被序列化,下次序列化 DOM 节点的时候,就可以拿到 nextId。
节点处理流程
回放(replay)
通过 Replayer 提供的 play
方法可以将上文记录的事件在 iframe 中进行回放。
第一步,初始化 rrweb.Replayer
实例时,再分别调用创建两个 service: createPlayerService
用于处理事件回放的逻辑,createSpeedService
用于控制回放的速度。
第二步,会调用 replayer.play()
方法,去触发 PLAY
事件类型,开始事件回放的处理流程。
const replayer = new rrweb.Replayer(events);
replayer.play();
// this.service 为 createPlayerService 创建的回放控制service实例
// timeOffset 值为鼠标拖拽后的时间偏移量
this.service.send({ type: 'PLAY', payload: { timeOffset } });
回放流程
自定义计时器
回放的过程中为了支持进度条的随意拖拽,以及回放速度的设置(如上图所示),自定义实现了高精度计时器 Timer ,关键属性和方法为
class Timer {
// 回放初始位置,对应进度条拖拽到的任意时间点
public timeOffset: number = 0;
// 回放速度
public speed: number;
// 回放队列 { doAction: () => void; delay: number }
private actions: actionWithDelay[];
private raf: number | null = null;
private liveMode: boolean;
// ...
public start() {
this.timeOffset = 0;
// performance.timing.navigationStart + performance.now() 约等于 Date.now()
let lastTimestamp = performance.now();
const { actions } = this;
const self = this;
function check() {
const time = performance.now();
// 当前的播放时间
self.timeOffset += (time - lastTimestamp) * self.speed;
lastTimestamp = time;
while (actions.length) {
const action = actions[0];
// 当前播放时间大于 action需要执行的时间段点
// action.delay = event.timestamp - baselineTime
if (self.timeOffset >= action.delay) {
actions.shift();
action.doAction();
} else {
break;
}
}
if (actions.length > 0 || self.liveMode) {
self.raf = requestAnimationFrame(check);
}
}
this.raf = requestAnimationFrame(check);
}
}
rebuild
rebuild的流程其实与snapshot的过程类似,直接上图:
总结
本文主要介绍了rrweb的调试方法和rrweb的工作流程,希望可以帮到各位倔友哈!