基于rrweb实现⽹页远控功能
⽬录
点击折叠/展开
简介
rrweb 是 'record and replay the web' 的简写,旨在利⽤现代浏览器所提供的强⼤ API 录制并回放任意 web 界⾯中的⽤户操作。
设计初衷是为了解决我们在客户环境 debug 时遇到的⼀些问题。⼤多数产品通常部署在客户的内⽹环境中,因此⼀旦出现问题只能通过各类远程操作⼯具登⼊客户环境中进⾏ debug,操作的空间和时间都⾮常有限。如果不幸遇到⼀些偶发性的问题,复现就变得难上加难,debug 更是⽆从谈起。
在这种情况下,前端的异常监控及对应数据的收集显得⾮常重要,但是传统的收集错误栈信息的⽅式并不能给我们提供⾜够的信息⽤于定位问题。
设计
序列化
如果仅仅需要在本地录制和回放浏览器内的变化,那么我们可以简单地通过深拷贝 DOM 来实现当前视图的保存。例如通过以下的代码实现(使⽤ jQuery 简化⽰例,仅保存body 部分):
// record
const snapshot = $("body").clone();
// replay
$("body").replaceWith(snapshot);
我们通过将 DOM 对象整体保存在内存中实现了快照。
但是这个对象本⾝并不是可序列化的,因此我们不能将其保存为特定的⽂本格式(例如 JSON)进⾏传输,也就⽆法做到远程录制,所以我们⾸先需要实现将 DOM 及其视图状态序列化的⽅法。在这⾥我们不使⽤⼀些开源⽅案例如 的原因包含两个⽅⾯:
我们需要实现⼀个“⾮标准”的序列化⽅法,下⽂会详细展开。
此部分代码需要运⾏在被录制的页⾯中,要尽可能的控制代码量,只保留必要功能。
序列化中的特殊处理
之所以说我们的序列化⽅法是⾮标准的是因为我们还需要做以下⼏部分的处理:
去脚本化。被录制页⾯中的所有 JavaScript 都不应该被执⾏,例如我们会在重建快照时将 script 标签改为 noscript 标签,此时 script 内部的内容就不再重要,录制时可以简单记录⼀个标记值⽽不需要将可能存在的⼤量脚本内容全部记录。
记录没有反映在 HTML 中的视图状态。例如 <input type="text" /> 输⼊后的值不会反映在其 HTML 中,⽽是通过 value 属性记录,我们在序列化时就需要读出该值并且以属性的形式回放成 <input type="text" value="recordValue" />。
相对路径转换为绝对路径。回放时我们会将被录制的页⾯放置在⼀个 <iframe> 中,此时的页⾯ URL 为重放页⾯的地址,如果被录制页⾯中有⼀些相对路径就会产⽣错误,所以在录制时就要将相对路径进⾏转换,同样的 CSS 样式表中的相对路径也需要转换。
尽量记录 CSS 样式表的内容。如果被录制页⾯加载了⼀些同源的样式表,我们则可以获取到解析好的 CSS rules,录制时将能获取到的样式都 inline 化,这样可以让⼀些内⽹环境(如 localhost)的录制也有⽐较好的效果。
唯⼀标识
同时,我们的序列化还应该包含全量和增量两种类型,全量序列化可以将⼀个 DOM 树转化为对应的树状数据结构。
例如以下的 DOM 树:
<html>
<body>
<header></header>
</body>
</html>
会被序列化成类似这样的数据结构:
{
"type": "Document",
"childNodes": [
{
"type": "Element",
"tagName": "html",
"attributes": {},
"childNodes": [
{
"type": "Element",
"tagName": "head",
"attributes": {},
"childNodes": [],
"id": 3
},
{
"type": "Element",
"tagName": "body",
"attributes": {},
"childNodes": [
{
"type": "Text",
"textContent": "\n ",
"id": 5
},
{
"type": "Element",
"tagName": "header",
"attributes": {},
"childNodes": [
{
"type": "Text",
"textContent": "\n ",
"id": 7
}
],
"id": 6
}
],
"id": 4
}
],
"id": 2
}
],
"id": 1
}
这个序列化的结果中有两点需要注意:
我们遍历 DOM 树时是以 Node 为单位,因此除了场景的元素类型节点以为,还包括 Text Node、Comment Node 等所有 Node 的记录。
我们给每⼀个 Node 都添加了唯⼀标识 id,这是为之后的增量快照做准备。
想象⼀下如果我们在同页⾯中记录⼀次点击按钮的操作并回放,我们可以⽤以下格式记录该操作(也就是我们所说的⼀次增量快照):
type clickSnapshot = {
source: "MouseInteraction",
type: "Click",
node: HTMLButtonElement,
};
再通过 de.click() 就能将操作再执⾏⼀次。
但是在实际场景中,虽然我们已经重建出了完整的 DOM,但是却没有办法将增量快照中被交互的 DOM 节点和已存在的 DOM 关联在⼀起。
这就是唯⼀标识 id 的作⽤,我们在录制端和回放端维护随时间变化完全⼀致的 id -> Node 映射,并随着 DOM 节点的创建和销毁进⾏同样的更新,保证我们在增量快照中只需要记录 id 就可以在回放时到对应的 DOM 节点。
上述⽰例中的数据结构相应的变为:
type clickSnapshot = {
source: "MouseInteraction";
type: "Click";
id: Number;
};
增量快照
在完成⼀次全量快照之后,我们就需要基于当前视图状态观察所有可能对视图造成改动的事件,在 rrweb 中我们已经观察了以下事件(将不断增加):
DOM 变动
节点创建、销毁
节点属性变化
⽂本变化
⿏标移动
⿏标交互
mouse up、mouse down
click、double click、context menu
focus、blur
touch start、touch move、touch end
页⾯或元素滚动
视窗⼤⼩改变
输⼊
Mutation Observer
由于我们在回放时不会执⾏所有的 JavaScript 脚本,所以例如这样的场景我们需要完整记录才能够实现回放:
点击 button,出现 dropdown menu,选择第⼀项,dropdown menu 消失
回放时,在“点击 button”执⾏之后 dropdown menu 不会⾃动出现,因为已经没有 JavaScript 脚本为我们执⾏这件事,所以我们需要记录 dropdown menu 相关的DOM 节点的创建以及后续的销毁,这也是录制中的最⼤难点。
好在现代浏览器已经给我们提供了⾮常强⼤的 API —— ⽤来完成这⼀功能。
此处我们不具体讲解 MutationObserver 的基本使⽤⽅式,只专注于在 rrweb 中我们需要做哪些特殊处理。
⾸先要了解 MutationObserver 的触发⽅式为批量异步回调,具体来说就是会在⼀系列 DOM 变化发⽣之后将这些变化⼀次性回调,传出的是⼀个 mutation 记录数组。
这⼀机制在常规使⽤时不会有问题,因为从 mutation 记录中我们可以获取到变更节点的 JS 对象,可以做很多等值⽐较以及访问⽗⼦、兄弟节点等操作来保证我们可以精准回放⼀条 mutation 记录。
但是在 rrweb 中由于我们有序列化的过程,我们就需要更多精细的判断来应对各种场景。
新增节点
例如以下两种操作会⽣成相同的 DOM 结构,但是产⽣不同的 mutation 记录:
body
htmlbutton属性n1
n2
创建节点 n1 并 append 在 body 中,再创建节点 n2 并 append 在 n1 中。
创建节点 n1、n2,将 n2 append 在 n1 中,再将 n1 append 在 body 中。
第 1 种情况将产⽣两条 mutation 记录,分别为增加节点 n1 和增加节点 n2;第 2 种情况则只会产⽣⼀条 mutation 记录,即增加节点 n1。
注意,在第⼀种情况下虽然 n1 append 时还没有⼦节点,但是由于上述的批量异步回调机制,当我们处理 mutation 记录时获取到的 n1 是已经有⼦节点 n2 的状态。
受第⼆种情况的限制,我们在处理新增节点时必须遍历其所有⼦孙节点,才能保证所有新增节点都被记录,但是这⼀策略应⽤在第⼀种情况中就会导致 n2 被作为新增节点记录两次,回放时就会产⽣与原页⾯不⼀致的 DOM 结构。
因此,在处理⼀次回调中的多个 mutation 记录时我们需要“惰性”处理新增节点,即在遍历每条 mutation 记录遇到新增节点时先收集,再在全部 mutation 遍历完毕之后对收集的新增节点进⾏去重操作,保证不遗漏节点的同时每个节点只被记录⼀次。
在中已经介绍了我们需要维护⼀个 id -> Node 的映射,因此当出现新增节点时,我们需要将新节点序列化并加⼊映射中。但由于我们为了去重新增节点,选择在所有 mutation 记录遍历完毕之后才进⾏序列化,在以下⽰例中就会出现问题:
mutation 记录 1,新增节点 n1。我们暂不处理,等待最终去重后序列化。
mutation 记录 2,n1 新增属性 a1。我们试图将它记录成⼀次增量快照,但会发现⽆法从映射中到 n1 对应的 id,因为此时它还未被序列化。
由此可见,由于我们对新增节点进⾏了延迟序列化的处理,所有 mutation 记录也都需要先收集,再新增节点去重并序列化之后再做处理。
移除节点
在处理移除节点时,我们需要做以下处理:
移除的节点还未被序列化,则说明是在本次 callback 中新增的节点,⽆需记录,并且从新增节点池中将其移除。
上步中在⼀次 callback 中被新增⼜移除的节点我们将其称为 dropped node,⽤于最终处理新增节点时判断节点的⽗节点是否已经 drop。
属性变化覆盖写
尽管 MutationObserver 是异步批量回调,但是我们仍然可以认为在⼀次回调中发⽣的 mutations 之间时间间隔极短,因此在记录 DOM 属性变化时我们可以通过覆盖写的⽅式优化增量快照的体积。
例如对⼀个 <textarea> 进⾏ resize 操作,会触发⼤量的 width 和 height 属性变化的 mutation 记录。虽然完整记录会让回放更加真实,但是也可能导致增量快照数量⼤⼤增加。进⾏取舍之后,我认为在同⼀次 mutation callback 中只记录同⼀个节点某⼀属性的最终值即可,也就是后续的 mutation 记录会覆盖写之前已有的 mutation 记录中的属性变化部分。
⿏标移动
通过记录⿏标移动位置,我们可以在回放时模拟⿏标移动轨迹。
尽量保证回放时⿏标移动流畅的同时也要尽量减少对应增量快照的数量,所以我们需要在监听 mousemove 的同时进⾏两层节流处理。第⼀层是每 20 ms 最多记录⼀次⿏标坐标,第⼆层是每 500 ms 最多发送⼀次⿏标坐标集合,第⼆层的主要⽬的是避免⼀次请求内容过多⽽做的分段。
时间逆推
我们在每个增量快照⽣成时会记录⼀个时间戳,⽤于在回放时按正确的时间差回放。但是由于节流处
理的影响,⿏标移动对应增量快照的时间戳会⽐实际记录时间要更晚,因此我们需要记录⼀个⽤于校正的负时间差,在回放时将时间校准。
输⼊
我们需要观察 <input>, <textarea>, <select> 三种元素的输⼊,包含⼈为交互和程序设置两种途径的输⼊。
⼈为交互
对于⼈为交互的操作我们主要靠监听 input 和 change 两个事件观察,需要注意的是对不同事件但值相同的情况进⾏去重。此外 <input type="radio" /> 也是⼀类特殊的控件,如果多个 radio 元素的组件 name 属性相同,那么当⼀个被选择时其他都会被反选,但是不会触发任何事件,因此我们需要单独处理。
程序设置
通过代码直接设置这些元素的属性也不会触发事件,我们可以通过劫持对应属性的 setter 来达到监听的⽬的,⽰例代码如下:
function hookSetter<T>(
target: T,
key: string | number | symbol,
d: PropertyDescriptor
): hookResetter {
const original = OwnPropertyDescriptor(target, key);
Object.defineProperty(target, key, {
set(value) {
// put hooked setter into event loop to avoid of set latency
setTimeout(() => {
d.set!.call(this, value);
}, 0);
if (original && original.set) {
original.set.call(this, value);
}
},
});
return () => hookSetter(target, key, original || {});
}
注意为了避免我们在 setter 中的逻辑阻塞被录制页⾯的正常交互,我们应该把逻辑放⼊ event loop 中异步执⾏。
回放
rrweb 的设计原则是尽量少的在录制端进⾏处理,最⼤程度减少对被录制页⾯的影响,因此在回放端我们需要做⼀些特殊的处理。
⾼精度计时器
在回放时我们会⼀次性拿到完整的快照链,如果将所有快照依次同步执⾏我们可以直接获取被录制页⾯最后的状态,但是我们需要的是同步初始化第⼀个全量快照,再异步地按照正确的时间间隔依次重放每⼀个增量快照,这就需要⼀个⾼精度的计时器。
之所以强调⾼精度,是因为原⽣的 setTimeout 并不能保证在设置的延迟时间之后准确执⾏,例如主线程阻塞时就会被推迟。
对于我们的回放功能⽽⾔,这种不确定的推迟是不可接受的,可能会导致各种怪异现象的发⽣,因此我们通过 requestAnimationFrame 来实现⼀个不断校准的定时器,确保绝⼤部分情况下增量快照的重放延迟不超过⼀帧。
同时⾃定义的计时器也是我们实现“快进”功能的基础。
补全缺失节点
在中提到了 rrweb 使⽤ MutationObserver 时的延迟序列化策略,这⼀策略可能导致以下场景中我们不能记录完整的增量快照:
parent
child2
child1
parent 节点插⼊⼦节点 child1
parent 节点在 child1 之前插⼊⼦节点 child2
按照实际执⾏顺序 child1 会被 rrweb 先序列化,但是在序列化新增节点时我们除了记录⽗节点之外还需要记录相邻节点,从⽽保证回放时可以把新增节点放置在正确的位置。但是此时 child 1 相邻节点 c
hild2 已经存在但是还未被序列化,我们会将其记录为 id: -1(不存在相邻节点时 id 为 null)。
重放时当我们处理到新增 child1 的增量快照时,我们可以通过其相邻节点 id 为 -1 这⼀特征知道帮助它定位的节点还未⽣成,然后将它临时放⼊”缺失节点池“中暂不插⼊DOM 树中。
之后在处理到新增 child2 的增量快照时,我们正常处理并插⼊ child2,完成重放之后检查 child2 的相邻节点 id 是否指向缺失节点池中的某个待添加节点,如果吻合则将其从池中取出插⼊对应位置。
模拟 Hover
在许多前端页⾯中都会存在 :hover 选择器对应的 CSS 样式,但是我们并不能通过 JavaScript 触发 hover 事件。因此回放时我们需要模拟 hover 事件让样式正确显⽰。
具体⽅式包括两部分:
遍历 CSS 样式表,对于 :hover 选择器相关 CSS 规则增加⼀条完全⼀致的规则,但是选择器为⼀个特殊的 class,例如 .:hover。
当回放 mouse up ⿏标交互事件时,为事件⽬标及其所有祖先节点都添加 .:hover 类名,mouse down 时再对应移除。
从任意时间点开始播放
除了基础的回放功能之外,我们还希望 rrweb-player 这样的播放器可以提供和视频播放器类似的功能,如拖拽到进度条⾄任意时间点播放。
实际实现时我们通过给定的起始时间点将快照链分为两部分,分别是时间点之前和之后的部分。然后同步执⾏之前的快照链,再正常异步执⾏之后的快照链就可以做到从任意时间点开始播放的效果。
沙盒
在中我们提到了“去脚本化”的处理,即在回放时我们不应该执⾏被录制页⾯中的 JavaScript,在重建快照的过程中我们将所有 script 标签改写为 noscript 标签解决了部分问题。但仍有⼀些脚本化的⾏为是不包含在 script 标签中的,例如 HTML 中的 inline script、表单提交等。
脚本化的⾏为多种多样,如果仅过滤已知场景难免有所疏漏,⽽⼀旦有脚本被执⾏就可能造成不可逆的⾮预期结果。因此我们通过 HTML 提供的 iframe 沙盒功能进⾏浏览器层⾯的限制。
iframe sandbox
我们在重建快照时将被录制的 DOM 重建在⼀个 iframe 元素中,通过设置它的 sandbox 属性,我们可以禁⽌以下⾏为:
表单提交
window.open 等弹出窗
JS 脚本(包含 inline event handler 和 <URL> )
这与我们的预期是相符的,尤其是对 JS 脚本的处理相⽐⾃⾏实现会更加安全、可靠。
避免链接跳转
当点击 a 元素链接时默认事件为跳转⾄它的 href 属性对应的 URL。在重放时我们会通过重建跳转后页⾯ DOM 的⽅式保证视觉上的正确重放,⽽原本的跳转则应该被禁⽌执⾏。
通常我们会通过事件代理捕获所有的 a 元素点击事件,再通过 event.preventDefault() 禁⽤默认事件。但当我们将回放页⾯放在沙盒内时,所有的 event handler 都将不被执⾏,我们也就⽆法实现事件代理。
重新查看我们回放交互事件增量快照的实现,我们会发现其实 click 事件是可以不被重放的。因为在禁⽤ JS 的情况下点击⾏为并不会产⽣视觉上的影响,也⽆需被感知。
不过为了优化回放的效果,我们可以在点击时给模拟的⿏标元素添加特殊的动画效果,⽤来提⽰观看者此处发⽣了⼀次点击。
iframe 样式设置
由于我们将 DOM 重建在 iframe 中,所以我们⽆法通过⽗页⾯的 CSS 样式表影响 iframe 中的元素。但是在不允许 JS 脚本执⾏的情况下 noscript 标签会被显⽰,⽽我们希望将其隐藏,就需要动态的向 iframe 中添加样式,⽰例代码如下:
const injectStyleRules: string[] = [
"iframe { background: ##f1f3f5 }",
"noscript { display: none !important; }",
];
const styleEl = ateElement("style");
const { documentElement, head } = tDocument!;
documentElement!.insertBefore(styleEl, head);
for (let idx = 0; idx < injectStyleRules.length; idx++) {
(styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx);
}
需要注意的是这个插⼊的 style 元素在被录制页⾯中并不存在,所以我们不能将其序列化,否则 id -> Node 的映射将出现错误。
结构
rrweb 主要由 3 部分组成:
rrweb-snapshot
包含 snapshot 和 rebuild 两个功能。snapshot ⽤于将 DOM 及其状态转化为可序列化的数据结构并添加唯⼀标识;rebuild 则是将 snapshot 记录的数据结构重建为对应的DOM。
rrweb
包含 record 和 replay 两个功能。record ⽤于记录 DOM 中的所有变更(mutation);
replay 则是将记录的变更按照对应的时间⼀⼀重放。
rrweb-player
为 rrweb 提供⼀套 UI 控件,提供基于 GUI 的暂停、快进、拖拽⾄任意时间点播放等功能。
缺陷
rrweb 对dom节点复制,然后通过序列化的快照数据在沙盒化回放过程中解析复原,并可以通过节点标志对必要的隐私内容进⾏屏蔽,很好的解决了传统通过视频录屏⽅式的数据量⼤、⽆法保护隐私、分辨率低的问题。
但同时 rrweb 因为这样的沙盒化设计,也会存在⼀些根本缺陷。
⾮常规节点录制
⽐如对canvas、iframe、pdf嵌⼊式的节点和其他通过 js 动态执⾏变化的⾮常规dom节点,并不能完整保存。
为了解决这个问题,则需要在指定节点上设置⼀些标记,并关闭回放时容器的沙盒化属性,让相关 js 可以同步执⾏,但同时也会带来更严重的问题,在进⾏直播时的双⽅ js 操作的同步,记录 js 的操作则会产⽣⼤量数据,从⽽引发性能问题,⽐如 echarts 数据图的渲染。
⽆法进⾏局部录制
当前的 rrweb 只能进⾏全页⾯录制,造成这种问题的原因是 dom 节点中存在的样式,很⼤可能是通过⽗级节点的样式表上继承⽽来的,⼦级节点实际上并没有设置任何样式,如果此时想只录制⼦节点的内容,则会丢失这些样式,从⽽引发更多关联节点的排版异常。
直播时对⽹络稳定性的要求较⾼
rrweb 通过全量、增量快照的⽅式进⾏序列化传输,使⽤时间戳作为排列顺序的依据,受⽹络传输速度的影响,每次发送/接收的数据的耗时不同,就会在解析渲染时造成视觉上的卡顿和不连贯。我们可以为其设置⼀个缓冲值,让每次传输都⼈为的按照⼤于最⾼传输耗时的固定延时进⾏渲染,这样当每次渲染的时候,所有所需的数据都已经返回,虽然在双⽅操作时会有延迟,但不会出现卡顿的现象。
但是我们需要对这样的缓冲操作设置⼀个让⼈可以接受的时间,如果⽹络不稳定,那最低、最⾼传输耗时就会相差很⼤,如果把缓冲时间设置的过久,那么就会造成直播不同步,并阻塞主线程事件;如果设置的过短,那么就会出现卡顿现象,所以使⽤直播功能对⽹络稳定性的要求较⾼。
在⼀定程度上使原⽹页性能降低
由于 rrweb 在⽹页操作过程中,会使⽤MutationObserver对所有发⽣变化的节点进⾏异步监控,并相应地执⾏其他操作,需要把这些任务都放进event loop中,
远控
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论