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

image.png

创建一个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录屏原理浅析_第1张图片

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录屏原理浅析_第2张图片

image.png

注释iife格式

rrweb录屏原理浅析_第3张图片

commonjsbrower minify 模式依次注释即可。然后每次打包就只输出esm格式的包了,source map文件也生成了

rrweb录屏原理浅析_第4张图片

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

image.png

rrweb-snapshot 要修改的配比较简单,增加suorcemap配置就好,可参考 rrweb packages

到这里rrweb源码的编译工作就完成了,接下可以看看rrweb_demo项目的配置

rrweb_demo

配置 alias 指向本地构建的esm格式的rrweb:

image.png

启动rrweb_demo项目后,打开devtool,发现rrweb的source map没有加载出来,这时候需要配置 source-map-loader了。

image.png

这样就结束了么?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录屏原理浅析_第5张图片

至此,大功告成,终于可以进入正题了。

总体工作流程

为了实现web界面录制与回放的功能,rrweb着重实现 dom元素的 序列化增量快照回放沙盒

rrweb录屏原理浅析_第6张图片

从上图可以看到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录屏原理浅析_第7张图片

dom序列化

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

截一段官网的文档说明:

rrweb录屏原理浅析_第8张图片

针对序列化的特殊处理,聊聊我个人的理解

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

dom的序列化是在Snapshot方法中完成的:

rrweb录屏原理浅析_第9张图片

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

rrweb录屏原理浅析_第10张图片
可以看到在增量 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录屏原理浅析_第11张图片

学习到了CSSStyleSheet 这个操作css的api。

  • 元素节点
  • 调用transformAttribute方法  把标签上的属性转换为绝对路径。
  • 将link 引入的远程样式,转换为inlineStylesheet。

rrweb录屏原理浅析_第12张图片

  1. 表单元素处理。input, textarea, select 等元素,记录它们的value 或者 checked;option元素记录selected属性。
  2. 占位元素。只需记录dom元素的宽高,重绘时用空div占位。

最后处理完成得到下图中的结构,如document._sn属性

rrweb录屏原理浅析_第13张图片

监听Dom变化

通过MutationObserver可监听dom元素的变化,通过批量异步的方式去触发回调,并将 DOM 变化通过 MutationRecord 数组传给回调方法。

image.png

  • 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录屏原理浅析_第14张图片

总结起来:

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

rrweb录屏原理浅析_第15张图片

双向链表的维护逻辑

遍历this.addset中的节点,依次加入链表

  • 若 DOM 节点的 previousSibling 已存在于链表中,则插入在 node.previousSibling 节点后
  • 若 DOM 节点的 nextSibling 已存在于链表中,则插入在 node.nextSibling 节点前;都不在,则插入链表的头部。
    通过这种添加方式,可以保证兄弟节点的顺序,DOM 节点的 nextSibling 一定会在该节点的后面,previousSibling 一定在该节点的前面;addedSet 中的节点全部添加到链表后,会对 addList 链表进行倒序遍历,这样可以保证 DOM 节点的 nextSibling 一定是在 DOM 节点之前被序列化,下次序列化 DOM 节点的时候,就可以拿到 nextId。

rrweb录屏原理浅析_第16张图片

节点处理流程

rrweb录屏原理浅析_第17张图片

回放(replay)

通过 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录屏原理浅析_第18张图片

自定义计时器

回放的过程中为了支持进度条的随意拖拽,以及回放速度的设置(如上图所示),自定义实现了高精度计时器 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录屏原理浅析_第19张图片

总结

本文主要介绍了rrweb的调试方法和rrweb的工作流程,希望可以帮到各位倔友哈!

参考
rrweb 带你还原问题现场

你可能感兴趣的:(前端监控)