使用 rrweb 录制和回放用户交互操作

一直以来项目的日志功能只记录了用户的登录和接口请求操作日志,用来辅助我们定位和解决反馈的异常问题。这些日志大多数情况下并不会记录用户是如进行操作的,只记录发起请求时携带的参数信息。大多数情况下这些日志已经足够帮助我们发现和解决问题了。
但在一些非常独特的问题反馈中,我们没有办法了解到用户是如何触发这些异常请求的。还需要去主动联系用户询问当时的操作场步骤去复现,或者按照日志中的参数去猜测用户是如何操作的。

最近正在听 Web Worker 这档播客节目,有一期节目邀请到了 Aryu 大佬,就发现了 rrweb 这个项目。有兴趣的话可以从项目的主页中在线尝试录制和回放功能,预设了3个业务场景的录制体验Demo。

可以看到录制效果非常不错。能录制到鼠标轨迹、滚轮操作、各种元素的聚焦、下拉和光标位置等各种各样的操作记录。

  • 但因为是DOM级别的录制,所以浏览器原生提供的功能,并不会被录制到。比如说 <select> 元素提供的下拉操作和 <input type="file"> 提供的文件选择操作。
  • v1.0.0 开始以插件的形式增加了控制台录制的功能。

而且使用起来非常简单,只需要引入和执行 record 方法就可以启动录制了,然后按照自己的需求去存储即可。

import { record } from 'rrweb'

let events = [];
record({
  emit(event) {
    // 将用户操作的 event 存入 events 数组中
    events.push(event);
  },
});

💥 不过!

我们在实际使用过程中会发现 rrweb 的录制量会大的超出我们的预期很多。比如我现在的项目录制简单快速的完整操作下来记录的的 eventList 可能就会超过 3M ,就有点太大了。
所以可以文档中关于 优化存储容量 的一些针对性优化。

其实简单的开启压缩之后就能将录制的 eventList 压缩到原本大概 1/4 的体积了,也非常的方便。

但是我的业务比较复杂会有 dialogpopup ,可能会同时会有多个组件的状态变更和渲染,所以 rrweb 录制时可能会出现顺序错位的情况,比如说 loading-mask 没有被关闭或者出现多个同样的 dialog。导致没办法很好的回放录制内容。

所以配合了 blockSelector 属性,配置了一些不需要被录制的元素选择器。

注意:在文章发布的时间节点上使用 npm i rrweb 安装的版本是 rrweb@2.0.0-alpha.4。这个版本中使用 blockSelector 是有BUG的,请确认安装的版本是否为 2.0.0-alpha.5 及以上的版本。


❗ 还是觉得录制结果太大了

如果你想要继续缩小录制结果。比如说我现在的业务场景,其实只需要在业务出现异常时录制前几个操作就好了,所以可以使用 checkoutEveryNth 或者 checkoutEveryNms 来定期重新制作一次全量快照。然后创建一个新的数组去存储新的 eventList

比如说文档中的示例:

// 使用二维数组来存放多个 event 数组
const eventsMatrix = [[]];

rrweb.record({
  emit(event, isCheckout) {
    // isCheckout 是一个标识,告诉你重新制作了快照
    if (isCheckout) {
      eventsMatrix.push([]);
    }
    const lastEvents = eventsMatrix[eventsMatrix.length - 1];
    lastEvents.push(event);
  },
  checkoutEveryNms: 5 * 60 * 1000, // 每5分钟重新制作快照
});

// 向后端传送最新的两个 event 数组
window.onerror = function () {
  const len = eventsMatrix.length;
  const events = eventsMatrix[len - 2].concat(eventsMatrix[len - 1]);
  const body = JSON.stringify({ events });
  fetch('http://YOUR_BACKEND_API', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body,
  });
};

分片的时候需要注意的是不能直接截取我们记录的 eventList 数组,需要等 isCheckout 这个标识返回为 true 时去分片。因为我们录制的 event 时基于全量快照的变更产生的记录。所以如果你手动剪裁 eventList 数组(也就是没有记录全量快照的话),是没有办法正常回放录制的用户操作的。

如果 checkoutEveryNthcheckoutEveryNms 不能满足你的需求,你可能会想要在某一个特殊节点上产生一个全量快照,那么简单的实现是可以终止当前的 rrweb 实例的录制,然后重新开启录制。


💼 实际业务场景中的使用

因为我只需要在用户进行表单编辑时开始录制,并且只需要记录用户端异常出现的前 30s 的操作即可。所以我会把 rrweb 的录制相关的操作放到 Vuex 之类状态管理库中维护。然后在通用的 onDialog 事件中去调用对应的 action 开启和结束录制。

以下是一个简单的实现思路会有一些边界情况没有覆盖:

// /store/modules/app.js
import { record, pack } from 'rrweb'

// 因为不需要监听改变,所以直接在外部声明 rrweb 相关变量
// rrweb 录制器
let rrwebRecorder = () => {}
// rrweb 事件集合
let rrwebEventRecords = []

const state = {
  // ...
}

const mutations = {
  // ...
}

const actions = {
  // 开启 RRWEB 录制
  setRRWebStart() {
    rrwebEventRecords = []
    rrwebRecorder = record({
      emit(event, isCheckout) {
        // 如果重新制作了快照,清空 rrwebEventRecords
        if (isCheckout) rrwebEventRecords = []
        rrwebEventRecords.push(event)
      },
      packFn: pack, // 开启压缩
      checkoutEveryNms: 30 * 1000, // 每 30 秒重新制作一次全量快照
      blockSelector: '.navbar, .sidebar', // 忽略 .navbar 和 。sidebar 元素的录制
      sampling: {
        mousemove: false, // 不录制鼠标移动事件
        mouseInteraction: false, // 不录制鼠标交互事件
        scroll: 150, // 每 150ms 最多触发一次滚动交互
        media: false, // 关闭媒体录制
        input: 'last' // 连续输入时只录制最终值
      }
    })
  },
  // 停止 RRWEB 录制
  setRRWebStop() {
    rrwebRecorder()
    rrwebEventRecords = []
  },
  // 获取屏幕录制结果
  getRRWebRecordList({ dispatch }) {
    const recordJSON = JSON.stringify({ events: rrwebEventRecords })
    dispatch('setRRWebStop')
    return Promise.resolve(recordJSON)
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

通用的弹窗控制函数,比如说 Vue2.x 经常会使用的 Mixins 方式

// /mixins.js
export default {
  // ...
  methods: {
    // 打开编辑弹窗
    onOpenEditDialog() {
      // ...
      this.$store.dispatch('app/setRRWebStart')
    },
    // 编辑弹窗关闭时触发
    handleEditDialogClose() {
      // ...
      this.$store.dispatch('app/setRRWebStop')
    }
  }
}

封装好的请求拦截器中:

import axios from 'axios'
import { Message } from 'element-ui'
import store from '@/store'

import { uploadClientEventRecord } from '@/api/system'

const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 30000
})

service.interceptors.response.use(
  success => {
    // ...
  },
  error => {
    console.log('err' + error)
    const { message } = error
    Message({
      message: message,
      type: 'error',
      duration: 5 * 1000
    })
    store.dispatch('app/getRRWebRecordList').then(events => {
      uploadClientEventRecord({ ...error, events })
    })
    return Promise.reject(error)
  }
)

其他

当然如果你不嫌麻烦,还是可以看 rrweb 暴露出来的的一些函数,有一部分是没有在文档中体现的
👉 rrweb/packages/rrweb/src/index.ts at master · rrweb-io/rrweb

比如说:

都是一些非常实用的内部函数,避免自己重复造轮子。


相关资源

rrweb-io/rrweb: record and replay the web
No.47 和 rrweb 作者 aryu 聊从开源到技术商业化、低代码和 AI、职场软技能 - Web Worker | 小宇宙

Fix: isBlocked throws on invalid element by dbseel · Pull Request #1032 · rrweb-io/rrweb
How to rebuild snapshot based on recorded events? · Issue #1153 · rrweb-io/rrweb

前端技术分享:页面性能优化问题复盘 - 有道技术团队 - SegmentFault 思否