前端监控:回放录制库 rrweb

前端监控:回放录制库 rrweb_第1张图片

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

前端监控:回放录制库 rrweb_第2张图片

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 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 参数:

  • 回放的总时长 = events[n].timestamp - events[0].timestamp,n 为事件队列总长度减一;
  • 时间轴的总时长为回放的总时长,鼠标拖拽的起始位置对应时间轴上的坐标为timeOffset
  • 根据初始事件的 timestamptimeOffset 计算出拖拽后的 基线时间戳(baselineTime)
  • 再从所有的事件队列中根据事件的 timestamp 截取 基线时间戳(baselineTime) 后的事件队列,即需要回放的事件队列。

前端监控:回放录制库 rrweb_第3张图片

 拿到事件队列后,需要遍历事件队列,根据事件类型转换为对应的回放 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 文件:

再通过以下 JS 代码初始化 replayer:

/** 获取上传给后端的事件 */
const events = YOUR_EVENTS;

const replayer = new rrweb.Replayer(events);

/** 播放 */
replayer.play();

/** 从第 3 秒的内容开始播放 */
replayer.play(3000);

/** 暂停 */
replayer.pause();

/** 暂停至第 5 秒处 */
replayer.pause(5000);

你可能感兴趣的:(前端,前端监控,录制与回放)