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);

前端监控:回放录制库 rrweb相关推荐

  1. 实现pv uv统计_聊聊前端监控(二)--行为监控的技术实现

    上一篇梳理了前端监控的主要场景和类型,从本文开始,讨论下我知道的一些技术实现.前端黑科技层出不穷,个人眼界有限,尽量把了解到的实现方式都罗列出来,希望对大家有些启发,同时也欢迎流言讨论. 限于篇幅,按 ...

  2. 一步一步搭建前端监控系统:如何将网页截图上报?

    摘要: 通过录屏或者截图,快速复现BUG场景. 作者:一步一个脚印一个坑 原文:搭建前端监控系统(备选)Js截图上报篇 Fundebug经授权转载,版权归原作者所有. PS:本文关于Fundebug录 ...

  3. 前端监控和前端埋点方案设计--摘抄

    一.为什么需要前端监控 前端监控的目的是: 获取用户行为以及跟踪产品在用户端的使用情况,并以监控数据为基础,指明产品优化的方向 . 前端监控可以分为三类:数据监控.性能监控和异常监控.下面我们来一一的 ...

  4. mysql 从库状态_大神教你自动发现监控mysql从库状态

    导读 zabbix从库如果复制有问题,在主库机器有问题的时候,切为主就会导致数据的丢失.或者主从分离的时候,如果从库出现延时状态的话,会导致前端页面展示的数据不是为最新的数据.因此,监控mysql从库 ...

  5. 前端监控和前端埋点方案设计

    在线上项目中,需要统计产品中用户行为和使用情况,从而可以从用户和产品的角度去了解用户群体,从而升级和迭代产品,使其更加贴近用户.用户行为数据可以通过前端数据监控的方式获得,除此之外,前端还需要实现性能 ...

  6. 简单使用萤石云,实时直播,监控回放

    实时直播 这个我直接使用的视频播放标签 用直播地址 ps:高清好像要转码,后端搞得我不清楚 <videoid="1234567"src="地址"autop ...

  7. 前端监控SDK开发分享

    大厂技术  高级前端  Node进阶 点击上方 程序员成长指北,关注公众号 回复1,加入高级Node交流群 作者:子慕大诗人 原文:https://www.cnblogs.com/1wen/p/144 ...

  8. 前端监控sdk 页面性能监控

    前端监控之页面性能监控 为什么要做前端监控? 更快发现问题和解决问题 做产品的决策依据 为业务扩展提供了更多可能性 指标数据监控 性能监控:首屏加载时间,卡顿率,http请求的响应时间,静态资源下载时 ...

  9. 为什么大厂前端监控都在用GIF做埋点?

    关注公众号 前端开发博客,领27本电子书 回复加群,自助秒进前端群 ❝ 什么是前端监控? 它指的是通过一定的手段来获取用户行为以及跟踪产品在用户端的使用情况,并以监控数据为基础,为产品优化指明方向,为 ...

  10. 前端监控 SDK 开发分享

    一.前言 随着前端的发展和被重视,慢慢的行业内对于前端监控系统的重视程度也在增加.这里不对为什么需要监控再做解释.那我们先直接说说需求. 对于中小型公司来说,可以直接使用三方的监控,比如自己搭建一套免 ...

最新文章

  1. python怎么装饰_如何理解python装饰器
  2. 《Linux From Scratch》第一部分:介绍 第一章:介绍- 1.4. 资源
  3. 这35个Java代码优化细节,你用了吗?
  4. java铝轮_为速度而生 JAVA Fuoco铝合金气动公路
  5. 【个人笔记】OpenCV4 C++ 图像处理与视频分析 01课
  6. 关于一个flask的服务接口实战(flask-migrate,flask-script,SQLAlchemy)
  7. 解决安装VC2015失败的问题
  8. 解密微软Longhorn和Office 12发布时间表
  9. Kubernetes 管理员认证(CKA)考试笔记(二)
  10. 接口和继承知识点总结
  11. 计算机室电源插座的配置及配电线路,小型机安装场地要求及机房环境电气要求讲课教案.pdf...
  12. Jenkins内网使用略谈
  13. shell_day1
  14. 回望2022,展望2023
  15. 水利RTU遥测终端机厂家
  16. QQ群关系库数据库查询解决方案,sql引索语句。
  17. 06 “eden没有发生minor gc, 对象直接分配在了old gen“ 的调试
  18. 2020.08.05狂人日记:C#计时器与串口通信
  19. ICRA2021论文阅读-多传感器融合语义slam
  20. 推荐GitHub上面好用的听歌软件 ------listen1

热门文章

  1. 小程序跳转:h5避免中间页直接打开微信小程序
  2. php企业微信付款到零钱,PHP实现微信商户支付企业付款到零钱功能
  3. opencore amr android,苹果手机amr文件用什么打开,opencore框架进行语音
  4. pc机安装android,4种在PC机上运行Android系统的方法
  5. git报错 fatal: unsafe repository 解决方法 xxx is owned by someone else
  6. “逃离”北京来到二线城市,谈谈面试和职业规划
  7. nodejs 运行后报错 Error: Couldn‘t find preset “es2015“ relative to directory
  8. 基于Q-Learning 的FlappyBird AI
  9. 六款超好用的大数据分析工具
  10. Nacos 中配置 Map 类型,不香