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