如何使用 rrweb 进行前端录屏

6,579 阅读12分钟

一、业务背景

系统在 VX.XX.X 版本提供了 webshell 的能力,但为了进一步防止恶意操作,所以我们需要实现“录制”用户的操作的能力。

咱可以提前通过一张 GIF 动图来看看最终实现的效果。

record-2

在系统设置模块中,打开终端视频回放模块,在这里存储着所有用户对于 webshell 的操作界面记录,并且我们可以发现,录制所呈现出来的效果(清晰度,用户输入,以及鼠标运行轨迹)都是非常不错的,具体实现方案,且看下文。

二、方案选型标准

  1. 能够完整录制用户操作行为
  2. 跨平台使用
  3. 用户对“录制”操作无感知
  4. 录制可回放,方便存储

三、方案选型

根据上述的方案选型标准,从众多的技术选型中,选择了两个较为合适的技术框架进行调研,分别为 webRTCrrweb

前者为 Google 开源的,该技术适用于所有现代浏览器以及所有主要平台的原生客户端。WebRTC 背后的技术是作为开放式网络标准实现的,并且可用作所有主要浏览器中的常规 JavaScript API

后者为 Yuyz0112github 上的开源项目,目前已有 10.9k 的 star , 是一个广受用户好评的成熟项目。

使用 WebRTC

WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC 包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。

使用方式

  1. 在页面上放置几个按钮。
<body>
  <button id="start">屏幕分享</button>
  <button id="record">开始录制</button>
  <button id="stop">结束录制</button>
  <button id="download">下载视频</button>
  <video autoplay playsinline id="player"></video>
  <script src="./index.js"></script>
</body>
  1. 点击按钮之后执行对应方法。
// 核心代码如下,具体示例可见下方 github
let buffer = [], // 存储录制视频的data数据段
  mediaRecorder, // new MediaRecorder 的实例对象
  allStream; // navigator.mediaDevices.getDisplayMedia 返回的stream

class WebRTCAction {
  start() {
    // ...
    navigator.mediaDevices
      .getDisplayMedia({
        video: true,
        audio: false,
      })
      .then(stream => {
        allStream = stream;
        document.querySelector('#player').srcObject = stream;
      })
      .catch(err => {
        console.error(err);
      });
  }
  record() {
    const options = {
      mimeType: 'video/webm;codecs=vp8',
    };
    // 判断是否是支持的mimeType格式
    if (!MediaRecorder.isTypeSupported(options.mimeType)) {
      console.error('不支持的视频格式');
      return;
    }
    try {
      mediaRecorder = new MediaRecorder(allStream, options);
      // 处理采集到的事件
      mediaRecorder.ondataavailable = function(e) {
        if (e && e.data && e.data.size > 0) {
          // 存储到数组中
          buffer.push(e.data);
        }
      };
      // 开始录制
      mediaRecorder.start(10);
    } catch (e) {
      console.error(e);
    }
  }
  download() {
    mediaRecorder.stop();
    // ...
  }
  stop() {
    //...
  }
}

const webAction = new WebRTCAction();

实现效果预览

record-1

整体代码见 github: webRTC-demo

简析原理

webRTC 点对点通信流程图

webRTC流程图

  1. PeerA 与 PeerB 通过信令服务器进行媒体协商,如双方使用的音视频编码格式。双方交换的媒体数据由 SDP 协议描述。
  2. PeerA 与 PeerB 通过 STUN 服务器获取到各自自己的网络信息,如 IP 和端口。然后通过信令服务器转发互相交换各种的网络信息。这样双方就知道对方的 IP 和端口了,即 P2P 打洞成功建立直连。这个过程涉及到 NAT 及 ICE 协议,具体后面会详细描述。
  3. PeerA 与 PeerB 如果没有建立起直连,则通过 TURN 中转服务器转发音视频数据,最终完成音视频通话。

优缺点

优点

  • 使用简单,API 较为简单,可将录屏获得的数据转为视频资源,进行 OSS 直传。
  • 跨平台(Web、Windows、MacOS、Linux、iOS、Android)

不足之处

  • 用户可操作性太大,且是由用户进行录屏窗口的选择,无法做到用户无感知录制,录制之后为视频资源,占据比较大的存储空间。

使用 rrweb

rrweb 是 'record and replay the web' 的简写,旨在利用现代浏览器所提供的强大 API 录制并回放任意 web 界面中的用户操作。

使用方式

  1. 通过 yarn 下载 rrweb
yarn add rrweb -S
  1. 通过 yarn 下载 rrweb-player
yarn add rrweb-player -S

下面是官网上提供的最简单实践方式。

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

对于 webshell 的录屏方式,我们需要配置 rrweb 使其支持 canvas 的录制,并且为了使其更易用,我们需要在录制时做到将数据进行压缩并且进行分片上传。

let timer: NodeJS.Timeout | null = null;
const recodeEvents = useRef([]);
import { zip } from 'js-zip';

/**
 *  开始录屏,并存储 events 数据,开启 canvas 录制、允许下载 canvas 中的字体文件
 *
 */
const startRecord = () => {
  stopFn = rrweb.record({
    emit(event) {
      recodeEvents.current.push(event);
    },
    recordCanvas: true,
    collectFonts: true,
  });

  timer = setInterval(saveRecord, 3 * 1000);
};
/**
 *  压缩 events 数据,并上传至后端
 *
 */
const saveRecord = async () => {
  if (timer) {
    let events = recodeEvents.current;
    recodeEvents.current = [];
    let video_data = zip(JSON.stringify({ events }));
    video_data = video_data.toString();
    const param = {
      id: currentVideoId,
      video_data,
    };
    await YOURUPLOADFUNCTION(param);
  }
};

实现效果预览

record-8

整体代码见 github: rrweb-demo

简析原理

rrweb 录制视频流程图

录制部分

img

当用户打开 webshell 界面之后,前端逻辑代码会自动开始“录制”当前界面,用户是无感知的,并执行轮询上传数据的接口,为了保证数据的实时性,程序中采用的是轮询的方式,如果采用当关闭 webshell 之后,一次性上传所有的录制数据,会导致上传数据量过大,如果该接口请求失败了,那么整段数据将不复存在。并且还采用了 gzip 算法进行数据的压缩,保证不存在大数据量的 http 传输。

回放部分

img

回放相对来说简单一些,由于我们上传的录制数据是经过 gzip 算法压缩过的,所以数据量会特别的小,我们采用一次性请求全部的 JSON 数据段,再进行 gzip 的解压缩,再将播放器界面渲染出来即可完成播放的一系列操作。

优缺点

优点: 是业界方案,github star 数量较多,值得信赖,能够实现用户无感知录屏,并且可以很方便的实现用户操作的复现。

不足之处: 对于特殊场景的支持不够友好,比如对于存在 pdf 的场景下,就会表现的十分卡顿。

最终实践方案

标准webRTCrrweb
完整录制屏幕
跨平台使用
用户对“录制”操作无感知×
录制可回放,方便存储×

综上所述,鉴于 rrweb 的优秀录制视频还原能力,以及实现用户无感知录制能力,且不存在录制 pdf 的场景,避免了目前使用 rrweb 会造成卡顿的场景 ,最终实现方案选择了 rrweb

四、rrweb 原理详解

技术流程图

rrweb 主要由三部分组成 rrwebrrweb-snapshotrrweb-player

录制原理

录制原理

rrweb 在录制时会首先进行首屏 DOM 快照,遍历整个页面的 DOM Tree 并通过 nodeType 映射转换为 JSON 结构数据,同时针对增量改变的数据同步转换为 JSON 数据进行存储。整个录制的过程会生成 unique id 来确定增量数据所对应的 DOM 节点,通过 timestamp 保证回放顺序。

全屏快照原理图

对于首屏快照后的增量数据更新,则是通过 mutationObserver 获取 DOM 增量变化,通过全局事件监听、事件(属性)代理的方式进行方法(属性)劫持,并将劫持到的增量变化数据存入 JSON 数据中。

增量更新

回放原理

回放原理图

首先针对首屏 DOM 快照进行重建,在遍历 JSON 产物的同时通过自定义 type 映射到不同的节点构建方法,重建首屏的 DOM 结构。

增量回放

DOM 快照

实际上, ⻚⾯中的视图状态可以通过 DOM 树的形式描述,所以当我们尝试录制⼀个⻚⾯时,我们可以记录 DOM 树在各个时间点上的状态。 记录每一时刻页面的 DOM 状态,回放的时候根据时间点显示即可。

简单实现一个记录当前状态。

// 克隆当前的 document 元素
const docEl = document.documentElement.cloneNode(true);
// 替换
document.replaceChild(docEl, document.documentElement);

我们通过上述方式获取到的是当前 DOM 的状态。

但是,每一时刻都记录全量数据会导致数据量过于大,不便于存储。

于是,rrweb 采用的方式是通过 DOM 快照。

记录初始页面的 DOM 状态,或者特定某个时刻的 DOM 状态, 后续收集的是不同时间点的操作指令 或者 某个时刻 某个 DOM 的变化作为一个增量快照, 在原先快照的基础上,不断加入根据行为解析的 DOM 数据,构建了后续的快照,减少大量数据的存储或传输。

我们获取到的 DOM 快照是一个 DOM 节点数据,并不是可序列化的,我们不能将其转化为可方便传输的数据,也就无法上传到服务器,无法实现远程录制的功能。

rrweb-snapshot

rrweb 中,是通过 rrweb-snapshot 来实现上述功能的。

rrweb-snapshot 包含 两部分

  1. snapshot: 将 DOM 及其状态转化为可序列化的数据结构并添加唯一标识
  2. rebuild: 将 snapshot 记录的数据结构重建为对应的 DOM
snapshot 实现细节
  1. 构建页面 DOM 树,为每一个 Node 节点都绑定了一个唯一 id , 这个映射只要是为了方便后续的增量快照操作。

  2. hrefsrcCSS 中的相对路径设为绝对路径 将一些脚本,样式,图片等引用的相对路径改为绝对路径。

  3. 将页面引用的样式变为内联样式,以确保可以使用本地样式 将页面引用的样式读取变为内联样式。

  4. 将一些 DOM 状态内联到 HTML 属性中,例如 HTMLInputElement 的值 记录没有反映在 HTML 中的视图状态。例如 输⼊后的值不会反映在其 HTML 中,我们需要读取其 value 值并加以记录。

  5. script 标记转换为 noscript 标记,以避免脚本被执行 在播放录制页面时,页面的脚本是不能够被执行的,需要禁掉。

rebuild 实现细节
  1. 通过创建 DOM , 设置属性,将 snapshot 生成的数据再转化成对应的 DOM 插入文档中。

上面我们将需要生成的数据已经处理好了,那么接下来就要处理录制的问题了。

rrweb

我们上面有说过是通过增量快照的形式来进行记录数据的一个变化的。在 rrweb 中 是通过 rrweb 仓库来实现的,包含两部分 record, 和 replay

  1. record: 用于记录 DOM 中的所有变更(mutation)
  2. replay: 则是将记录的变更按照对应的时间一一重放
record 实现细节
  1. record 会监听用户的行为来记录相关的数据。
  • DOM 变动
    • 节点创建、销毁
    • 节点属性变化
    • 文本变化
  • 鼠标移动
  • 鼠标交互
    • mouse up、mouse down
    • click、double click、context menu
    • focus、blur
    • touch start、touch move、touch end
  • 页面或元素滚动
  • 视窗大小改变
  • 输入
MutationObserver

其中监听 DOM 变化使用的 API 为 MutationObserver

MutationObserver接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。

当监视的 DOM 发生变动时, MutationObserver 将收到通知并触发预先设定好的回调参数,与 addEventListener 方法 比较相似。

当我们尝试改变页面 DOM 的属性,或者新增 DOM 节点的时候,都会对应生成一条 mutationObserver record, record 记录了一些变动信息。 在 rrweb 中, 对每一条 mutation record做了以下处理。

private processMutation = (m: mutationRecord) => {
    //  首先判断是否为忽略的 DOM 节点
    if (isIgnored(m.target)) {
      return;
    }
    // 判断节点类型
    switch (m.type) {
        // ...
      case 'attributes': {
        const target = m.target as HTMLElement;
        let value = (m.target as HTMLElement).getAttribute(m.attributeName!);
        // 对 input 标签中的 value 属性进行处理
        if (m.attributeName === 'value') {
          value = maskInputValue({
            maskInputOptions: this.maskInputOptions,
            tagName: (m.target as HTMLElement).tagName,
            type: (m.target as HTMLElement).getAttribute('type'),
            value,
            maskInputFn: this.maskInputFn,
          });
        }
        // 判断是否为不需要监听变化的 DOM 节点
        if (isBlocked(m.target, this.blockClass) || value === m.oldValue) {
          return;
        }
        // ...
        break;
      }
    // ...
    }
  };

  public processMutations = (mutations: mutationRecord[]) => {
    mutations.forEach(this.processMutation);
    this.emit();
  };

针对不同的类型进行处理, characterData 是节点内容或节点文本变动,attributes 是节点属性的变动,childList 是子节点的变动,包括新增子节点,移除子节点,移动子节点等。

addEventListeners

其中监听 鼠标移动,鼠标交互,页面滚动,视窗大小 使用的 API 为 事件绑定

// 实现 addEventListeners 逻辑的核心代码
export function on(
    type: string,
    fn: EventListenerOrEventListenerObject,
    target: Document | IWindow = document,
  ): listenerHandler {
    const options = { capture: true, passive: true };
    target.addEventListener(type, fn, options);
    return () => target.removeEventListener(type, fn, options);
  }

  // 监听 页面滚动 的核心代码
  export function initScrollObserver(
    cb: scrollCallback,
    doc: Document,
    mirror: Mirror,
    blockClass: blockClass,
    sampling: SamplingStrategy,
  ): listenerHandler {
    const updatePosition = throttle<UIEvent>((evt) => {
      const target = getEventTarget(evt);
      if (!target || isBlocked(target as Node, blockClass)) {
        return;
      }
      const id = mirror.getId(target as INode);
      // ...
    }, sampling.scroll || 100);
    return on('scroll', updatePosition, doc);
  }


replay 实现细节
  1. 解析收集到的 events 集合。
  2. 当事件类型为 FullSnapshot 时,会调用 rebuild, 根据快照数据生成页面的 DOM, 当事件类型为 IncrementalSnapshot 时,则说明是增量快照,即收集的数据只是 DOM 的变化数据或者对应的用户行为数据,根据不同的数据类型做对应的节点插入,删除,节点属性的更改等。
  const firstFullsnapshot = this.service.state.context.events.find(
      (e) => e.type === EventType.FullSnapshot,
    );

 if (firstFullsnapshot) {
      setTimeout(() => {
        // 判断是否为 FullSnapshot
        if (this.firstFullSnapshot) {
          return;
        }
        this.firstFullSnapshot = firstFullsnapshot;
        this.rebuildFullSnapshot(
          firstFullsnapshot as fullSnapshotEvent & { timestamp: number },
        );
        this.iframe.contentWindow!.scrollTo(
          (firstFullsnapshot as fullSnapshotEvent).data.initialOffset,
        );
      }, 1);
    }

private getCastFn(event: eventWithTime, isSync = false) {
    // 判断 type 类型 是否为 IncrementalSnapshot
   case EventType.IncrementalSnapshot:
           castFn = () => {
             // 调用 applyIncremental 函数
             this.applyIncremental(event, isSync);
             if (isSync) {
               return;
             }
           };
           break;
         default:

       //...
}

// applyIncremental 函数具体实现
// 其中 incrementalSnapshotEvent 代表增量数据,其具体增量类型可以通过 `event.data.source` 字段进行判断:
private applyIncremental(
    e: incrementalSnapshotEvent & { timestamp: number; delay?: number },
    isSync: boolean,
  ) {
    const { data: d } = e;
    // 判断增量类型
    switch (d.source) {
      // ...
      case IncrementalSource.Mutation: {
        if (isSync) {
          d.adds.forEach((m) => this.treeIndex.add(m));
          d.texts.forEach((m) => this.treeIndex.text(m));
          d.attributes.forEach((m) => this.treeIndex.attribute(m));
          d.removes.forEach((m) => this.treeIndex.remove(m, this.mirror));
        }
        try {
          this.applyMutation(d, isSync);
        } catch (error) {
          this.warn(`Exception in mutation ${error.message || error}`, d);
        }
        break;
      }
      // ...
      default:
    }
  }

五、总结

其实,前端实现录制能力可以实现很多比较实用的功能。

比如: 当你在处理线上问题的时候,由于环境的影响、用户数据和浏览器版本等等原因而不能快速复现时,那么使用 rrweb来实现一套前端监控系统就显得十分的有必要了。目前业内已经有比较合适的方案了,开源的有Sentry, 商业化的有fundebug

比如: 当你需要将用户的操作进行回溯时,那么使用rrweb等方案实现前端录制就是一个很好的选择。

...

系统使用 rrweb 来实现前端录屏操作还只是众多可应用方面的一个实践而已,所以,掌握好 WebRTCrrweb 这种类似的技术还是十分有必要的。