前端监控:回放录制库 rrweb

news/2024/10/17 14:16:39/

rrweb

rrweb 主要由 rrwebrrweb-playerrrweb-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 中插入节点时:

  1. 若 DOM 节点的 previousSibling 已存在于链表中,则插入在 node.previousSibling 节点后
  2. 若 DOM 节点的 nextSibling 已存在于链表中,则插入在 node.nextSibling 节点前
  3. 都不在,则插入链表的头部

通过这种添加方式,可以保证兄弟节点的顺序,DOM 节点的 nextSibling 一定会在该节点的后面,previousSibling 一定在该节点的前面;addedSet 序列化处理完成后,会对 addList 链表进行倒序遍历,这样可以保证 DOM 节点的 nextSibling 一定是在 DOM 节点之前被序列化,下次序列化 DOM 节点的时候,就可以拿到 nextId

2. 节点移动

遍历 MutationRecord.addedNodes 节点,若记录的节点有 __sn 属性,则添加到 movedSet 中。有 __sn 属性代表是已经被序列化处理过的 DOM 节点,即意味着是对节点的移动。

在对 movedSet 中的节点序列化处理之前,会判断其父节点是否已被移除:

  1. 父节点被移除,则无需处理,跳过;
  2. 父节点未被移除,对该节点进行序列化。

3. 节点删除

遍历 MutationRecord.removedNodes 节点:

  1. 若该节点是本次新增节点,则忽略该节点,并且从 addedSet 中移除该节点,同时记录到 droppedSet 中,在处理新增节点的时候需要用到:虽然移除了该节点,但其子节点可能还存在于 addedSet 中,在处理 addedSet 节点时,会判断其祖先节点是否已被移除;
  2. 需要删除的节点记录在 this.removes 中,记录了 parentId 和节点 id。

序列化 DOM

MutationBuffer 实例会调用 snapshotserializeNodeWithId 方法对 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
  • 根据初始事件的 timestamptimeOffset 计算出拖拽后的 基线时间戳(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 去异步处理队列的定时回放;与浏览器原生的 setTimeoutsetInterval 相比,requestAnimationFrame 不会被主线程任务阻塞,而执行 setTimeoutsetInterval 都有可能会有被阻塞。其次,使用了 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);

http://www.ppmy.cn/news/533377.html

相关文章

java写键盘鼠标录制器,易语言仿按键精灵录制键盘鼠标操作的代码

DLL命令表 .版本 2 .DLL命令 取按键状态, 短整数型, "user32.dll", "GetAsyncKeyState" .参数 鼠标键, 整数型 .DLL命令 取鼠标位置, 整数型, "user32.dll", "GetCursorPos" .参数 当前鼠标位置, 鼠标位置, 传址 .DLL命令 模拟鼠标按键…

QT 录制鼠标键盘回放

QT 录制鼠标键盘回放 前段时间因为朋友想要解放双手想要把一些操作录制回放 所以做了这个小项目 因为我目前运用的QT只能在程序中去记录鼠标跟键盘的操作 所以网上找了找方法 有钩子函数使用 所以运用了钩子函数 一、Hook Window的钩子可以捕捉整个窗口的鼠标跟键盘 首先我们…

如何预防CC攻击 180.188.22.2

CC攻击是DDoS(分布式拒绝服务)的一种&#xff0c;相比其它的DDoS攻击CC似乎更有技术含量一些。这种攻击你见不到虚假IP&#xff0c;见不到特别大的异常流量&#xff0c;但造成服务器无法进行正常连接&#xff0c;一条ADSL的普通用户足以挂掉一台高性能的Web服务器 。 如何应对…

【备战秋招】每日一题:2023.3.15-阿里OD机试(第三题)-k次操作最小化极差

在线评测链接:P1084 题目内容 在一个遥远的王国里&#xff0c;有一座高耸入云的宝塔&#xff0c;据说里面藏有神秘的宝藏。但是&#xff0c;进入宝塔的道路异常困难&#xff0c;需要经过各种险阻&#xff0c;其中一个重要的关卡是“平衡之门”。 平衡之门是一条走廊&#xf…

Linux组管理和权限管理

一、Linux组 在linux中每个用户必须属于一个组&#xff0c;不能独立于组外&#xff0c;在linux中 文件所有者 一般为文件创建者&#xff0c;可以通过ls -ahl 查看文件所有者 chown 用户名 文件名 &#xff1a;修改文件所有者 groupadd 组名 创建组 当某个用户创建了一个文件…

【C++学习】C++入门 | 缺省参数 | 函数重载 | 探究C++为什么能够支持函数重载

写在前面&#xff1a; 上一篇文章我介绍了C该怎么学&#xff0c;什么是命名空间&#xff0c;以及C的输入输出&#xff0c; 这里是传送门&#xff1a;http://t.csdn.cn/Oi6V8 这篇文章我们继续来学习C的基础知识。 目录 写在前面&#xff1a; 1. 缺省参数 2. 函数重载 3…

HD90假钞辨真伪

网友现在是一股强大的力量。提醒大家看好手中的人民币哦

银行取票机

import array.SuperArray;public class queue {private SuperArray superArray new SuperArray();// 入队public void add(int data) {superArray.addToTail(data);}// 出队public int pop() {Integer select superArray.select(0);superArray.delete(0);return select;}publ…