rrweb 主要由 rrweb
、 rrweb-player
和 rrweb-snapshot
三个库组成:
本文主要介绍的是 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
中插入节点时:
node.previousSibling
节点后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。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!,
);
}
}
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/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 event
this.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
参数:
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 可以方便地帮助我们实现录屏回放功能:
进行录制:
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 文件:
再通过以下 JS 代码初始化 replayer:
/** 获取上传给后端的事件 */
const events = YOUR_EVENTS;
const replayer = new rrweb.Replayer(events);
/** 播放 */
replayer.play();
/** 从第 3 秒的内容开始播放 */
replayer.play(3000);
/** 暂停 */
replayer.pause();
/** 暂停至第 5 秒处 */
replayer.pause(5000);