rrweb 浏览器录制及转视频方案
背景
最近在做保险相关的项目,由于医保局的监管要求,用户购买保险的流程必须可以回溯。这样在用户与保险公司之间产生纠纷时,就可以有迹可循。
比如用户说,当时为自己和妻子二人投了保,但是保险公司后台只有一笔订单,这时如果只是把后台数据给用户看,用户肯定不会信服。
最好的手段就是把用户投保的具体操作过程录制成视频,在发生纠纷时,直接以视频为证,这样才更有说服力。
DOM 快照
当我们想要查看用户在投保过程中某一时刻的页面状态时,我们只需要将那一刻的页面 dom 结构,以及页面中的 css 样式记录下来,然后在浏览器中重新渲染出来就能达到回溯的效果了。
const cloneDoc = document.documentElement.cloneNode(true); // 录制
document.replaceChild(cloneDoc, document.documentElement); // 回放
这样我们就实现了某一时刻 DOM 快照的功能。但是这个录制的 cloneDoc 还只是内存中的一个对象,并没有实现远程录制。
序列化
为了实现远程录制,我们需要将 cloneDoc 这个对象序列化成字符串,保存到服务端,然后在回放的时候从服务器上取出来,交给浏览器重新渲染。
const serializer = new XMLSerializer(); // XMLSerializer 是浏览器自带的 api,可以将 dom 对象序列化成 string
const str = serializer.serializeToString(cloneDoc);
document.documentElement.innerHTML = str;
至此,我们就完成了对用户界面某一时刻的远程录制功能。
定时快照
但是我们的目的是录制视频,只有一个 dom 快照显然是不够的。了解动画的同学都应该知道,动画是由每秒至少 24 帧的画面按顺序播放而产生的。在这里顺便科普一下这块的知识,当我们人眼观察到一个物体之后,这个画面会在我们的视网膜中停留16.7ms左右的的时间,专业名词叫做视觉停留,那么具体到给我们的感觉就是这个画面是“渐渐”消失的。
那么当我们在播放动画的时候,当第一帧画面在我们的视网膜中刚刚消失的时候,把第二帧放出来,那么给人的感觉就是画面是连续的,是在动的。但是动画里的人物动作给人的感觉还是有点卡顿、有点不自然的,为什么呢?我们来算一下: 1秒/24帧 = 41.7毫秒,远远低于人眼可分辨的16ms的间隔,所以我们会觉得有点卡卡的。
为了达到更加流畅的画面,很多游戏和电影都会采用 60 帧/秒的速度来放映画面,因为 1秒/60帧 = 16.7ms,和人眼视觉停留的时间差不多,所以会感觉到画面很流畅。可以看一下你的电脑屏幕,一般的刷新率也是60帧。
扯远了,我们回归正题。由上面的知识我们知道,既然我们想要录制视频,那么至少每秒需要 24 帧的数据,也就是说 1000ms/24帧 = 41.7毫秒要 clone 一遍网页内容。
setInterval(() => {
const cloneDoc = document.documentElement.cloneNode(true)
const str = serializer.serializeToString(cloneDoc);
axios.post(address,str); // 保存到服务端
}, 41.7)
现在我们可以让画面动起来了,但是稍微细想便可知道这种方法根本行不通,原因有一下几点:
- 每秒 clone 24 次整个页面内容,对性能损耗巨大,严重影响用户体验
- 每秒要将 24 帧的页面内容上传到服务端,对网络开销也是巨大的
- 回放时,每秒要渲染 24 个完整的 html 内容,浏览器根本做不到这么快
- 还有,要是页面没变动,那么 24 帧的数据可能是完全一样的,根本没必要clone 这个多次。
增量快照
基于以上定时快照的缺点,其实我们可以只在页面初始化完成之后 clone 一次完整的页面内容,等到页面有变动的时候,只记录变化的部分。这样一来,好处就显而易见了:
- 只记录变化的部分,比起记录整个网页要小的多。这样对网页的性能、网络的开销都会小很多。
- 我们只在页面有变动的时候才记录,这样一来,大量重复数据的问题也给解决了。
- 回放时,我们只需要首先将第一帧(完整的页面内容)先渲染出来,然后在按照记录的时间,按顺序将变化的部分渲染到页面。这样就可以像看视频一样来回溯用户的操作流程了。
举个例子,如上图所示,页面中一共有 4 个 div。页面有两次变化,第一次 dom2 变成了红色,第二次变化 dom4 变成了绿色。那么我们记录的数据大致是这个样子:
var events = [
{完整的 html 内容},
{
id: 'dom2',
type: '#fff -> red'
},
{
id: 'dom4',
type: '#fff -> green'
}
]
记录的数据是一个数组,数组中有 3 个原始,第一个元素是完整的 html 内容,第二个元素描述的是 dom2 变成了红色,第三个元素描述的是 dom4 变成了绿色。
然后我们根据上诉记录的数据,就可以首先将 events[0] 渲染出来,然后执行 events[1] 将 dom2 变成红色,再将 dom4 变成绿色。
这样我们在理论上就完成了从页面的录制,到保存到远程服务器,再到最后回放,形成了功能上的完整的闭环。
MutationObserver
在上一步中,我们已经从理论上实现了录制和回放的功能。但是具体实现呢?我们怎么才能知道页面什么时候变化呢?变化了哪些东西呢?
实际上浏览器已经为我们提供了非常强大的 API,叫做 MutationObserver。它会以批量的方式返回 dom 的更新记录。
还是拿上面的例子来说明,改变一下 dom2 和 dom4 的背景色
setTimeout(() => {
let dom2 = document.getElementById("dom2");
dom2.style.background = "red";
let dom4 = document.getElementById("dom4");
dom4.style.background = "green";
}, 5000);
const callback = function (mutationsList, observer) {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
console.log("子元素增加或者删除.");
} else if (mutation.type === "attributes") {
console.log("元素属性发生改变");
}
}
};
document.addEventListener("DOMContentLoaded", function () {
const observer = new MutationObserver(callback);
observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
});
});
得到的回调数据是这样的
可以看到,MutationObserver 只记录了变化的 dom 元素(target),和变化的类型(type)。如此一来,我们便可以利用 MutationObserver 实现增量快照的思路。
可交互元素
利用 MutationObserver 我们可以记录元素的增加、删除、属性的更改,但是它没法跟踪像 input、textarea、select 这类可交互元素的输入。
对于这种可交互的元素,我们就需要通过监听 input 和 change 来记录输入的过程,这样我们就解决了用户手动输入的场景。
但是有些元素的值是通过程序直接设置的,这样是不会出发 input 和 change 事件的。这种情况下我们可以通过劫持对应属性的 setter 来达到监听的目的。
const input = document.getElementById("input");
Object.defineProperty(input, "value", {
get: function () {
console.log("获取input的值");
},
set: function (val) {
console.log("input 的值更新了");
},
});
input.value = 123;
以上就是浏览器录制和回放的大体思路,也是开源工具 rrweb(record replay web)的核心思想。当然 rrweb 中还记录了鼠标的移动轨迹、浏览器窗口的大小,增加了回放时的沙盒环境、时间校准等等,在这里不再赘述,有兴趣的同学可以自行查阅 rrweb 官网的介绍。
rrweb
以上篇幅主要介绍了 rrweb 录制和回放的核心思想,这里大致介绍一下它的使用方法。更多使用姿势请查看 rrweb 使用指南。
通过 npm 引入
npm install --save rrweb
录制
const events = []
let stopFn = rrweb.record({
emit(event) {
if (events.length > 100) {
// 当事件数量大于 100 时停止录制
stopFn();
// 将 events 序列化成字符串,并保持到服务器
}
},
});
回放
const events = []; //从服务端取出记录并反序列化成数组
const replayer = new rrweb.Replayer(events);
replayer.play();
静态资源时效问题
下面是我截取的一段录制数据
可以看到录制的数据中存在外链的图片,也就是说在我们利用录制的数据进行回放的时候,需要依赖这张图片。但是随着项目的迭代,这张图片很可能早已不在,这时我们再回放时,页面中的图片就会加载不出来。
其实不只是图片,外链的 css、字体文件等等都有这个问题。再回到文章开头提到的保险场景,保额信息就在网站内的一张海报上,客户可能会说:“我当时看到的保额明明是150万,怎么现在变成100万了?”,这时你要怎么证明当时海报上写的就是 100 万保额呢?
json 转视频
所以最稳妥的方案还是将 rrweb 录制的原始数据转换成视频,这样一来,不管网站怎么变化,迭代了多少版本,视频是不受影响的。
我的做法是通过 puppeteer 在服务端运行无头浏览器,在无头浏览器中回放录制的数据,然后每秒截取一定数量的图片,最后通过 ffmpeg 合成视频。下面是大致的流程图
帧率
我这里是一秒 50 帧,也就是说每隔 20ms 要截一张图。
截图时机
这里有个坑,puppeteer 截一张图的时间大概需要 300ms,假设页面在回放的过程中,我们使用 setInterval 每隔 20ms 执行一次截图,那么两次截图动作之间其实相隔了一次截图的时间,差了接近 300ms。第二帧我们想要截取的是视频地 20ms 的数据,可是回放页面已经播放到 320ms 处了。
暂停播放
为解决截图耗时所带来的影响,在每次截图之前,我将回放视频暂停到对应的时间点,这样截取到的就是我们想要的画面了。
updateCanvas () {
if (this.imgIndex * 20 >= this.timeLength) {
this.stopCut(); // 事先计算整个视频需要截多少帧,截满了就结束
return;
}
// 截图
this.iframe.screenshot({
type: 'png',
encoding: 'binary',
}).then(buffer => {
this.readAble.push(buffer) //保存截图数据到可读流中
this.page.evaluate((data) => {
window.chromePlayer.pause(data * 20); // 将回放页中的视频暂停到对应时间点
}, this.imgIndex)
this.updateCanvas(this.imgIndex++)
})
}
输出视频
stopCut () {
this.readAble.push(null) // 截图完成后,需要给可读流一个 null,表示没有数据了
this.ffmpeg
.videoCodec('mpeg4') // 视频格式,这里我输出的是 mp4
.videoBitrate('1000k') // 每秒钟视频所占用的大小,这个是视频清晰度的关键指标
.inputFPS(50) // 帧率,这个是视频流畅度的关键指标,需要和每秒截图的数量保持一致
.on('end', () => {
console.log('\n视频转换成功')
})
.on('error', (e) => {
console.log('error happend:' + e)
})
.save('./res.mp4') // 输出视频
}
结语
由于 puppeteer 截图性能的问题,目前转 1 秒中的 rrweb 视频,需要 15 秒的时间,性能上是远远不够的。如果你有什么好的想法,欢迎加入到这个项目中来,一起实现更加稳定、高效、强大的 rrweb 转视频工具。
这里附上 源码地址
本文地址:https://blog.csdn.net/qq_28773159/article/details/111998552