本篇文章来自团队小伙伴 @陈小信 的一次学习分享,希望跟大家分享与探讨。 求积硅步以致千里,勇于探享生活之美。

前端录制回放系统初体验
顾名思义,就是录制用户在网页中的各种操作,并且支持能随时回放操作。
说到需要就不得不说一个经典的场景,一般前端做异常监控和错误上报,会采用自研或接入第三方 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 是一个前端录制和回放的框架。全称 record and replay the web ,顾名思义就是可以录制和回放 web 界面中的操作,其核心原理就是上面介绍的方案。
rrweb 包含三个部分:
rrweb-snapshot 主要处理 DOM 结构序列化和重组;rrweb 主要功能是录制和回放;rrweb-player 一个视频播放器 UI 空间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 快照。
在 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;completetakeFullSnapshot 从字面意思能看出其作用是生成「完整」的快照,也就是会将 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 负责序列化和重组的功能,主要通过 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 函数重组 Nodeexport 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.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 源码的过程也受益颇多,源码中对数据结构的一些使用,例如双链表,队列,树等也值得一览。
以上便是本次分享的全部内容,希望对你有所帮助 ^_^
喜欢的话别忘了动动手指,点赞、收藏、关注三连一波带走。
我们是万拓科创前端团队,左手组件库,右手工具库,各种技术野蛮生长。
一个人跑得快,不如一群人跑得远。欢迎加入我们的小分队,牛年牛气轰轰往前冲。