前端录制回放初体验
本篇文章来自团队小伙伴 @陈小信 的一次学习分享,希望跟大家分享与探讨。
求积硅步以致千里,勇于探享生活之美。
问题背景
什么是前端录制回放?
顾名思义,就是录制用户在网页中的各种操作,并且支持能随时回放操作。
为什么需要?
说到需要就不得不说一个经典的场景,一般前端做异常监控和错误上报,会采用自研或接入第三方 SDK
的形式,来收集和上报网站交互过程中 JavaScript
的报错信息和其它相关数据,也就是埋点。
在传统的埋点方案中,根据 SourceMap
能定位到具体报错代码文件和行列信息等。基本能定位大部分场景问题,但有一些情况下是很难复现错误,多是在测试扯皮的时候,程序员口头禅之一(我这里没有报错呀,是不是你电脑有问题)。
要是能把出错的操作过程录制下来就好了,这样就能方便我们复现场景了,且留存证据,好像是自己给自己挖了个坑。
如何实现?
前端能实现录视频?我第一反应就是质疑,接着我就是一波 Google
,发现确实有可行方案。
在 Google
之前,我想到了通过设定定时器,对视图窗口进行截图,截图可用 canvas2html
的方式来实现,但这种方式无疑会造成性能问题,立马否决。
下面介绍我所「知道」的 Google
的方案,如有问题,欢迎指正。
思路初现
网页本质上是一个 DOM
节点形式存在,通过浏览器渲染出来。我们是否可以把 DOM
以某种方式保存起来,并且在不同时间节点持续记录 DOM
数据状态。再将数据还原成 DOM
节点渲染出来完成回放呢?
操作记录
通过 document.documentElement.cloneNode()
克隆到 DOM
的数据对象,此时这个数据不能直接通过接口传输给后端,需要进行一些格式化预处理,处理成方便传输及存储的数据格式。最简单的方式就是进行序列化,也就是转换成 JSON
数据格式。
// 序列化后
let docJSON = {
"type": "Document",
"childNodes": [
{
"type": "Element",
"tagName": "html",
"attributes": {},
"childNodes": [
{
"type": "Element",
"tagName": "head",
"attributes": {},
"childNodes": []
}
]
}
]
}
有完整的 DOM
数据之后,还需要在 DOM
变化时进行监听,记录每次变化的 DOM
节点信息。对数据进行监听可用 MutationObserver
,它是一个可以监听 DOM
变化的 API
。
const observer = new MutationObserver(mutationsList => {
console.log(mutationsList); // 发生变化的数据
});
// 以上述配置开始观察目标节点
observer.observe(document, {});
除了对 DOM
变化进行监听以外,还有一个就是事件监听,用户与网页的交互多是通过鼠标,键盘等输入设备来进行。而这些交互的背后就是 JavaScript
的事件监听。事件监听可以通过绑定系统事件来完成,同样是需要记录下来,以鼠标移动为例:
// 鼠标移动
document.addEventListener('mousemove', e => {
// 伪代码 获取鼠标移动的信息并记录下来
positions.push({
x: clientX,
y: clientY,
timeOffset: Date.now() - timeBaseline,
});
});
addEventListener可以绑定多个相同事件,不影响开发者的事件绑定
回放操作
数据已经有了,接着就是回放,回放本质上是将 JSON
数据还原成 DOM
节点渲染出来。那就将快照数据还原就可以啊「嘴强王者」,数据还原并非那么容易啊!
渲染环境
首先为了确保回放过程代码隔离,需要沙箱环境, iframe
标签可以做到,并且 iframe
提供了 sandbox
属性可配置沙箱。沙箱环境的作用是确保代码安全并且不被干扰。
<iframe sandbox srcdoc></iframe>
sanbox
属性可以做到沙箱作用,点击查看文档
srcdoc
可以直接设置成一段html
代码
数据还原
快照重组主要是 DOM
节点的重组,有点像虚拟 DOM
转成真实文档节点的过程,但是事件类型快照是不需要重组。
定时器
有了数据和环境,还需要定时器。通过定时器不停渲染 DOM
,实质上就是一个播放视频的效果, requestAnimationFrame
是最合适的。
requestAnimationFrame 会在每一帧中执行,从而避免堵塞,是比
setTimeout
更合适的选择
至此有一个大概的想法,距离落地还是有段距离。得益于开源,我们可上 Github 看看有没有合适的轮子可复制(借鉴),刚好有现成的一框架 「「rrweb」」,不妨一起看看。
rrweb 框架
rrweb
是一个前端录制和回放的框架。全称 record and replay the web
,顾名思义就是可以录制和回放 web
界面中的操作,其核心原理就是上面介绍的方案。
rrweb 组成
rrweb
包含三个部分:
rrweb-snapshot
主要处理DOM
结构序列化和重组;rrweb
主要功能是录制和回放;rrweb-player
一个视频播放器 UI 空间
rrweb 使用
npm 安装习以为常,import/require 引入问题不大
录制
通过 rrweb.record
方法来录制页面,emit
回调可接受到录制的数据。
// 1.录制
let events = []; // 记录快照
rrweb.record({
emit(event) {
// 将 event 存入 events 数组中
events.push(event);
},
});
回放
通过 rrweb.Replayer
可回放视频,需要传递录制好的数据。
// 2.回放
const replayer = new rrweb.Replayer(events);
replayer.play();
点击查看官方案例效果
rrweb 源码
按照以上所说的思路,接下来会解析其中一些关键代码,当然只是在我个人理解上做的一些分析,实际上 rrweb
源码远不止这些。
核心部分为三大块: record
(录制)、 replay
回放、 snapshot
快照。
Record 录制
在 DOM
加载完成后,record
会做一次完整的 DOM
序列化,我们把它叫做全量快照,全量快照记录了整个 HTML
数据结构。
在 record.ts
中找到关键的入口函数的定义 init
,入口函数是会在 document
加载完成或(可交互,完成)时调用了 takeFullSnapshot
以及 observe(document)
函数。
if (
document.readyState === 'interactive' ||
document.readyState === 'complete'
) {
init();
} else {
//...
on('load',() => { init(); },),
}
const init = () => {
takeFullSnapshot(); // 生成全量快照
handlers.push(observe(document)); //监听器
};
document.readyState
包含三种状态:
可交互 interactive
;正在加载中 loading
;完成 complete
takeFullSnapshot
从字面意思能看出其作用是生成「完整」的快照,也就是会将 document
序列化出一个完整的数据,称之为 「「全量快照」」。
所有序列化相关操作都是使用 snapshot
完成,snapshot
接受一个 dom
对象和一个配置对象传递 document
将整个页面序列化得到完成的快照数据。
// 生成全量快照
takeFullSnapshot = (isCheckout = false) => {
//...
const [node, idNodeMap] = snapshot(document, {
//...一些配置项
});
//...
}
idNodeMap 是一个
id
为key
,DOM
对象为value
的key-value
键值对对象
observe(document)
是一些监听器的初始化,同样是将整个 document
对象传过去进行监听,通过调用 initObservers
来初始化一些监听器。
const observe = (doc: Document) => {
return initObservers(//...)
}
在 observer.ts
文件中可以找到 initObservers
函数定义,该函数初始化了 11 个监听器,可以分为 DOM
类型 / Event
事件类型 / Media
媒体三大类:
export function initObservers(
// dom
const mutationObserver = initMutationObserver();
const mousemoveHandler = initMoveObserver();
const mouseInteractionHandler = initMouseInteractionObserver();
const scrollHandler = initScrollObserver();
const viewportResizeHandler = initViewportResizeObserver();
// ...
)
DOM
变化监听器,主要有DOM
变化(增删改), 样式变化,核心是通过MutationObserver
来实现let mutationObserverCtor = window.MutationObserver;
const observer = new mutationObserverCtor(
// 处理变化的数据
mutationBuffer.processMutations.bind(mutationBuffer),
);
observer.observe(doc, {});
return observer;交互监听-以鼠标移动
initMoveObserver
为例
// 鼠标移动记录
function initMoveObserver() {
const updatePosition = throttle<MouseEvent | TouchEvent>(
(evt) => {
positions.push({
x: clientX,
y: clientY,
});
});
const handlers = [
on('mousemove', updatePosition, doc),
on('touchmove', updatePosition, doc),
];
}
媒体类型监听器,有 canvas
/video
/audio
,以video
为例,本质上记录播放和暂停状态,mediaInteractionCb
将play
/pause
状态回调出来。
function initMediaInteractionObserver(): listenerHandler {
mediaInteractionCb({
type: type === 'play' ? MediaInteractions.Play : MediaInteractions.Pause,
id: mirror.getId(target as INode),
});
}
Snapshot 快照
snapshot
负责序列化和重组的功能,主要通过 serializeNodeWithId
处理 DOM
序列化和 rebuildWithSN
函数处理 DOM
重组。
serializeNodeWithId
函数负责序列化,主要做了三件事:
调用 serializeNode
序列化Node
;通过 genId()
生成唯一ID 并绑定到Node
中;递归实现序列化子节点,并最终返回一个带 ID
的对象
// 序列化一个带有ID的DOM
export function serializeNodeWithId(n) {
// 1. 序列化 核心函数 serializeNode
const _serializedNode = serializeNode(n);
// 2. 生成唯一ID
let id = genId();
// 绑定ID
const serializedNode = Object.assign(_serializedNode, { id });
// 3.子节点序列化-递归
for (const childN of Array.from(n.childNodes)) {
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
if (serializedChildNode) {
serializedNode.childNodes.push(serializedChildNode);
}
}
}
serializeNodeWithId
核心是通过 serializeNode
序列化 DOM
,针对不同的节点分别做了一些特殊处理。
节点属性的处理:
for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
attributes[name] = transformAttribute(doc, tagName, name, value);
}
处理外联 css
样式,通过 getCssRulesString
获取到具体样式代码,并且储存到 attributes
中。
const cssText = getCssRulesString(stylesheet as CSSStyleSheet);
if (cssText) {
attributes._cssText = absoluteToStylesheet(
cssText,
stylesheet!.href!,
);
}
处理 form
表单,逻辑是保存选中状态,并且做了一些安全处理,例如密码框内容替换成 *
。
if (
attributes.type !== 'radio' &&
attributes.type !== 'checkbox' &&
// ...
) {
attributes.value = maskInputOptions[tagName]
? '*'.repeat(value.length)
: value;
} else if (n.checked) {
attributes.checked = n.checked;
}
canvas
状态保存通过 toDataURL
保存 canvas
数据:
attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL();
「rebuild」 负责重建 DOM
:
通过 buildNodeWithSN
函数重组Node
递归调用 重组子节点
export function buildNodeWithSN(n) {
// DOM 重组核心函数 buildNode
let node = buildNode(n, { doc, hackCss });
// 子节点重建并且appendChild
for (const childN of n.childNodes) {
const childNode = buildNodeWithSN(childN);
if (afterAppend) {
afterAppend(childNode);
}
}
}
Replay 回放
回放部分在 replay.ts
文件中,先创建沙箱环境,接着或进行重建 document
全量快照,在通过 requestAnimationFrame
模拟定时器的方式来播放增量快照。
replay
的构造函数接收两个参数,快照数据 events
和 配置项 config
export class Replayer {
constructor(events, config) {
// 1.创建沙箱环境
this.setupDom();
// 2.定时器
const timer = new Timer();
// 3.播放服务
this.service = new createPlayerService(events, timer);
this.service.start();
}
}
构造函数中最核心三步,创建沙箱环境,定时器,和初始化播放器并且启动。播放器创建依赖 events
和 timer
,本质上还是使用 timer
来实现播放。
沙箱环境
首先,在 replay.ts
的构造函数中可以找打 this.setupDom
的调用,setupDom
核心是通过 iframe
来创建出一个沙箱环境。
private setupDom() {
// 创建iframe
this.iframe = document.createElement('iframe');
this.iframe.style.display = 'none';
this.iframe.setAttribute('sandbox', attributes.join(' '));
}
播放服务
同样在 replay.ts
构造函数中,调用 createPlayerService
函数来创建播放器服务器,该函数在同级目录下的 machine.ts
中定义了,核心思路是通过给定时器 timer
加入需要执行的快照动作 actions
, 在调用 timer.start()
开始回放快照。
export function createPlayerService() {
//...
play(ctx) {
// 获取每个 event 执行的 doAction 函数
for (const event of needEvents) {
//..
const castFn = getCastFn(event);
actions.push({
doAction: () => {
castFn();
}
})
//..
}
// 添加到定时器队列中
timer.addActions(actions);
// 启动定时器播放 视频
timer.start();
},
//...
}
播放服务使用到第三方库
@xstate/fsm
状态机来控制各种状态(播放,暂停,直播)
定时器 timer.ts
也是在同级目录下,核心是通过 requestAnimationFrame
实现了定时器功能, 并对快照回放,以队列的形式存储需要播放的快照 actions
,接着在 start
中递归调用 action.doAction
来实现对应时间节点的快照还原。
export class Timer {
// 添加队列
public addActions(actions: actionWithDelay[]) {
this.actions = this.actions.concat(actions);
}
// 播放队列
public start() {
function check() {
// ...
// 循环调用actions中的doAction 也就是 castFn 函数
while (actions.length) {
const action = actions[0];
actions.shift();
// doAction 会对快照进行回放动作,针对不同快照会执行不同动作
action.doAction();
}
if (actions.length > 0 || self.liveMode) {
self.raf = requestAnimationFrame(check);
}
}
this.raf = requestAnimationFrame(check);
}
}
doAction
在不同类型快照会执行不同动作,在播放服务中 doAction
最终会调用 getCastFn
函数来做了一些 case
:
private getCastFn(event: eventWithTime, isSync = false) {
switch (event.type) {
case EventType.DomContentLoaded: //dom 加载解析完成
case EventType.FullSnapshot: // 全量快照
case EventType.IncrementalSnapshot: //增量
castFn = () => {
this.applyIncremental(event, isSync);
}
}
}
applyIncremental
函数会增对不同的增量快照做不同处理,包含 DOM
增量, 鼠标交互,页面滚动等,以DOM
增量快照的 case
为例,最终会走到 applyMutation
中:
private applyIncremental(){
switch (d.source) {
case IncrementalSource.Mutation: {
this.applyMutation(d, isSync); // DOM变化
break;
}
case IncrementalSource.MouseMove: //鼠标移动
case IncrementalSource.MouseInteraction: //鼠标点击事件
//...
}
applyMutation
才是最终执行 DOM
还原操作的地方,包含 DOM
的增删改步骤:
private applyMutation(d: mutationData, useVirtualParent: boolean) {
d.removes.forEach((mutation) => {
//.. 移除dom
});
const appendNode = (mutation: addedNodeMutation) => {
// 添加dom到具体节点下
};
d.adds.forEach((mutation) => {
// 添加
appendNode(mutation);
});
d.texts.forEach((mutation) => {
//...文本处理
});
d.attributes.forEach((mutation) => {
//...属性处理
});
}
以上就是回放的关键流程实现代码,rrweb
中不仅仅是做了这些,还包含数据压缩,移动端处理,隐私问题等等细节处理,有兴趣可自行查看源码。
最后
这种实现录制回放思路确实值得学习,读 rrweb
源码的过程也受益颇多,源码中对数据结构的一些使用,例如双链表,队列,树等也值得一览。
以上便是本次分享的全部内容,希望对你有所帮助 ^_^
喜欢的话别忘了动动手指,点赞、收藏、关注三连一波带走。
关于我们
我们是万拓科创前端团队,左手组件库,右手工具库,各种技术野蛮生长。
一个人跑得快,不如一群人跑得远。欢迎加入我们的小分队,牛年牛气轰轰往前冲。
参考文章
rrweb-io/rrweb rrweb:打开 web 页面录制与回放的黑盒子