rrweb录屏原理浅析

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录屏原理浅析
文章图片

创建一个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录屏原理浅析
文章图片

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配置
rrweb录屏原理浅析
文章图片

rrweb录屏原理浅析
文章图片

注释iife格式
rrweb录屏原理浅析
文章图片

commonjs 和 brower minify 模式依次注释即可。然后每次打包就只输出esm格式的包了,source map文件也生成了
rrweb录屏原理浅析
文章图片

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录屏原理浅析
文章图片

rrweb-snapshot 要修改的配比较简单,增加suorcemap配置就好,可参考 rrweb packages
到这里rrweb源码的编译工作就完成了,接下可以看看rrweb_demo项目的配置
rrweb_demo
配置 alias 指向本地构建的esm格式的rrweb:
rrweb录屏原理浅析
文章图片

启动rrweb_demo项目后,打开devtool,发现rrweb的source map没有加载出来,这时候需要配置 source-map-loader了。
rrweb录屏原理浅析
文章图片

这样就结束了么?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了。
rrweb录屏原理浅析
文章图片

至此,大功告成,终于可以进入正题了。
总体工作流程
为了实现web界面录制与回放的功能,rrweb着重实现 dom元素的 序列化、增量快照、回放 和 沙盒 。
rrweb录屏原理浅析
文章图片

从上图可以看到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元素的变化。
rrweb录屏原理浅析
文章图片

dom序列化 从官网的序列化文档中,可以知道:
如果仅仅需要在本地录制和回放浏览器内的变化,那么我们可以简单地通过深拷贝 DOM 来实现当前视图的保存,然后在用requestAnimationFrame播放就好 demo;但实际场景都是需要进行数据传输的,所有必须将web状态序列化文本格式(如josn)。
截一段官网的文档说明:
rrweb录屏原理浅析
文章图片

针对序列化的特殊处理,聊聊我个人的理解
  • 去脚本化。一是为了一个安全的沙盒环境;二是也没必要执行script,因为无论是用户怎么点击,事件的逻辑处理但体现在dom的更新上,而这都可以通过MutationObserver API 监听到,类似于 dom diff。
  • 处理form表单;input 的值根据type类型 可能会设置在value/checked/selected等属性上,需要单独记录
  • 资源路径转换为绝对路径;这个也比较好理解,我们无法确认客户端的文件目录结构,所以只能转化为绝对路径;前面我们在生成source map文件的时候,也设置了绝对路径哈。
  • 样式内联;避免额外请求资源,保证回放体验。
dom的序列化是在Snapshot方法中完成的:
rrweb录屏原理浅析
文章图片

snapshot方法的逻辑还是比较清晰,序列化的主体逻辑在serializeNode方法中,它会根据nodeType序列化不同的节点;值得注意的是这里还维护了一个 序列化Id -> dom的映射
rrweb录屏原理浅析
文章图片

可以看到在增量 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 }

  • 文本节点
rrweb录屏原理浅析
文章图片

学习到了CSSStyleSheet 这个操作css的api。
  • 元素节点
  • 调用transformAttribute方法把标签上的属性转换为绝对路径。
  • 将link 引入的远程样式,转换为inlineStylesheet。
rrweb录屏原理浅析
文章图片

  1. 表单元素处理。input, textarea, select 等元素,记录它们的value 或者 checked;option元素记录selected属性。
  2. 占位元素。只需记录dom元素的宽高,重绘时用空div占位。
最后处理完成得到下图中的结构,如document._sn属性
rrweb录屏原理浅析
文章图片

监听Dom变化 通过MutationObserver可监听dom元素的变化,通过批量异步的方式去触发回调,并将 DOM 变化通过 MutationRecord 数组传给回调方法。
rrweb录屏原理浅析
文章图片

  • 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 机制相似。
再次截取官方文档的说明:
rrweb录屏原理浅析
文章图片

总结起来:
  • 为避免节点遗漏,需遍历子节点,然后用set结构去重。
  • DOM 的关联关系是通过 parentId 和 nextId 建立起来的,方便绘制界面;但是现在节点时统一序列化的,那就会有dom节点的父节点、或下一个兄弟节点尚未被序列化,拿不到id的问题;所以需要维护一个双向链表,遍历addedset中节点依次添加到链接中。最后倒序遍历链表,依次序列化节点。
    新增节点mutation event
rrweb录屏原理浅析
文章图片

双向链表的维护逻辑 遍历this.addset中的节点,依次加入链表
  • 若 DOM 节点的 previousSibling 已存在于链表中,则插入在 node.previousSibling 节点后
  • 若 DOM 节点的 nextSibling 已存在于链表中,则插入在 node.nextSibling 节点前;都不在,则插入链表的头部。
    通过这种添加方式,可以保证兄弟节点的顺序,DOM 节点的 nextSibling 一定会在该节点的后面,previousSibling 一定在该节点的前面;addedSet 中的节点全部添加到链表后,会对 addList 链表进行倒序遍历,这样可以保证 DOM 节点的 nextSibling 一定是在 DOM 节点之前被序列化,下次序列化 DOM 节点的时候,就可以拿到 nextId。
rrweb录屏原理浅析
文章图片

节点处理流程 rrweb录屏原理浅析
文章图片

回放(replay)
【rrweb录屏原理浅析】通过 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 } });

回放流程
rrweb录屏原理浅析
文章图片

自定义计时器 回放的过程中为了支持进度条的随意拖拽,以及回放速度的设置(如上图所示),自定义实现了高精度计时器 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的调试方法和rrweb的工作流程,希望可以帮到各位倔友哈!
参考
rrweb 带你还原问题现场

    推荐阅读