# 前端录制回放
前端录制回放指的是录制用户在网页中的各种操作并支持回放,文本介绍的录制回放基于开源项目 rrweb 实现。
# 名词解释
- 全量快照:深度优先遍历整个 Document,生成一个类似于 dom 树的数据结构,这个数据称为全量快照。
- 增量快照:在完成一次全量快照之后,基于当前视图状态观察所有可能对视图造成改动的情形,此时产生的数据生成增量快照。
# Todo & Not Todo
# Todo
本文将介绍如下 4 个方面的内容:
- 实现网页录制用到的 DOM API
- 减小数据体积的策略
- 数据的存储策略
涉及的知识点有 DOM API、Web Workers、IndexedDB 和 Gzip 压缩。
# Not Todo
本文不介绍 rrweb 提供的 API,到 rrweb 的仓库 (opens new window)可查看 rrweb 的教程。
# 实现网页录制用到的 DOM API
rrweb 可观察 DOM 变动、canvas 画布的变动、鼠标移动、鼠标交互、页面或元素滚动、视窗大小改变和输入等,为了观察这些事件,它使用了多个 DOM API,下面分别介绍它们。
# 观察 DOM 变动
观察 DOM 变更用到的 API 是 MutationObserver,它的用法如下:
// 当观察到变动时,执行这个回调函数
const callback = (mutationList, observer) => {
mutationList.forEach((mutation) => {
if (mutation.type === 'characterData') {
} else if (mutation.type === 'attributes') {
} else if (mutation.type === 'childList') {
}
})
}
// 创建一个观察者
const observer = new MutationObserver(callback);
// 开始观察目标节点
observer.observe(targetNode, {
// 观察目标节点的子树
subtree: true,
// 观察目标节点的 childList,插入或者移除 child
childList: true,
// 观察属性值的变化
attributes: true,
// 观察哪些属性值的变化,如果 attributeFilter 为 undefined,那么所有属性都会被观察
attributeFilter: ['class'],
// 记录属性改变之前的值
attributeOldValue: true,
// 观察文本的变化
characterData: true,
// 记录文本变化之前的值
characterDataOldValue: true
});
observer.disconnect();
# canvas 画布的变动
有两种方式观察 canvas 画布的变动,一种是将 canvas 2D 上下文和 webgl 上下文的 API 包裹一层,记录调用的方法和参数,另一种是定期给 canvas 生成一张二进制位图,保存这张截图。
包裹 canvas 2D 上下文和 webgl 上下文的 API,以 webgl 上下文的 API 为例:
function patchGLPrototype(
prototype: WebGLRenderingContext | WebGL2RenderingContext,
type: CanvasContext,
cb: canvasManagerMutationCallback
): listenerHandler[] {
const props = Object.getOwnPropertyNames(prototype);
for (const prop of props) {
const original = source[name] as () => unknown;
// 重写方法
source[name] = (...args: unknown[]) {
original.apply(this, args)
const recordArgs = serializeArgs([...args], window, prototype);
const mutation: canvasMutationWithType = {
type,
p: prop,
a: recordArgs,
};
// 记录导致画布发生变化的参数和方法
cb(this.canvas, mutation);
}
}
}
patchGLPrototype(WebGLRenderingContext.prototype, CanvasContext.WebGL, cb)
patchGLPrototype(WebGL2RenderingContext.prototype, CanvasContext.WebGL2, cb)
用重写原始方法的方式观察 canvas 画布的变动会导致 FPS 有明显的下降,但是产生的数据体积较小。定期给 canvas 生成一张二进制位图,保存截图,回放的时候将图片画在 canvas 画布上,这种方式产生的数据体积较大,但不会导致 FPS 有明显下降,用到的 web API 有 Web Workers(在后文单独介绍)、requestAnimationFrame、createImageBitmap 和 OffscreenCanvas。
- requestAnimationFrame(callback)
让浏览器在下一次重新绘制之前调用指定函数,用它来计算上一次生成截图到现在的时间间隔,如果间隔小于指定时间不生成截图。
- createImageBitmap
用它以给定的 canvas 生成二进制位图,用法如下:
const bitmap =
await createImageBitmap(canvas,sx,sy,sw,sh, {
// 二进制位图的宽度
resizeWidth: 100,
// 二进制位图的高度
resizeHeight: 100,
// 图片原样显示还是沿 Y 轴翻转
imageOrientation: 'none',
// 位图的颜色通道是否应被alpha通道预乘
premultiplyAlpha: 'default',
// 是否应使用颜色空间转换对图像进行解码,
colorSpaceConversion: 'default',
// 调整位图的输出质量
resizeQuality: 'low'
});
- OffscreenCanvas
创建一个离屏渲染的 canvas 将上一步的二进制位图画在画布上,生成 blob ,最终生成 base64,在这一步还需要生成一张透明的图,去判断 base64 对应的图是否是透明图,如果是则不保存数据。示例代码如下:
const offscreen = new OffscreenCanvas(width, height);
const ctx = offscreen.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
const blob = await offscreen.convertToBlob();
const arrayBuffer = await blob.arrayBuffer();
const base64 = encode(arrayBuffer);
function getTransparentBlobFor(width: number, height: number): Promise<string> {
const offscreen = new OffscreenCanvas(width, height);
offscreen.getContext('2d');
const blob = await offscreen.convertToBlob();
const arrayBuffer = await blob.arrayBuffer();
const base64 = encode(arrayBuffer); // cpu intensive
return base64;
}
// 判断是否透明图
if (base64 !== await getTransparentBlobFor(width, height)) {
// do something
}
offscreen.convertToBlob 在创建 blob 时,能指定文件的格式和图片质量,默认导出 image/png 格式的数据,可选的格式还有 image/jpeg 和 image/webp。
# 鼠标移动
给 document 绑定 mousemove、touchmove 和 drag 事件,在事件处理程序中获取鼠标的位置。
# 鼠标交互
给 document 绑定 mouseup、mousedown、click、contextmenu、dblclick、focus、blur、touchstart、touchend、touchcancel 事件,在事件处理程序中得到鼠标的位置。
通过 event.composedPath() 能获取事件的触发路径,它返回一个包含触发事件的 dom 节点数组,第一个位置是事件的 target。JSX 语法绑定事件在event对象上没有这个方法。
# 页面或元素滚动
给 document 绑定 scroll 事件,在事件处理程序中获取 target 的 scrollLeft 和 scrollTop。
# 视窗大小改变
给 window 绑定 resize 事件,在事件处理程序中获取视口的宽高。
# 输入
利用事件冒泡的特性,给 document 绑定 change 事件或者绑定 change 事件和 input 事件,在事件处理程序中判断 target 是否是 input、textarea 或者 select,如果是,则统计 target 的 value 和 checked 属性值,否则返回。
为了观察输入值的变化,还会获取 HTMLInputElement.prototype.value 的属性描述符,如果它有 setter 则将它的 setter 包裹一层,除此之外还有 HTMLInputElement.prototype.checked、HTMLSelectElement.prototype.value、HTMLTextAreaElement.prototype.value、HTMLSelectElement.prototype.selectedIndex 和 HTMLOptionElement.prototype.selected,以 HTMLInputElement.prototype.value 为例,示例代码如下:
const propertyDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
if (propertyDescriptor && propertyDescriptor.set) {
Object.defineProperty(HTMLInputElement.prototype, 'value', {
set(value) {
propertyDescriptor.set.call(this, value)
eventHandler({target: this})
}
})
}
# 多媒体交互
给 document 绑定 play、pause、seeked 和 volumechange 事件(这些事件都不支持冒泡,所以可能有 bug,但没有实际测试过),在事件处理程序中获取 target 的音量和播放进度。
# CSS 样式变动
观察 CSS 样式的变动,涉及到 CSSStyleSheet、CSSStyleDeclaration、CSSGroupingRule、这两个对象,采用的方式是将原始方法包一层,方法有:CSSStyleDeclaration.prototype.setProperty、CSSStyleSheet.prototype.insertRule 和 CSSStyleSheet.prototype.deleteRule。
# 网页字体的变动
将 window.FontFace 和 document.fonts.add 包裹一层。
# 减小数据体积的策略
从提取冗余数据、减小 canvas 截图的大小和 Gzip 压缩这三个方面减小数据的体积。
# 提取的冗余数据
要提取的冗余数据有 className、style 内联样式和 style 嵌入样式,提取出的冗余数据与录制数据单独保存,并且在每个用户间共用。如下是一条录制数据示例:
./img/record_data.png)
提取之后上图的 class 属性值将变成 '0 1', style 属性值将变成 '0;1;2',另外会新增两个对象,分别是 classNameMap 和 styleMap,它们的的值如下:
const classNameMap = {
'tui-collapse-panel': '0',
'tui-collapse-panel-active': '1'
}
const styleMap = {
'pointer-events: auto': '0',
'opacity: 1': '1',
'cursor: default': '2'
}
style 嵌入样式也按照类似的方式提取,但是保存的数据结构不同,接口如下:
type cssTexts = Array<{
// 这是 style 标签中的样式规则
textContent: string,
uid: string
}>
# 减小 canvas 截图的大小
两个策略,一个是用 createImageBitmap 生成二进制位图的时候,将输出图片的宽高设置成 canvas 宽高的 1/2(这个比例可以由使用方决定),另一个是用 OffscreenCanvas.convertToBlob 创建 blob 对象时,输出 webp 格式的图片,并且将图片的质量设置为 0.5。代码如下:
const bitmap = await createImageBitmap(canvas, {
resizeWidth: imgW,
resizeHeight: imgH,
});
const offscreen = new OffscreenCanvas(imgW, imgW);
const ctx = offscreen.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
const blob = await OffscreenCanvas.convertToBlob({
type: 'image/webp',
quality: 0.5
})
const arrayBuffer = await blob.arrayBuffer();
const base64 = encode(arrayBuffer);
# GZip 压缩
录制数据是一个可序列化的对象,要想进行 GZip 压缩必须将它序列化成字符串,用 JSON.stringify 序列化大对象时,可能导致浏览器崩溃,然而全量快照的大小取决于 dom 树的嵌套层级,业务方的 dom 树究竟嵌套了多少层,前端录制回放工具对此不可知,所以不能用 JSON.stringify 直接序列化所有类型的录制数据。最终得出如下3条序列化规则:
- 拥有 childNodes 嵌套层级的对象采用分层序列化。全量快照和由 MutationObserver 产生的,并且用 add 字段的增量快照属于这一类。
- 没有 childNodes 的对象直接用 JSON.stringify 将其整体序列化为字符串。
- 有 childNodes 但可预知嵌套层级较小的标签,比如 head,style 直接用 JSON.stringify 将对象整体序列化为字符串。
使用上述规则之后,序列化全量快照的结果如下:
./img/seriz.png)
用 fflate 实现 GZip 压缩,将多个录制数据批量压缩,对于 GZip 等压缩算法来说更为友好,但是对拥有 childNodes 嵌套层级的对象不进行批量压缩。代码如下:
const stringifyDataArr = events.map(event => JSON.stringify(event))
// GZip 压缩
compress(`[${stringifyDataArr.join(',')}]`)
之所以不用 JSON.stringify(events) 是为了防止 events 太大导致浏览器崩溃。
为了不阻塞主线程,减小数据体积相关的操作在 worker 线程中进行, 后面将介绍 Web Worker。
# 数据的存储策略
录制数据使用 IndexedDB API 保存在用户本地。前面提取的冗余数据保存到单独的数据库中,该数据库名固定,它有三个 table,分别是 classNameMap、styleMap 和 cssTexts,录制数据存储在哪个数据库由业务方使用录制工具时传入。
IndexedDB 存储数据时要对数据进行结构克隆,因此不支持结构克隆算法 (opens new window)的数据不能被 IndexedDB 存储,比如:函数、DOM 节点 等,能被 IndexedDB 的数据类型有除 symbol 之外的基本类型、Blob、ArrayBuffer 和 File 等。IndexedDB API 在 Web Worker 中可用。
存储在客户端的数据有两类,分别是持久的和临时的。持久存储要让用户授权;临时存储不需要授权,当达到存储限制时,根据 LRU 策略(最近最少使用)将数据清除,IndexedDB 默认采用临时存储。
浏览器最大存储空间是磁盘剩余空间的 50%,这种限制被称为 global 限制,当限制达到时,origin eviction 程序将会运行,它以 LRU 策略删除整个 origin 的全部数据,直到浏览器存储的数据量低于限制。还有一种限制是 group 限制,每个 group 能分配 global 20% 的存储空间,至少 10M 最大 2G,但不能超过 global 限制。每个 origin 都是某 group 的一部分,如下:
- mozilla.org — group1, origin1
- www.mozilla.org — group1, origin2
- joe.blogs.mozilla.org — group1, origin3
- firefox.com — group2, origin4
上述 mozilla.org、www.mozilla.org 和 joe.blogs.mozilla.org 是同一个 group,它们总共最多可以使用 global 限制的 20%,firefox.com 是单独的 group,它独占 global 限制的 20%。
group 限制是硬限制,不会触发 origin eviction 程序,global 限制是软限制,会触发 origin eviction 程序,当以 LRU 策略释放一些空间之后,数据存储操作还能继续。如果超出了 group 限制,或者 origin eviction 无法释放足够的空间时,浏览器将抛出 QuotaExceededError。
录制工具将 IndexedDB 数据库相关的操作放在 Web Worker 中。
# Web Worker
Web Worker 常用于处理耗时任务,worker 线程中的计算不阻塞主线程,但它与主线程共用 CPU 资源,而且与主线程通信会影响到主线程的性能。
# 传递数据
worker 实例通过 onmessage 接收消息,通过 postMessage 发送消息。主线程与 worker 传递的数据默认要进行结构克隆 (opens new window),对象在传递给 worker 时被序列化,随后在另一端反序列化,当传递的对象非常庞大时,会消耗大量计算资源,降低运行速度。Chrome 17+, Firefox, Opera, Safari, IE10+ 在传递 ArrayBuffer 时有一种名为对象转移 (opens new window)的方式传递数据,对象转移是指将对象引用零成本转交给 worker 的上下文,而不需要进行结构克隆,对象引用转移后,原先上下文就无法访问此对象了。代码如下:
var buffer = new ArrayBuffer(1);
worker.postMessage(buffer, [buffer]);
worker.postMessage({
id: 1,
buffer
}, [buffer]);
结构克隆调用的方法是 structuredClone
# 任务队列
在主线程与 worker 线程之后手动维护一个任务队列,如果前面的 postMessage 还没来得及消费,就不发送新的消息,即便发送了,也没有意义,当排队的任务太多,可能导致 worker 线程卡住,此时除了销毁 worker没有别的办法。