rrweb录制用户行为助你排忧解难!

文摘   2024-08-27 19:15   吉林  

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

使用方式:

yarn add rrweb -S
yarn add rrweb-player -S

vue3r如何使用

<div class="p-4 bg-gray-50 min-h-screen">
  <el-dialog v-model="isPlaying" title="视频录制" width="54%" class="relative h-5/6">
    <div v-if="isPlaying" id="replayer" ref="replayer" class="w-1/2 m-auto mt-20 absolute top-0 left-0"></div>
  </el-dialog>

  <div class="w-1/2 border-1 m-auto mt-60 text-center">
    <el-button type="primary" @click="handleRecord">开始录制</el-button>
    <el-button type="primary" @click="handleStop">结束录制</el-button>
    <el-button type="primary" @click="handleReplayer">回放</el-button>
  </div>
  <div class="w-1/2 border-1 m-auto mt-20 text-center">
    <el-input type="textarea" v-model="numValue"></el-input>
  </div>
</div>

开始录制

function handleRecord() {
    try {
        recorder.value = rrweb.record({
            // sampling: 0.5, // 每秒采样两次
            // ignore: ['input[type="password"]'], // 忽略密码输入框的变化
            // maxMissedFrames: 10, // 在性能问题时最多跳过10帧
            // checkDOMChange: false, // 禁用DOM变化检查
            // blockClass: 'do-not-record', // 不录制拥有该类名的元素内容
            emit(event) {
                // 在这里处理记录的事件,例如发送到服务器
                console.log(event, 'event');
                events.value.push(event);
                // if (events.value.length > 5) {
                //     // 当事件数量大于 100 时停止录制
                //     recorder.value();
                // }
                // 发送到服务器的代码
                // fetch('/api/log', {
                //   method: 'POST',
                //   body: JSON.stringify(event),
                //   headers: {
                //     'Content-Type': 'application/json',
                //   },
                // });
            },
            // 可以配置其他选项,如采样率、忽略某些事件等
        });
    } catch (error) {
        console.error('Error creating recorder instance:', error);
    }
}

结束录制

function handleStop() {
    if (!recorder.value) {
        console.error('记录器未初始化。请先开始录制。');
        return;
    }
    console.log('events:', events.value);
    recorder.value();
}

回放

function handleReplayer() {
    if (!recorder.value) return console.error('记录器未初始化。请先开始录制。');
    recorder.value();
    if (!events.value.length) return console.error('记录器未有数据。');
    isPlaying.value = true;
    setTimeout(() => {
        replayInstance.value = new rrwebPlayer({
            target: replayer.value, // 可以自定义 DOM 元素
            // 配置项
            props: {
                events: events.value,
                skipInactivefalse//是否快速跳过无用户操作的阶段
                showDebugfalse//是否在回放过程中打印 debug 信息
                showWarningfalse//是否在回放过程中打印警告信息
                autoPlaytrue//是否自动播放
                showControlletrue,  //是否显示播放器控制 UI
                speedOption: [1248//倍速播放可选值
            },
        });
        replayInstance.value.addEventListener("finish", (payload) => {
            console.log(payload, 2222);
        })
        // replayInstance.play()
    }, 100);
}

实现效果预览

rrweb 原理详解

技术流程图

rrweb 主要由三部分组成 rrweb、rrweb-snapshot、rrweb-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:包含 snapshot 和 rebuild 两个功能。 snapshot 用于将 DOM 及其状态转化为可序列化的数据结构并添加唯一标识; rebuild 则是将 snapshot 记录的数据结构重建为对应的 DOM,并插入文档中

rrweb:包含 record 和 replay 两个功能。 record 用于记录 DOM 中的所有变更(mutation); replay 则是将记录的变更按照对应的时间一一重放。

rrweb-player:为 rrweb 提供一套 UI 控件,提供基于图形用户界面的暂停、快进、拖拽至任意时间点播放等功能。

rrdom:为node平台mock浏览器的dom,event等api

rrweb-snapshot

在 rrweb 中,是通过 rrweb-snapshot 来实现上述功能的。 rrweb-snapshot 包含 两部分

snapshot: 将 DOM 及其状态转化为可序列化的数据结构并添加唯一标识 rebuild: 将 snapshot 记录的数据结构重建为对应的 DOM。

snapshot 实现细节

1.构建页面 DOM 树,为每一个 Node 节点都绑定了一个唯一 id , 这个映射只要是为了方便后续的增量快照操作。

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

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

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

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

rebuild 实现细节

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

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

rrweb

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

record: 用于记录 DOM 中的所有变更(mutation)

replay: 则是将记录的变更按照对应的时间一一重放

record 实现细节

record 会监听用户的行为来记录相关的数据。

declare function record<T = eventWithTime>(options?: recordOptions<T>): listenerHandler | undefined;

/**
 * ck
 * 定义一个泛型类型 `recordOptions<T>`,用于配置录制选项
 * @template T - 事件类型
 */

export declare type recordOptions<T> = {
    /**
     * 可选的回调函数,在录制事件发出时调用。
     * @param {Te - 要发出的事件。
     * @param {booleanisCheckout - 指示事件是否是检查点事件。
     * 默认值:未定义
     */

    emit?: (e: T, isCheckout?: boolean) => void;

    /**
     * 可选的数字,指定每隔多少个事件进行一次检查点。
     * 默认值:未定义
     */

    checkoutEveryNth?: number;

    /**
     * 可选的数字,指定每隔多少毫秒进行一次检查点。
     * 默认值:未定义
     */

    checkoutEveryNms?: number;

    /**
     * 可选的 `blockClass` 类型,用于定义要阻止的类。
     * 默认值:未定义
     */

    blockClass?: blockClass;

    /**
     * 可选的字符串,用于定义要阻止的 CSS 选择器。
     * 默认值:未定义
     */

    blockSelector?: string;

    /**
     * 可选的字符串,用于定义要忽略的类。
     * 默认值:未定义
     */

    ignoreClass?: string;

    /**
     * 可选的 `maskTextClass` 类型,用于定义要掩码的文本类。
     * 默认值:未定义
     */

    maskTextClass?: maskTextClass;

    /**
     * 可选的字符串,用于定义要掩码的文本的 CSS 选择器。
     * 默认值:未定义
     */

    maskTextSelector?: string;

    /**
     * 可选的布尔值,指示是否要掩码所有输入框。
     * 默认值:未定义
     */

    maskAllInputs?: boolean;

    /**
     * 可选的 `MaskInputOptions` 类型,用于定义掩码输入的选项。
     * 默认值:未定义
     */

    maskInputOptions?: MaskInputOptions;

    /**
     * 可选的函数,用于自定义输入框的掩码处理逻辑。
     * 默认值:未定义
     */

    maskInputFn?: MaskInputFn;

    /**
     * 可选的函数,用于自定义文本的掩码处理逻辑。
     * 默认值:未定义
     */

    maskTextFn?: MaskTextFn;

    /**
     * 可选的 `SlimDOMOptions` 类型,用于定义简化 DOM 的选项。
     * 默认值:未定义
     */

    slimDOMOptions?: SlimDOMOptions | 'all' | true;

    /**
     * 可选的 `Set<string>` 类型,用于定义要忽略的 CSS 属性。
     * 默认值:未定义
     */

    ignoreCSSAttributes?: Set<string>;

    /**
     * 可选的布尔值,指示是否内联样式表。
     * 默认值:未定义
     */

    inlineStylesheet?: boolean;

    /**
     * 可选的 `hooksParam` 类型,用于定义钩子函数。
     * 默认值:未定义
     */

    hooks?: hooksParam;

    /**
     * 可选的函数,用于自定义打包逻辑。
     * 默认值:未定义
     */

    packFn?: PackFn;

    /**
     * 可选的 `SamplingStrategy` 类型,用于定义采样策略。
     * 默认值:未定义
     */

    sampling?: SamplingStrategy;

    /**
     * 可选的 `DataURLOptions` 类型,用于定义数据 URL 的选项。
     * 默认值:未定义
     */

    dataURLOptions?: DataURLOptions;

    /**
     * 可选的布尔值,指示是否录制 canvas。
     * 默认值:未定义
     */

    recordCanvas?: boolean;

    /**
     * 可选的布尔值,指示是否录制跨源 iframe。
     * 默认值:未定义
     */

    recordCrossOriginIframes?: boolean;

    /**
     * 可选的布尔值,指示是否在用户输入时触发。
     * 默认值:未定义
     */

    userTriggeredOnInput?: boolean;

    /**
     * 可选的布尔值,指示是否收集字体。
     * 默认值:未定义
     */

    collectFonts?: boolean;

    /**
     * 可选的布尔值,指示是否内联图像。
     * 默认值:未定义
     */

    inlineImages?: boolean;

    /**
     * 可选的 `RecordPlugin` 数组,用于定义插件。
     * 默认值:未定义
     */

    plugins?: RecordPlugin[];

    /**
     * 可选的数字,用于定义鼠标移动事件的等待时间(毫秒)。
     * 默认值:未定义
     */

    mousemoveWait?: number;

    /**
     * 可选的函数,用于自定义是否保留 iframe 的 src 属性。
     * 默认值:未定义
     */

    keepIframeSrcFn?: KeepIframeSrcFn;
};

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({
            maskInputOptionsthis.maskInputOptions,
            tagName: (m.target as HTMLElement).tagName,
            type: (m.target as HTMLElement).getAttribute('type'),
            value,
            maskInputFnthis.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(
    typestring,
    fn: EventListenerOrEventListenerObject,
    target: Document | IWindow = document,
  ): listenerHandler {
    const options = { capturetruepassivetrue };
    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 实现细节

解析收集到的 events 集合。

当事件类型为 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 & { timestampnumber },
    );
    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来实现一套前端监控系统就显得十分的有必要了

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

我希望这篇文章对您有所帮助。

Thank you for reading.

推荐阅读

全栈开发ck
叩首问路,码梦为生