前端录制回放系统初体验
顾名思义,就是录制用户在网页中的各种操作,并且支持能随时回放操作。
说到需要就不得不说一个经典的场景,一般前端做异常监控和错误上报,会采用自研或接入第三方 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
;- 完成
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
负责序列化和重组的功能,主要通过 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.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
源码的过程也受益颇多,源码中对数据结构的一些使用,例如双链表,队列,树等也值得一览。
以上便是本次分享的全部内容,希望对你有所帮助 ^_^
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/MgBkL2ZmbKKApzSXEfdctg
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。