一直以来项目的日志功能只记录了用户的登录和接口请求操作日志,用来辅助我们定位和解决反馈的异常问题。这些日志大多数情况下并不会记录用户是如进行操作的,只记录发起请求时携带的参数信息。大多数情况下这些日志已经足够帮助我们发现和解决问题了。
但在一些非常独特的问题反馈中,我们没有办法了解到用户是如何触发这些异常请求的。还需要去主动联系用户询问当时的操作场步骤去复现,或者按照日志中的参数去猜测用户是如何操作的。
最近正在听 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
的体积了,也非常的方便。
但是我的业务比较复杂会有 dialog
和 popup
,可能会同时会有多个组件的状态变更和渲染,所以 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
数组(也就是没有记录全量快照的话),是没有办法正常回放录制的用户操作的。
如果 checkoutEveryNth
和 checkoutEveryNms
不能满足你的需求,你可能会想要在某一个特殊节点上产生一个全量快照,那么简单的实现是可以终止当前的 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
比如说:
freezePage
: Throttling/pausing of Mutation Events · Issue #221 · rrweb-io/rrwebtakeFullSnapshot
: rrweb/guide.zh_CN.md 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