用户行为录制技术方案

avatar
前端工程师 @Aloudata

前言

作为 web 开发工程师,为了保障页面的质量,我们会去做单测,E2E 测试,同时也会有专门的测试工程师介入,来帮我们扫清页面中的bug,但是你永远不能保证自己的代码在用户电脑上百分之百靠谱,于是在前端工程化方面,我们有各式各样的埋点方案,全链路监控、用户行为上报、接口状态监控等等。

看起来是很齐全了,但这些都是针对用户行为的瞬间采样,虽然看起来很完备,但是当实际遇到问题时,很多时候还需要开发者自己去思考上下文。

作为产品经理,我的产品推广出去了,虽然埋点记录能捕获大部分的用户行为,但是有时候我还是想观察用户的真实使用链路。在不涉及隐私的前提下,想要对用户行为进行深度理解,进一步对用户行为进行定性分析。

作为测试同学,看到线上出了一部分的 bug,我是很想穿越回用户身边,想看看用户到底是通过什么样的操作手段触发了这个问题,很有可能这是一个未曾考虑到的测试用例。

以上种种问题,都跟我们今天聊的用户行为录制有很大的关系,有了这个玩意,我们可以详细的回归用户的所有操作,既可以复原用户操作排查问题,也可以帮助产品上观察用户使用行为,测试也可以基于这个录制形成自动化回归用例。

用户行为录制是一个定性分析工具,记录用户整个会话的每一个点击、滑动、输入等行为,并支持以录像的方式来回放这些操作,既然要录制用户的完整操作流程,那么我们从前端视角来看看,分别都有哪些技术方案可以帮助我们完成这一功能。

方案:快照截图

首先我们可以想到是否能利用Canvas截图,不停的画页面然后不停的截图,做过一些营销活动的同学一定对这个方案不会陌生,它是用户记录和分享页面信息的有效手段。

那么这个方案能否满足我们的需求呢?

依据图片是否由设备本地生成,快照可分为前端处理和后端处理两种方式,由于后端生成的方案依赖于网络通信,同时需要启动无头浏览器,不可避免地存在通信开销和等待时延,同时对于模板和数据结构变更也有一定的维护成本,我们这里暂时只考虑前端生成方案。

原理

前端侧对于快照的处理过程,实质上是将 DOM 节点包含的视图信息转换为图片信息的过程。这个过程可以借助 Canvas 的原生 API 实现,这也是方案可行性的基础。

具体来说,转换过程是将目标 DOM 节点绘制到 canvas 画布,然后 canvas 画布以图片形式导出。可简单标记为绘制阶段和导出阶段两个步骤:

  • 绘制阶段:选择希望绘制的 DOM 节点,根据 nodeType 调用 canvas 对象的对应 API,将目标 DOM 节点绘制到 canvas 画布(例如对于的绘制使用 drawImage 方法)。
  • 导出阶段:通过 canvas 的 toDataURL 或 getImageData 等对外接口,最终实现画布内容的导出。

以上方案说起来比较容易,但是实际开发需要处理的内容比较多,比如:

  • canvas 的drawImage方法只接受 CanvasImageSource,而CanvasImageSource并不包括文本节点、普通的div等,将非的元素绘制到 canvas 需要特定处理。
  • 当 DOM 复杂时,层级优先级处理较为复杂。
  • 需要关注浮动,绝对定位等布局定位的处理。
  • 很多新的 css 属性无法处理。

这里我们选择了社区中比较出名的方案:HTML2Canvas,官方地址为:github.com/niklasvh/ht…

这里我们主要考虑 4 个点:

  • 页面动画是否能绘制?  no 
  • 页面样式是否不错位? no  
  • 交互性的行为是否能记录?
  • 跨域的图片资源能否记录,播放的视频能否记录?

Canvas demo

image.png

Canvas 总结

从上面 demo 中我们可以看到 html2canvas 并不是一个完美的录制方案,从技术上来讲

  • 布局会有偏差,input 框中样式产生了错位
  • 部分 CSS 样式不支持:html2canvas.hertzen.com/features
  • 无法绘制动画,demo 中 css 动画的状态无法绘制
  • 如果有 video 的话,视频播放状态等无法记录

另外就算上面方案的技术问题可以解决,这种方案的弊端也非常明显:性能太差了,无论是绘制的性能还是传输的性能,所以这个方案不太符合我们的需求。

方案:视频录制

既然截图效果差,性能也不好,那么我们可以直接录制我们当前的页面,将视频流存储起来,那么现在有相应的能力来满足我们的需求吗,哎,好像 webRTC 可以啊。

首先简单了解一下webRTC:浏览器上的音视频通信相关的能力叫做 WebRTC(real time communication),是随着网速越来越快,音视频需求越来越多,而被浏览器所实现的音视频的标准 API。

音视频通信的流程有五步:采集、编码、通信、解码、渲染。

其不仅可以用于「音视频录制、视频通话」,还可以用在「照相机、音乐播放器、共享远程桌面、即时通信工具、P2P 网络加速、文件传输、实时人脸识别」等场景上。

但我们来看下它的兼容性情况:

webRTC demo

iShot_2022-09-24_10.56.46.gif

webRTC 总结

通过上面的 demo,我们发现 webrtc 效果上是能满足我们的需求,但是其有一个最大的弊端:那就是因为安全性策略,在启动 webRTC之前,会有弹窗询问用户,需要获得用户的同意,同时在开启之后会有一个录制的状态条,所以在整个的过程对于用户而言会有一些侵入性,用户会有明显的感知。

同时 webrtc 需要部署在 https 环境下,这也是一个额外的要求。

显然这个技术点更适合用在一些明确的监控场景下,比如面试、上课等,所以不太符合我们的需求。

方案: dom 序列化

接下来就是我们今天的主角了,也是目前最流行的解决方案,

从效果上讲,它可以媲美 webrtc 的录制,基本上我们的操作都能被正确录制下来,同时因为它存储了完整的网页信息,所以可以将页面布局做到很好地还原。

从性能上讲,因为它是 dom 序列化以后的结构,同时是做的增量存储,所以它的传输量比 webrtc 或者是截图的方式都要低一些。

原理

网页本质上是一个 DOM 节点形式存在,通过浏览器渲染出来。我们是否可以把 DOM 以某种方式保存起来,并且在不同时间节点持续记录 DOM 变更状态,再将数据还原成 DOM 节点渲染出来完成回放呢?

从技术设计上,大致都可以拆分为 DOM 序列化、构建快照序列、回放重演以及沙箱环境等四个方向。

元素序列化

需要保存某一时刻的页面状态时,最直观的就是将那一刻的页面 dom 结构做个快照,然后在浏览器中重新渲染出来就能达到回溯的效果了。

const cloneDoc  = document.documentElement.cloneNode(true); // 录制   
document.replaceChild(cloneDoc, document.documentElement); // 回放  

这样我们就实现了某一时刻 DOM 快照的功能。但是这个录制的 cloneDoc 还只是内存中的一个对象,并没有实现远程存储,所以我们需要有一种办法将页面中的所有元素序列化成一个普通对象,这样就能像保存一个字符串或者是 json 文本一样将相关数据传到后台服务器中。

为了实现这个功能,我们需要将 cloneDoc 这个对象序列化成字符串,保存到服务端,然后在回放的时候从服务器上取出来,交给浏览器重新渲染。

// XMLSerializer 是浏览器自带的 api,可以将 dom 对象序列化成 string   
const serializer = new XMLSerializer()
const str = serializer.serializeToString(cloneDoc);   
document.documentElement.innerHTML = str;   

但是我们的目的是录制视频,只有一个 dom 快照显然是不够的。了解动画的同学都应该知道,动画是由每秒至少 24 帧的画面按顺序播放而产生的,那简单,我们就每秒钟clone 24次呗。

稍微细想便可知道这种方法根本行不通,原因有以下几点:

  • 每秒 clone 24 次整个页面内容,对性能损耗巨大,严重影响用户体验
  • 每秒要将 24 帧的页面内容上传到服务端,对网络开销也是巨大的
  • 回放时,每秒要渲染 24 个完整的 html 内容,浏览器根本做不到这么快
  • 还有,要是页面没变动,那么 24 帧的数据可能是完全一样的,根本没必要 clone 这个多次。

综上问题,我们面临着 2 个比较大的挑战,

  • 一个是页面 dom 结构无法序列化,所谓不可序列化是指虽然我们可以通过 innerHTML 等⽅式获取到描述 DOM 的⽂本格式,但其中会丢失⼀些视图状态,例如  元素的 value 就不⼀定会记录在 HTML 中。
  • 页面记录的方式有问题,存在大量的冗余记录,同时传输消耗大

基于以上缺点,我们需要设计一套可序列化的页面结构,同时只有等到页面有变动的时候,做增量记录,即只记录变化的部分。这样一来,好处就显而易见了

  • 页面结构的状态可以保留下来
  • 只记录变化的部分,比起记录整个网页要小的多。这样对网页的性能、网络的开销都会小很多。
  • 我们只在页面有变动的时候才记录,这样一来,大量重复数据的问题也给解决了。
  • 回放时,我们只需要首先将第一帧(完整的页面内容)先渲染出来,然后在按照记录的时间,按顺序将变化的部分渲染到页面。这样就可以像看视频一样来回溯用户的操作流程了。

目前市面上较为出名的 DOM 结构描述库有 parse5,大家可以参考一下。

在线演示地址:astexplorer.net/#/1CHlCXc4n…

ps:我们后面介绍的 rrweb 并未采用这个解析库,而是自己实现了一套,具体的原因我们待会再说。

监听变化

在上一步中,我们已经从理论上实现了录制和回放的功能,但是具体实现呢?我们怎么才能知道页面什么时候变化呢?

可能有的同学就想到了我们的主角:MutationObserver。

该接口提供了监视对 DOM 树所做更改的能力,当 DOM 发生变化时,通过批量异步的方式去触发回调,并将 DOM 变化通过 MutationRecord 数组传给回调方法,我们可以利用这个接口,保存每次变化的DOM数据,并把这些数据转换成可视化的数据结构,然后分别保存起来。接着使用特定的方式对之前保存起来的DOM数据进行还原并重新渲染出来。DOM节点的变化也就意味了页面轨迹发生了变化,这样就可以把这些轨迹记录下来。

但是它没法跟踪像 input、textarea、select 这类可交互元素的输入。

对于这种可交互的元素,我们主要靠监听 input 和 change 来记录输入的过程。

但是有些元素的值是通过程序直接设置的,这样是不会触发 input 和 change 事件的。这种情况下我们可以通过劫持对应属性的 setter 来达到监听的目的。

记录增量数据有很多种方法,比如 dom diff ,最开始 rrweb 团队也尝试过这个方案,但是发现在 dom 复杂的场景下页面开销太高了,所以放弃了方案。

最终他们采用的快照+OPlog 的方式,他们把引发视图变更的操作归为以下⼏类:

  • DOM 变动

    • 节点创建、销毁
    • 节点属性变化
    • ⽂本变化
  • ⿏标交互

  • ⻚⾯或元素滚动

  • 视窗⼤⼩改变

  • 输⼊

  • ⿏标移动(特指⿏标的视觉位置)

对于每个操作只需要记录其操作类型和相关的数据,就可以在回放时重现对应的操作,也就回放了该操作对视图的改变。

这样只需要在开始录制时制作⼀个完整的 DOM 快照,之后则记录所有的操作数据,这些操作数据我们称之为 Oplog(operations log),这⼀思路和 log-structured file system 是类似的。

回放重演

简单来说,重演就是将收集到的数据按照顺序依次“播放”一遍,视频文件的播放需要音视频解码器,而我们的重演环节要做的工作就可以简单理解成一个 Web 应用解码器,从用户端收集上来的数据结构除了要做清洗和存储外,还不能直接被回放侧使用,其中有不少需要考虑的细节。

我们举几个详细的例子

  • 页面中的 JS 脚本要不要执行?如果执行,那不是所有的接口交互意味着都会在发生一次,那对于用户数据是有破坏性的,如果不执行?那么这些操作如果禁止?

    • 改写 script 为 noScript ,同时通过沙箱阻止 JS 的行为
  • 如何校准回放时的定时器偏差?

    • 使用 requestAnimationFrame 替代 settimeout,同时不停地做矫正
  • 如何过滤用户隐私?

    • 约定部分类名的结构不做具体 value 存储

沙箱

上面我们提到了回放的一些问题点,其中 js 执行是最大的一个挑战,而沙箱就可以帮助我们解决该问题,它是为了给回放提供一个安全可控的运行环境,既然采用 DOM 快照方案,那么便需要考虑如何禁止一些“不安全”的 DOM 操作。例如应用内链接跳转、我们不太可能会直接给用户打开一个新的 tab,为了保证快照状态依次回放,我们还需要考虑如何安全准确的反序列化构建 DOM。

rrweb在重建快照时将被录制的 DOM 重建在⼀个 iframe 元素中,通过设置它的 sandbox 属性,我们可以禁⽌以下⾏为:

  • 表单提交
  • window.open 等弹出窗
  • JS 脚本(包含 inline script , event handler 和 url 操作 )

rrweb

上面我们提到过很多次 rrweb 了,现在让我们好好来介绍下它,其官方地址是: github.com/rrweb-io/rr…

这是一个开源的 Web 会话回放库,提供了易于使用的 API 来记录用户的交互并远程回放。rrweb 主要由 rrweb 、 rrweb-player 和 rrweb-snapshot 三个库组成:

  • rrweb:提供了 record 和 replay 两个方法;record 方法用来记录页面上 DOM 的变化,replay 方法支持根据时间戳去还原 DOM 的变化。
  • rrweb-player:基于 svelte 模板实现,为 rrweb 提供了回放的 GUI 工具,支持暂停、倍速播放、拖拽时间轴等功能。内部调用了 rrweb 提供的 replay 等方法。
  • rrweb-snapshot:包括 snapshot 和 rebuilding 两大特性,snapshot 用来序列化 DOM 为增量快照,rebuilding 负责将增量快照还原为 DOM。

上面我们有提到 rrweb 的 dom 序列化方案是自己实现的,这点其官方文章也给出了解释:

我们不使⽤⼀些开源⽅案例如 parse5的原因包含两个⽅⾯:

  1. 我们需要实现⼀个“⾮标准”的序列化⽅法。
  2. 此部分代码需要运⾏在被录制的⻚⾯中,要尽可能的控制代码量,只保留必要功能。

之所以说我们的序列化⽅法是⾮标准的是因为我们还需要做以下⼏部分的处理:

  1. 去脚本化,被录制⻚⾯中的所有 JavaScript 都不应该被执⾏。
  2. 记录没有反映在 HTML 中的视图状态。例如  输⼊后的值不会反映在其 HTML中,我们需要读取其 value 值并加以记录。
  3. 相对路径转换为绝对路径。回放时⻚⾯ URL为重放⻚⾯的地址,如果被录制⻚⾯中有⼀些相对路径就会产⽣错误。
  4. 尽量记录 CSS 样式表的内容。如果被录制⻚⾯加载了⼀些同源的样式表,我们则可以获取到解析好的 CSS rules,录制时将能获取到的样式都 inline 化,这样可以让⼀些内⽹环境(如 localhost)的录制也有⽐较好的回放效果。

rrweb demo

iShot_2022-09-24_10.59.34.gif

沙箱回放还是录制视频存储?

在我们的录制数据中,有许多的外链资源,比如 cdn 的文件,外链的图片等等,也就是说在我们利用录制的数据进行回放的时候,需要依赖这张图片。但是随着项目的迭代,这张图片很可能早已不在,这时我们在回放时,页面中的图片就会加载不出来。比如一个保险场景,保额信息就在网站内的一张海报上,客户可能会说:“我当时看到的保额明明是 150 万,怎么现在变成 100 万了?”,这时你要怎么证明当时海报上写的就是 100 万保额呢?

所以最稳妥的方案还是将 rrweb 录制的原始数据转换成视频,这样一来,不管网站怎么变化,迭代了多少版本,视频是不受影响的。我的做法是通过 puppeteer 在服务端运行无头浏览器,在无头浏览器中回放录制的数据,然后每秒截取一定数量的图片,最后通过 ffmpeg 合成视频。

当然这里 rrweb官方也有提供一些解决方案,比如后端可以在每次数据上传以后将外链资源扫描出来,然后存在本地服务器端,这样子可以保证拥有对资源的控制权。这里就按照各自的具体场景决定了。

总结

以上便是我们今天所有的分享内容了,我们从最简单的定时截图到 webrtc 录制,再到最后的 dom 序列化录制方案,一步步地讲解了用户录制的技术方案选型和挑战,希望能帮助大家对这一技术点有所启发和帮助。

参考资料

团队介绍

以上便是本次分享的全部内容,来自团队 @少和 分享,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 。

我们是来自大应科技(Aloudata, www.aloudata.com )的前端团队,负责大应科技全线产品前端开发工作。在一家初创公司,前端团队不仅要快速完成业务迭代,还要同时建设前端工程化的落地和新技术的预研,我们会围绕产品品质、开发效率、创意与前沿技术等多方向对大前端进行探索和分享,包括但不限于性能监控、组件库、前端框架、可视化、3D 技术、在线编辑器等等。