rrweb
rrweb 主要由 rrweb
、 rrweb-player
和 rrweb-snapshot
三个库组成:
- rrweb:提供了 record 和 replay 两个方法;record 方法用来记录页面上 DOM 的变化,replay 方法支持根据时间戳去还原 DOM 的变化。
- rrweb-player:基于 svelte 模板实现,为 rrweb 提供了回放的 GUI 工具,支持暂停、倍速播放、拖拽时间轴等功能。内部调用了 rrweb 的提供的 replay 等方法。
- rrweb-snapshot:包括 snapshot 和 rebuilding 两大特性,snapshot 用来序列化 DOM 为增量快照,rebuilding 负责将增量快照还原为 DOM。
本文主要介绍的是 rrweb 库的录制、回放实现原理。
工作流程与原理
基于 rrweb 去实现录屏,emit 回调方法可以拿到 DOM 变化对应所有 event,可以根据业务需求去做处理在 emit 内部做处理:
let events = [];rrweb.record({/** 订阅事件监听,必需字段 */emit(event) {events.push(event);},
});
record
方法内部会根据事件类型去初始化事件的监听,例如 DOM 元素变化、鼠标移动、鼠标交互、滚动等都有各自专属的事件监听方法。
要实现对 DOM 元素变化的监听,离不开浏览器提供的 MutationObserver
API,该 API 会在一系列 DOM 变化后,通过批量异步的方式去触发回调,并将 DOM 变化通过 MutationRecord
数组传给回调方法。
rrweb 内部也是基于该 API 去实现监听,回调方法为 MutationBuffer
类提的 processMutations
方法:
const observer = new MutationObserver(mutationBuffer.processMutations.bind(mutationBuffer),
);
mutationBuffer.processMutations
方法会根据 MutationRecord.type
值做不同的处理:
type === 'attributes'
: 代表 DOM 属性变化,所有属性变化的节点会记录在this.attributes
数组中,结构为{ node: Node, attributes: {} }
,attributes 中仅记录本次变化涉及到的属性;type === 'characterData'
: 代表 characterData 节点变化,会记录在this.texts
数组中,结构为{ node: Node, value: string }
,value 为 characterData 节点的最新值;type === 'childList'
: 代表子节点树 childList 变化,包括节点新增,节点移动,节点删除。若每次都完整记录整个 DOM 树,数据会非常庞大,显然不是一个可行的方案,所以,rrweb 采用了增量快照的处理方式。
childList 增量快照对应三个关键的 Set:addedSet
、 movedSet
、 droppedSet
,即三种节点操作:新增、移动、删除,这点和 React diff
机制相似。此处使用 Set 结构,实现了对 DOM 节点的去重处理。
1. 节点新增
遍历 MutationRecord.addedNodes
节点,将未被序列化的节点添加到 addedSet
中,并且若该节点存在于被删除集合 droppedSet
中,则从 droppedSet
中移除。
遍历完所有 MutationRecord
记录数组,会统一对 addedSet
中的节点做序列化处理,每个节点序列化处理的结果是:
export type addedNodeMutation = {parentId: number;nextId: number | null;node: serializedNodeWithId;
}
DOM 的关联关系是通过 parentId
和 nextId
建立起来的,若该 DOM 节点的父节点、或下一个兄弟节点尚未被序列化,则该节点无法被准确定位,所以需要先将其存储下来,最后处理。
rrweb 使用了一个双向链表 addList
用来存储父节点尚未被添加的节点,向 addList
中插入节点时:
- 若 DOM 节点的 previousSibling 已存在于链表中,则插入在
node.previousSibling
节点后 - 若 DOM 节点的 nextSibling 已存在于链表中,则插入在
node.nextSibling
节点前 - 都不在,则插入链表的头部
通过这种添加方式,可以保证兄弟节点的顺序,DOM 节点的 nextSibling
一定会在该节点的后面,previousSibling
一定在该节点的前面;addedSet
序列化处理完成后,会对 addList
链表进行倒序遍历,这样可以保证 DOM 节点的 nextSibling
一定是在 DOM 节点之前被序列化,下次序列化 DOM 节点的时候,就可以拿到 nextId
。
2. 节点移动
遍历 MutationRecord.addedNodes
节点,若记录的节点有 __sn
属性,则添加到 movedSet
中。有 __sn
属性代表是已经被序列化处理过的 DOM 节点,即意味着是对节点的移动。
在对 movedSet
中的节点序列化处理之前,会判断其父节点是否已被移除:
- 父节点被移除,则无需处理,跳过;
- 父节点未被移除,对该节点进行序列化。
3. 节点删除
遍历 MutationRecord.removedNodes
节点:
- 若该节点是本次新增节点,则忽略该节点,并且从
addedSet
中移除该节点,同时记录到droppedSet
中,在处理新增节点的时候需要用到:虽然移除了该节点,但其子节点可能还存在于addedSet
中,在处理addedSet
节点时,会判断其祖先节点是否已被移除; - 需要删除的节点记录在
this.removes
中,记录了 parentId 和节点 id。
序列化 DOM
MutationBuffer
实例会调用 snapshot
的 serializeNodeWithId
方法对 DOM 节点进行序列化处理。 serializeNodeWithId
内部调用 serializeNode
方法,根据 nodeType
对 Document、Doctype、Element、Text、CDATASection、Comment 等不同类型的 node 进行序列化处理,其中的关键是对 Element 的序列化处理。
- 遍历元素的
attributes
属性,并且调用transformAttribute
方法将资源路径处理为绝对路径;
for (const { name, value } of Array.from((n as HTMLElement).attributes)) {attributes[name] = transformAttribute(doc, tagName, name, value);
}
- 通过检查元素是否包含
blockClass
类名,或是否匹配blockSelector
选择器,去判断元素是否需要被隐藏;为了保证元素隐藏不会影响页面布局,会给返回一个同等宽高的空元素;
const needBlock = _isBlockedElement(n as HTMLElement,blockClass,blockSelector,
);
-
区分外链 style 文件和内联 style,对 CSS 样式序列化,并对 css 样式中引用资源的相对路径转换为绝对路径;对于外链文件,通过 CSSStyleSheet 实例的 cssRules 读取所有的样式,拼接成一个字符串,放到
_cssText
属性中;
if (tagName === 'link' && inlineStylesheet) {/** document.styleSheets 获取所有的外链style */const stylesheet = Array.from(doc.styleSheets).find((s) => {return s.href === (n as HTMLLinkElement).href;});/** 获取该条css文件对应的所有rule的字符串 */const cssText = getCssRulesString(stylesheet as CSSStyleSheet);if (cssText) {delete attributes.rel;delete attributes.href;/** 将css文件中资源路径转换为绝对路径 */attributes._cssText = absoluteToStylesheet( cssText,stylesheet!.href!,);}
}
- 对用户输入数据调用 maskInputValue 方法进行加密处理;
- 将 canvas 转换为 base64 图片保存,记录 media 当前播放的时间、元素的滚动位置等;
- 返回一个序列化后的对象 serializedNode,其中包含前面处理过的 attributes 属性,序列化的关键是每个节点都会有唯一的 id,其中 rootId 代表所属 document 的 id,帮助在回放的时候识别根节点。
return {type: NodeType.Element,tagName,attributes,childNodes: [],isSVG,needBlock,rootId,
};
拿到序列化后的 DOM 节点,会统一调用wrapEvent
方法给事件添加上时间戳:
function wrapEvent(e: event): eventWithTime {return {...e,timestamp: Date.now(),};
}
serializeNodeWithId
方法在序列化的时候会从 DOM 节点的 __sn.id
属性中读取 id,若不存在,就调用 genId 生成新的 id,并赋值给 __sn.id
属性,该 id 是用来唯一标识 DOM 节点,通过 id 建立起 id -> DOM
的映射关系,使得在回放的时候找到对应的 DOM 节点:
function genId(): number {return _id++;
}const serializedNode = Object.assign(_serializedNode, { id });
若 DOM 节点存在子节点,则会递归调用 serializeNodeWithId
方法,最后会返回一个下面这样的 tree 数据结构:
{type: NodeType.Document,childNodes: [{{type: NodeType.Element,tagName,attributes,childNodes: [{//...}],isSVG,needBlock,rootId,}}],rootId,
};
Replay 回放
回放部分在 replay/index.ts
文件中,先创建沙箱环境,接着或进行重建 document
全量快照,在通过 requestAnimationFrame
模拟定时器的方式来播放增量快照。replay
的构造函数接收两个参数,快照数据 events
和 配置项 config,
构造函数中最核心三步,创建沙箱环境,定时器,和初始化播放器并且启动。:
export class Replayer {constructor(events, config) {/** 1.创建沙箱环境 */this.setupDom();/** 2.定时器 */const timer = new Timer();/** 3.播放服务 */this.service = new createPlayerService(events, timer);this.service.start();}private setupDom() {this.wrapper = document.createElement('div');this.wrapper.classList.add('replayer-wrapper');this.config.root!.appendChild(this.wrapper);this.mouse = document.createElement('div');this.mouse.classList.add('replayer-mouse');this.wrapper.appendChild(this.mouse);if (this.config.mouseTail !== false) {this.mouseTail = document.createElement('canvas');this.mouseTail.classList.add('replayer-mouse-tail');this.mouseTail.style.display = 'inherit';this.wrapper.appendChild(this.mouseTail);}this.iframe = document.createElement('iframe');const attributes = ['allow-same-origin'];if (this.config.UNSAFE_replayCanvas) {attributes.push('allow-scripts');}// hide iframe before first meta eventthis.iframe.style.display = 'none';this.iframe.setAttribute('sandbox', attributes.join(' '));this.disableInteract();this.wrapper.appendChild(this.iframe);if (this.iframe.contentWindow && this.iframe.contentDocument) {smoothscrollPolyfill(this.iframe.contentWindow,this.iframe.contentDocument,);polyfill(this.iframe.contentWindow as IWindow);}}
}
本质上还是使用 timer
来实现播放。 setupDom
核心是通过 iframe
来创建出一个沙箱环境。
createPlayerService
函数的核心思路是通过给定时器 timer
加入需要执行的快照动作 actions
, 在调用 timer.start()
开始回放快照:
export function createPlayerService() {//...play(ctx) {/** 获取每个 event 执行的 doAction 函数 */for (const event of needEvents) {//..const castFn = getCastFn(event);actions.push({doAction: () => {castFn();}})//..}/** 添加到定时器队列中 */timer.addActions(actions);/** 启动定时器播放 视频 */timer.start();},//...
}
rrweb 中不仅仅是做了这些,还包含数据压缩,移动端处理,隐私问题等等细节处理等等。
自定义计时器
回放的过程中为了支持进度条的随意拖拽,以及回放速度的设置(如上图所示),自定义实现了高精度计时器 Timer ,关键属性和方法为:
export declare class Timer {/** 回放初始位置,对应进度条拖拽到的任意时间点 */timeOffset: number;/** 回放的速度 */speed: number;/** 回放Action队列 */private actions;/** 添加回放Action队列 */addActions(actions: actionWithDelay[]): void;/** 开始回放 */start(): void;/** 设置回放速度 */setSpeed(speed: number): void;
}
通过 Replayer 提供的 play
方法可以将上文记录的事件在 iframe 中进行回放。
1. 初始化 rrweb.Replayer
实例时,会创建一个 iframe 作为承载事件回放的容器,再分别调用创建两个 service: createPlayerService
用于处理事件回放的逻辑,createSpeedService
用于控制回放的速度。
const replayer = new rrweb.Replayer(events);
replayer.play();
2. 会调用 replayer.play()
方法,去触发 PLAY
事件类型,开始事件回放的处理流程。
/** this.service 为 createPlayerService 创建的回放控制service实例 */
/** timeOffset 值为鼠标拖拽后的时间偏移量 */
this.service.send({ type: 'PLAY', payload: { timeOffset } });
回放支持随意拖拽的关键在于传入时间偏移量 timeOffset
参数:
- 回放的总时长 = events[n].timestamp - events[0].timestamp,
n
为事件队列总长度减一; - 时间轴的总时长为回放的总时长,鼠标拖拽的起始位置对应时间轴上的坐标为
timeOffset
; - 根据初始事件的
timestamp
和timeOffset
计算出拖拽后的基线时间戳(baselineTime)
; - 再从所有的事件队列中根据事件的
timestamp
截取基线时间戳(baselineTime)
后的事件队列,即需要回放的事件队列。
拿到事件队列后,需要遍历事件队列,根据事件类型转换为对应的回放 Action,并且添加到自定义计时器 Timer 的 Action 队列中。
actions.push({doAction: () => {castFn();},delay: event.delay!,
});
doAction
为回放的时候要调用的方法,会根据不同的EventType
去做回放处理,例如 DOM 元素的变化对应增量事件EventType.IncrementalSnapshot
。若是增量事件类型,回放 Action 会调用applyIncremental
方法去应用增量快照,根据序列化后的节点数据构建出实际的 DOM 节点,为前面序列化 DOM 的反过程,并且添加到iframe容器中。delay
= event.timestamp - baselineTime,为当前事件的时间戳相对于基线时间戳
的差值。
Timer 自定义计时器是一个高精度计时器,主要是因为 start
方法内部使用了 requestAnimationFrame
去异步处理队列的定时回放;与浏览器原生的 setTimeout
和 setInterval
相比,requestAnimationFrame
不会被主线程任务阻塞,而执行 setTimeout
、 setInterval
都有可能会有被阻塞。其次,使用了 performance.now()
时间函数去计算当前已播放时长;performance.now()
会返回一个用浮点数表示的、精度高达微秒级的时间戳,精度高于其他可用的时间类函数,例如 Date.now()
只能返回毫秒级别:
public start() {this.timeOffset = 0;/** performance.timing.navigationStart + performance.now() 约等于 Date.now() */let lastTimestamp = performance.now();/** Action 队列 */const { actions } = this;const self = this;function check() {const time = performance.now();/** self.timeOffset为当前播放时长:已播放时长 * 播放速度(speed) 累加而来. 之所以是累加,因为在播放的过程中,速度可能会更改多次 */self.timeOffset += (time - lastTimestamp) * self.speed;lastTimestamp = time;/** 遍历 Action 队列 */while (actions.length) {const action = actions[0];/** 差值是相对于`基线时间戳`的,当前已播放 {timeOffset}ms *//** 所以需要播放所有「差值 <= 当前播放时长」的 action */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);
}
完成回放 Action 队列转换后,会调用 timer.start()
方法去按照正确的时间间隔依次执行回放。在每次 requestAnimationFrame
回调中,会正序遍历 Action 队列,若当前 Action 相对于基线时间戳
的差值小于当前的播放时长,则说明该 Action 在本次异步回调中需要被触发,会调用 action.doAction
方法去实现本次增量快照的回放。回放过的 Action 会从队列中删除,保证下次 requestAnimationFrame
回调不会重新执行。
使用
总之,基于 rrweb 可以方便地帮助我们实现录屏回放功能:
- 用来做用户问题回溯,取代用户录屏,无法复现等情况
- 监控页面error等情况的操作路径
进行录制:
let events = [];rrweb.record({emit(event) {// 将 event 存入 events 数组中events.push(event);},
});// save 函数用于将 events 发送至后端存入,并重置 events 数组
function save() {const body = JSON.stringify({ events });events = [];fetch('http://YOUR_BACKEND_API', {method: 'POST',headers: {'Content-Type': 'application/json',},body,});
}// 每 10 秒调用一次 save 方法,避免请求过多
setInterval(save, 10 * 1000);
回放时需要引入对应的 CSS 文件:
<linkrel="stylesheet"href="https://cdn.jsdelivr.net/npm/rrweb@latest/dist/rrweb.min.css"
/>
再通过以下 JS 代码初始化 replayer:
/** 获取上传给后端的事件 */
const events = YOUR_EVENTS;const replayer = new rrweb.Replayer(events);/** 播放 */
replayer.play();/** 从第 3 秒的内容开始播放 */
replayer.play(3000);/** 暂停 */
replayer.pause();/** 暂停至第 5 秒处 */
replayer.pause(5000);