作者:植物系青年
https://juejin.cn/post/7280429214607769658
目前,在我们的项目中通常会使用各种各样的埋点和监控来收集页面访问的信息,例如点击埋点、PV埋点等,这些埋点数据能够反应绝大部分的用户行为,但是对于一些关注上下文的使用场景而言这些埋点是不够的。
因此,我们需要一种手段来获取用户某一时段连续的操作行为,也就是录制用户行为,包括整个会话中的每一个点击、滑动、输入等行为,同时支持回放录制的操作行为,完整且真实地重现用户行为以帮助我们回溯或分析某些使用场景。
录制用户行为最容易想到的就是将屏幕操作通过视频的方式录制下来,目前浏览器本身已经提供了一套基于音视轨的实时数据流传输方案 WebRTC[1](Web Real-Time Communications),在我们的录屏使用场景主要关注以下几个 API:
整体录制流程如下:
mediaDevices.getDisplayMedia()
由用户授权选择屏幕进行录制,获取到数据流;new MediaRecorder()
对象录制获取的屏幕的数据流;ondataavailable
监听事件用于获取录制的 Blob 数据。html复制代码<template>
<video ref="playerRef"></video>
<button @click="handleStart">开启录制</button>
<button @click="handlePause">暂停录制</button>
<button @click="handleResume">继续录制</button>
<button @click="handleStop">结束录制</button>
<button @click="handleReplay">播放录制</button>
<button @click="handleReset">重置内容</button>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
const playerRef = ref();
const state = reactive({
mediaRecorder: null as null | MediaRecorder,
blobs: [] as Blob[],
});
// 开始录制
const handleStart = async () => {
const stream = await navigator.mediaDevices.getDisplayMedia();
state.mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm',
});
state.mediaRecorder.addEventListener('dataavailable', (e: BlobEvent) => {
state.blobs.push(e.data);
});
state.mediaRecorder?.start();
};
// canvas录制(特殊处理)
const handleCanvasRecord = () => {
const stream = canvas.captureStream(60); // 60 FPS recording
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9',
});
recorder.ondataavailable = (e) => {
state.blobs.push(e.data);
};
}
// 暂停录制
const handlePause = () => { state.mediaRecorder?.pause() };
// 继续录制
const handleResume = () => { state.mediaRecorder?.resume() };
// 停止录制
const handleStop = () => { state.mediaRecorder?.stop() };
// 播放录制
const handleReplay = () => {
if (state.blobs.length === 0 || !playerRef.value) return;
const blob = new Blob(state.blobs, { type: 'video/webm' });
playerRef.value.src = URL.createObjectURL(blob);
playerRef.value.play();
};
const handleReset = () => {
state.blobs = [];
state.mediaRecorder = null;
playerRef.value.src = null;
};
const handleDownload = () => {
if (state.blobs.length === 0) return;
const blob = new Blob(state.blobs, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.style.display = 'none';
a.download = 'record.webm';
a.click();
};
</script>
Untitled 1.png
尽管浏览器原生提供了这样既简单又实用的屏幕录制解决方案,但在我们实际应用场景中仍旧有非常多的问题:
众所周知,视频是由一帧帧的画面组合而成的,因此我们可以按照一定时间间隔来截图的方式保存当前页面快照,然后将快照按照相同的截取速度播放形成视频就能实现用户行为录制了。最常用的截图方法就是以 html2canvas[6] 库为代表的 canvas 截图,我们在使用过程中也发现了较多问题:
<template>
<el-button @click="handleStart">开启录制</el-button>
<el-button @click="handleStop">停止录制</el-button>
<el-button @click="handleReplay">播放录制</el-button>
<img :src="state.imgs[state.num ?? 0]" />
</template>
<script lang="ts" setup>
import { reactive } from 'vue';
import html2canvas from 'html2canvas';
const state = reactive({
visible: false,
imgs: [] as string[],
num: 0,
recordInterval: null as any,
replayInterval: null as any,
});
const FPS = 30;
const interval = 1000 / FPS;
const handleStart = async () => {
handleReset();
state.recordInterval = setInterval(() => {
if (state.imgs.length > 100) {
handleStop();
return;
}
html2canvas(document.body).then((canvas: any) => {
const img = canvas.toDataURL();
state.imgs.push(img);
});
}, interval);
};
const handleStop = () => {
state.recordInterval && clearInterval(state.recordInterval);
};
const handleReplay = async () => {
state.recordInterval && clearInterval(state.recordInterval);
state.num = 0;
state.visible = true;
state.replayInterval = setInterval(() => {
if (state.num >= state.imgs.length - 1) {
clearInterval(state.replayInterval);
return;
}
state.num++;
}, interval);
};
const handleReset = () => {
state.imgs = [];
state.recordInterval = null;
state.replayInterval = null;
state.num = 0;
};
</script>
实际内容 | 截图效果 |
---|---|
每一个瞬间我们看到的页面都是浏览器当前渲染的 DOM节点,那么我们完全可以将 DOM 节点保存下来,并持续记录 DOM 节点的变化,然后再将记录的 DOM 节点数据通过浏览器渲染回放,这样即可实现用户行为录制的需求。整个思路非常简单,但具体实现起来是非常复杂的事情,我们需要考虑 DOM 节点数据如何保存、如何捕获用户行为并记录 DOM 节点变换和如何将记录的数据在浏览器上回放出来等。所幸当前社区已经有非常成熟的库,也就是 rrweb[8](record and replay the web)🎉
rrweb 主要由 3 部分组成:
录制过程
rrweb 在录制时会首先进行首屏 DOM 快照,遍历整个页面的 DOM Tree 并通过 nodeType 映射转换为 JSON 结构数据。针对不同 nodeType[12] 类型的节点类型的序列化操作具有非常多的细节,如想了解细节可阅读这部分源码[13]。全量快照数据示例如下:
Untitled 10.png在获取首屏全量快照之后,我们就需要监听各类变动以获取增量的数据,增量改变的数据也需要同步转换为 JSON 数据进行存储。对于增量数据更新,则是通过 mutationObserver[14] 获取 DOM 增量变化,以及通过全局事件监听、事件(属性)代理的方式进行方法(属性)劫持,并将劫持到的增量变化数据存入 JSON 数据中。针对不同类型的变动有这不同的监听处理方式,如想了解细节可阅读这部分源码[15]。
回放过程
回放主要就是将录制过程中的全量快照和增量快照进行重建复原,那么为保证一个安全可靠的环境在回放时我们不应该执行被录制页面中的 JavaScript,在重建快照的过程中通过 script 标签改写为 noscript 标签和 dom 重建在 iframe 中并设置 sandbox[16] 属性等手段来构建安全可靠的沙盒环境。在沙盒环境中,首先需要对首屏 DOM 快照进行重建,遍历 JSON 产物的同时通过自定义 type 映射到不同的节点构建方法以重建首屏 DOM 结构,然后 rrweb 内部则根据不同的增量类型调用不同的函数在页面对增量数据进行展现。同时,播放时通过录制产生的时间戳来保证回放顺序,通过 Node id 作用至指定的 DOM 节点,通过 requestAnimationFrame 保证页面播放流畅度。
<template>
<button @click="handleStart">开启录制</button>
<button @click="handleStop">结束录制</button>
<button @click="handleReplay">播放录制</button>
<div class="replay" ref="replayRef"></div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import * as rrweb from 'rrweb';
import rrwebPlayer from 'rrweb-player';
import 'rrweb-player/dist/style.css';
const replayRef = ref();
const state = reactive({
events: [] as any[],
stopFn: null as any,
});
const handleStart = () => {
state.stopFn = rrweb.record({
emit(event) {
if (state.events.length > 100) {
// 当事件数量大于 100 时停止录制
handleStop();
} else {
state.events.push(event);
}
},
});
ElMessage('开始录制');
};
const handleStop = () => {
state.stopFn?.();
ElMessage('已停止录制');
};
const handleReplay = () => {
new rrwebPlayer({
target: replayRef.value, // 可以自定义 DOM 元素
// 配置项
props: {
events: state.events,
},
});
};
</script>
Untitled.gifUntitled 5.png
从效果上来讲,rrweb 录制内容存储了完整的页面结构能够较好地还原页面的整个操作,并且 rrweb 录制无损录制具有较好的清晰度,不像视频录制和页面截图需要考虑分辨率与产物大小的问题,同时也不像 canvas 截图一样对内容和样式有较大的局限性使得部分页面内容无法录制。
从性能上来讲,rrweb 录制传输的内容为 JSON 数据并且只对用户操作内容作增量记录,当页面静默的时候不会有额外数据的记录,相比较视频录制和页面截图而言大大减少了最终产物的体积,减轻了数据传输的压力,同时也提高了录制的性能。
从功能上来讲,rrweb 除了基础的录制回放功能之外,还具有较好的可扩展性和可操作性:
对比内容 | 视频录制 | 页面截图 | Dom 快照录制 |
---|---|---|---|
开源库 | WebRTC 原生支持 | html2canvas | rrweb |
用户感知 | 录制有感 | 录制无感 | 录制无感 |
产物大小 | 大 | 大 | 相对较小 |
兼容性 | 详见相关 API 兼容性 | 部分场景内容截图无法显示 | 兼容性相对较好 |
可操作性 | 弱 | 弱 | 强(支持数据脱敏/加密等) |
回放清晰度 | 录制时决定,有损录制 | 录制时决定,有损录制 | 高保真 |
💡 Dom快照录制 - rrweb 库 是目前最为流行的解决方案,一些商业化平台解决方案也都主要基于 rrweb 库来进行录制与回放的功能开发。但是,方案的选择不是绝对的,在不同的使用场景下选择合适的方案才是最重要的哦 (^_-)
在获取页面的录屏内容之后,这只是第一步,更重要的是我们能够利用这些录屏信息获取到什么信息,分析出什么内容?
# | 应用场景 | 说明 |
---|---|---|
1 | 产品功能分析 | 产品在功能的上线后仅通过点击或PV埋点来判断使用情况是不够的,更应该关注于一些关键页面/功能的真实使用场景,通过用户行为录制将用户的操作路径记录下来,通过回放种子用户的操作进行分析或利用算法对使用路径进行分析能够更好了解功能设计的情况,并帮助进一步优化。 |
2 | 用户访谈记录 | 产品在对用户进行访谈时通过整理用户口述记录和回放访谈录音等方式来进行分析和研究,整体访谈的成本较高,信息利用率也较低,而通过用户行为录制记录下来访谈过程中用户真实的操作记录能够更好地帮助产品来回顾访谈用户的操作习惯。 |
3 | 问题现场复现 | 解决问题第一步就需要复现问题,但有时候问题的复现操作是极其隐蔽的或者由于用户的使用环境等因素很难定位,通过录制用户行为来保存报错时刻的上下文使得我们能够更好地了解用户报错的操作,最大程度减少沟通和内容传递的成本。 |
4 | 自动化测试用例 | 通常自动化测试用例的编写和维护都由人工手动来完成,成本相对比较高,后期维护也不方便,通过用户行为录制将录制的数据加以转换就可以更加快捷方便地进行测试用例的采集,同时也便于管理。 |
5 | 其他 | 还有案例复盘、行为监控/监管、业务流程质检... |
Sentry 平台[21] 提供了录制与回放功能用于进行分析,其应用重点在于查看错误或性能问题发生之前、期间和之后的操作情况,其录制与回放功能同样基于 rrweb 库进行开发。
Untitled 6.pngHotjar 平台[22]提供了录制与回放功能用于进行分析,其除了录制与回放功能外,其提供了页面热力图等分析能力,以帮助用户更好地了解产品的情况,官方也提供了 Demo[23] 可体验 。
Untitled 7.png其他相关的商业化平台还有LogRocket[24]、FullStory[25]、marker.io[26]等等。
目前,用户行为录制已经在各个场景中广泛使用,例如用户调研、产品分析、Bug回溯、自动化测试和行为监控等等。视频录制、页面截图和 Dom 快照录制等技术方案各有优劣,在面对不同的使用场景时要选择合适的技术方案,但总体而言,以 rrweb 库为代表的 Dom 快照录制技术是目前最为广泛使用也最具优势的技术方案,在各种商业化解决方案中也主要采用该技术方案或思路来实现。
WebRTC: https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FWebRTC_API
[2]getDisplayMedia(): https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FMediaDevices%2FgetUserMedia
[3]MediaRecorder(): https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FMediaRecorder%2FMediaRecorder
[4]ondataavailable: https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FMediaRecorder%2Fdataavailable_event
[5]兼容性查询: https://link.juejin.cn?target=https%3A%2F%2Fcaniuse.com%2F
[6]html2canvas: https://link.juejin.cn?target=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2Fhtml2canvas
[7]不支持部分CSS样式: https://link.juejin.cn?target=https%3A%2F%2Fhtml2canvas.hertzen.com%2Ffeatures
[8]rrweb: https://link.juejin.cn?target=https%3A%2F%2Fwww.rrweb.io%2F
[9]rrweb-snapshot: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Frrweb-io%2Frrweb%2Ftree%2Fmaster%2Fpackages%2Frrweb-snapshot%2F
[10]rrweb: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Frrweb-io%2Frrweb
[11]rrweb-player: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Frrweb-io%2Frrweb%2Ftree%2Fmaster%2Fpackages%2Frrweb-player%2F
[12]nodeType: https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FNode%2FnodeType
[13]源码: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Frrweb-io%2Frrweb%2Fblob%2Fmaster%2Fpackages%2Frrweb-snapshot%2Fsrc%2Fsnapshot.ts%23L430
[14]mutationObserver: https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FMutationObserver
[15]源码: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Frrweb-io%2Frrweb%2Fblob%2Fmaster%2Fpackages%2Frrweb%2Fsrc%2Frecord%2Fobserver.ts%23L1278
[16]sandbox: https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FHTML%2FElement%2Fiframe
[17]rrvideo: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Frrweb-io%2Frrweb%2Ftree%2Fmaster%2Fpackages%2Frrvideo
[18]链接: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Frrweb-io%2Frrweb%2Fblob%2Fmaster%2Fguide.zh_CN.md%23%E9%9A%90%E7%A7%81
[19]链接: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Frrweb-io%2Frrweb%2Fblob%2Fmaster%2Fdocs%2Frecipes%2Foptimize-storage.zh_CN.md
[20]链接: https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Frrweb-io%2Frrweb%2Fblob%2Fmaster%2Fdocs%2Frecipes%2Fplugin.zh_CN.md
[21]Sentry 平台: https://link.juejin.cn?target=https%3A%2F%2Fdocs.sentry.io%2Fproduct%2Fsession-replay%2F
[22]Hotjar 平台: https://link.juejin.cn?target=https%3A%2F%2Fwww.hotjar.com%2F
[23]Demo: https://link.juejin.cn?target=https%3A%2F%2Finsights.hotjar.com%2Fsites%2F2327305%2Foverview
[24]LogRocket: https://link.juejin.cn?target=https%3A%2F%2Flogrocket.com%2Ffeatures%2Fsession-replay-developers
[25]FullStory: https://link.juejin.cn?target=https%3A%2F%2Fwww.fullstory.com%2F
[26]marker.io: https://link.juejin.cn?target=https%3A%2F%2Fmarker.io%2F
向下滑动查看
推荐阅读 点击标题可跳转