背景
作为互联网金融保险从业者(搬过砖)的同学应该多少都有听说过【可回溯】这个词,它主要是为了记录用户所有的操作轨迹以及交互状态,大都用在与用户发生纠纷时的举证,并且也是金融监管部门的监管红线(必须要有)。可回溯的一个核心要求****:体现在销售行为可回溯、重要信息可查询、问题责任可确认。那么今天我们就来聊聊关于保险用户行为可回溯的那些事。
方案调研
用户行为可回溯仅从要达到的效果出发在不考虑实现成本、性能消耗等方面都有哪些方案呢?我们先来简单进行梳理和对比。
视频录制
在录制用户行为时,最常想到的方式是通过视频录制屏幕操作。如今,浏览器已提供了一套强大的实时数据流传输方案——WebRTC(Web Real-Time Communications)。通过调用其原生API,我们可以轻松实现屏幕录制功能,为用户行为分析提供了便利。
在我们的录屏使用场景主要关注以下几个 API:
-
getDisplayMedia() - 提示用户给予使用媒体输入的许可从而获取屏幕的流;
-
MediaRecorder() - 生成对指定的媒体流进行录制的 MediaRecorder 对象;
-
ondataavailable - 当 MediaRecorder 将媒体数据传递到应用程序以供使用时将触发该事件;
整体录制流程如下:
-
调用mediaDevices.getDisplayMedia()由用户授权选择屏幕进行录制,获取到数据流;
-
生成一个new MediaRecorder()对象录制获取的屏幕的数据流;
-
在 MediaRecorder 对象上设置ondataavailable监听事件用于获取录制的 Blob 数据。
-
播放的时候获取Blob数据进行播放
页面截图
众所周知,视频的本质是由一连串连续的画面构成,,因此我们可以按照一定时间间隔来截图的方式保存当前页面快照,然后将快照按照相同的截取速度播放形成视频就能实现用户行为录制了。最常用的截图方法就是以 html2canvas 库为代表的 canvas 截图。将全量的document按照一定的时间处理为图片。后续将图片一帧一帧展示出来。
每隔一段时间,例如每一帧都将全量的dom保存为图片,回放的时候按顺序一张张展示即可。所以最后存储的其实是图片。
DOM快照
每一个瞬间我们看到的页面都是浏览器当前渲染的 DOM节点,那么我们完全可以将 DOM 节点保存下来,并持续记录 DOM 节点的变化,然后再将记录的 DOM 节点数据通过浏览器渲染回放,这样即可实现用户行为录制的需求。整个思路非常简单,但具体实现起来是非常复杂的事情,我们需要考虑 DOM 节点数据如何保存、如何捕获用户行为并记录 DOM 节点变换和如何将记录的数据在浏览器上回放出来等。
方案对比
既然有这么多方案可以到达我们的效果,那么到底哪种方式更加适合呢?我们通过一个表格进行详细对比
可回溯系统建设
回溯流程与原理
rrweb 在录制时会首先进行首屏 DOM 快照,遍历整个页面的 DOM Tree 并通过 nodeType 映射转换为 JSON 结构数据。在获取首屏全量快照之后,我们就需要监听各类变动以获取增量的数据,增量改变的数据也需要同步转换为 JSON 数据进行存储。对于增量数据更新,则是通过 mutationObserver 获取 DOM 增量变化,以及通过全局事件监听例如鼠标移动,鼠标滚动,鼠标交互等等,并将劫持到的增量变化数据存入 JSON 数据中。
可回溯主要由以下三个包构成:
- rrweb-snapshot,包含 snapshot 和 rebuild 两部分,snapshot 用于将 DOM 及其状态转化为可序列化的数据结构并添加唯一标识,rebuild 是将 snapshot 记录的数据结构重建为对应 DOM。
- rrweb,包含 record 和 replay 两个功能,record 用于记录 DOM 中的所有变更,replay 则是将记录的变更按照对应的时间一一重放。
- rrweb-player,为 rrweb 提供一套 UI 控件,提供基于 GUI 的暂停、快进、拖拽至任意时间点播放等功能。
基本原理如下图所示:
我们通过上面的介绍,已经了解了DOM录制的基本原理,接下来就要结合项目进行业务落地了,在项目落地之前我们还需要考虑如下几个问题:
- 如何防止录屏过程中影响项目性能?
- 如何防止频繁上报和上报数据量过大问题?
- 如何保证DOM数据的完整性,降低丢帧的概率?
- 如何结合金融业务场景考虑能力扩展?
加载时机
用户行为可回溯录制属于合规监管的强诉求,但是并不属于业务功能的强诉求,在可回溯的接入时既要满足用户行为的录制功能又要考虑用户的体验感受。结合实际情况可知,可回溯的开启肯定是晚于整个页面的用户可交互时机的,因为我们需要在整的用户同意后才可以进行录制和行为收集,基于此sdk可以通过延迟异步加载的方式进行载入,这样我们既可以保证业务项目资源的优先加载,降低对项目性能的影响。
但是延迟加载避免了与项目资源的竞争关系,进而也会引发新的问题,那就是如果在sdk加载完成前业务项目已经调用了sdk的某些api,此时如果sdk没有完成加载就会报错找不多对应的api,其实这个解决相对简单,既然有调用时机的问题,那么就可以启用一个临时api队列进行缓存,这样就可以很好的解决加载时机问题,简单实现如下:
数据收集
加载时机确认后,我们接着来看数据收集的问题,DOM快照方案会通过MutationObserver来监听DOM结构的变化和全局事件的监听来得到鼠标移动,窗口缩放,视图滚动,媒体交互等。
这种事件监听的变化频次是非常高的,鼠标的移动有可能就会触发几十条甚至上百条的数据,针对这种情况首先想到的肯定是先将这些数据进行暂存,然后使用setTimeout函数或者setInterval函数每过一段时间后对暂存的数据进行合并上报,但考虑到对性能的影响这里我们基于requestIdleCallback进行了封装模拟setInterval进行轮询上报,同时在不支持requestIdleCallback的环境下使用setTimeout模拟实现。同时为了保证一定的实时性和数据大小在触发检测的事件进行了条件控制:
-
maxWaitTime:最大等待时间,与requestIdleCallback进行互补
-
maxEvents:最大事件数量,避免上报的数据量过大
-
maxSize: 最大数据量,超过一定数据大小进行压缩
// 伪代码 export default class FrequencyController { constructor(options = {}) { try { this.options = { maxWaitTime: 1000, // 最大等待时间 normalInterval: 800, // 常规发送间隔 maxEvents: 30, // 最大事件数量 ...options };
this.events = []; // 事件队列 this.lastTriggerTime = Date.now(); this.rafId = null; this.timerId = null; this.paused = false; this.callback = null; this.sending = false; this.forceSendTimer = null; } // 开始 start(callback) { if (typeof callback !== 'function') { throw new Error('Callback must be a function'); } this.callback = callback; this.schedule(); }// 执行 schedule() {
} scheduleIdleCallback(callback) { } shouldTrigger(eventsLength, timeElapsed) { } checkAndTrigger(eventsLength) { } trigger(options) { } forceSend(options) { } pause() { } currentPaused() { } resume() { } clearTimers() { } destroy() { }}
上报机制
在完成数据收集后每满足一次数据收集的条件就会进行数据请求的上报,本来以为万事大吉的时候又遇到新的问题,在后台查看用户可回溯视频时发现某些情况下用户的视频有丢帧的情况,而且基本都是在页面跳转时发生,原因主要是因为页面跳转时发送请求有可能会被中断。当用户在页面上进行跳转时,浏览器会开始加载新的页面,这个过程会导致当前页面的所有未完成的ajax请求被中断。浏览器会丢弃当前页面的所有未完成的网络请求,以确保新页面的加载能够顺利进行。
这种情况最简单的方式是将页面请求改为:同步请求阻塞,如果使用同步请求,虽然可以防止请求被中断,但会阻塞页面的卸载和跳转,导致用户体验不佳。到这里便想到了navigator.sendBeacon大展身手的时候了。
Navigator.sendBeacon 是一个非常实用的 Web API,它主要用于在浏览器后台发送异步请求,尤其擅长在页面卸载或关闭这样的特定场景下,将数据可靠地发送至服务器,而且不会等待服务端的响应,也就不会阻塞页面的卸载或关闭流程。
为了比较全面的了解 Navigator.sendBeacon,我们对 Navigator.sendBeacon和普通AJAX进行简单的对比。
经过对比虽然Navigator.sendBeacon虽然有一些局限性,但是针对页面关闭或者卸载场景下发送请求非常合适。
动画操作
至此,经过以上一些列探索终于基本实现了我们的需求,大概效果如下:
大数场景下可回溯录制时没有任何问题的,但是当我们某些页面有许多动画时,页面在无任何用户交互的情况下发现在不断地发送数据,这主要是因为动画的变化会一直进行增量的DOM快照收集,对于用户行为录制而言这种变动其实是无意义的,本身可以作为静态dom只保留某一个时刻的状态就可以,这种不断地增量快照的收集反而有可能对服务器增加压力。
那么针对动画这种场景应该怎么办呢?在查看源码后发现record.js中processMutations函数是专门收集处理增量内容变化的函数,当有动画元素时我们给动画的dom容器增加一个标识,在增量收集时我们把动画渲染触发某几次变化后认为是动画的比较稳定的状态,后续动画增量的变化我们都排除忽略掉,这样我们在可回溯里看到的便是动画某个时刻的状态,动画后续循环的变动都忽略不计入收集的数据中。最终大致代码如下:
this.processMutations = (mutationList) => {
const excludeElements = document.querySelectorAll(`.${this.onceClass},${this.onceSelector}`);
const excludeSet = new Set(excludeElements);
// 使用 Map 缓存已记录的元素及其触发次数
const onceMap = this.onceElement.reduce((map, item) => {
map.set(item.element, item.num);
return map;
}, new Map());
const toUpdateOnceElement = [];
for (const mutation of mutationList) {
const { target } = mutation;
// 检查是否是 exclude 元素或其子元素
let matchedExcludeEle = null;
for (const ele of excludeSet) {
if (ele === target || ele.contains(target)) {
matchedExcludeEle = ele;
break;
}
}
if (matchedExcludeEle) {
const currentNum = onceMap.get(matchedExcludeEle) || 0;
if (currentNum <= 3) {
this.processMutation(mutation);
// 标记为需要更新计数器
toUpdateOnceElement.push(matchedExcludeEle);
}
} else {
// 非 exclude 元素直接处理
this.processMutation(mutation);
}
}
// 批量更新 onceElement 计数器
for (const ele of toUpdateOnceElement) {
const index = this.onceElement.findIndex(i => i.element === ele);
if (index !== -1) {
this.onceElement[index].num += 1;
} else {
this.onceElement.push({ element: ele, num: 1 });
}
}
this.emit();
};
未来规划
经过不断探索,以用户数据洞察用户行为可回溯系统稳定性建设基本告一段落,在不影响业务性能的前提下实现了合规要求的目标,可以很好的还原了用户的行为操作,但至此也给我们带来了新的启发和想法。
- 同屏互动:在金融业务中在用户遇到问题时,可以在用户授权允许的情况下协助用户进行指导相关操作,提升线上沟通效率、增强互动体验。
- 事故现场还原:以上帝视角俯瞰监听页面全局,触发异常时将用户异常前后一分钟大dom快照数据进行上报,辅助排除问题。
技术辅助推动业务发展,期望在未来探索出更多的应用场景。