序列化
如果只需要在浏览器本地录制和回放变化,我们可以通过深拷贝 DOM 对象来简单地保存当前视图。例如,以下代码实现(使用 jQuery 的简化示例,仅保存 body 部分):
javascript
// record
const snapshot = $('body').clone();
// replay
$('body').replaceWith(snapshot);现在我们已经通过在内存中保存整个 DOM 对象实现了一个快照。
但该对象本身不是可序列化的,这意味着我们无法将它保存为某种特定的文本格式(例如 JSON)以便传输。而远程录制正需要这种能力,因此我们需要实现一种序列化 DOM 数据的方法。
我们没有使用现有的开源方案(例如 parse5),原因有两点:
- 我们需要实现一种“非标准”的序列化方法,后文会详细讨论。
- 这部分代码需要运行在被录制的页面上,我们希望尽可能控制代码量,只保留必要的功能。
序列化中的特殊处理
我们的序列化方法之所以是非标准的,是因为还需要完成以下几部分工作:
- 输出必须是描述性的。原始被录制页面中的所有 JavaScript 在回放时都不应被执行。在 rrweb 中,我们通过在快照中将
script标签替换为占位的noscript标签来实现这一点。script 内部的内容不再重要,我们会记录脚本对 DOM 造成的任何变化,因此无需完整记录原始网页上可能存在的大量脚本内容。 - 录制未反映在 HTML 中的视图状态。例如,
<input type="text" />的值不会反映在其 HTML 中,而是由value属性承载。我们需要在序列化时读取该值并将其存储为属性,使其呈现为<input type="text" value="recordValue" />。 - 将相对路径转换为绝对路径。回放时,我们会把录制的页面放入
<iframe>中,此时页面 URL 是回放页面的地址。如果被录制页面中存在相对路径,用户尝试打开时就会出错,因此录制时我们需要转换相对路径。CSS 样式表中的相对路径同样需要转换。 - 我们希望记录 CSS 样式表的内容。如果被录制页面链接了外部样式表,我们可以从浏览器中获取其解析后的 CSS 规则,生成一个包含所有这些规则的内联样式表。这样一来,那些并非始终可访问的样式表(例如位于内网或 localhost 上)也会被包含在录制中,从而能够被正确回放。
唯一标识
同时,我们的序列化还应包含全量和增量两种类型。全量序列化可以将一棵 DOM 树转换为对应的树状数据结构。
例如,下面的 DOM 树:
html
<html>
<body>
<header></header>
</body>
</html>将被序列化为如下数据结构:
json
{
"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(节点)为单位。因此,除了 DOM 中的“元素类型”节点外,我们还记录了文本节点(Text Node)、注释节点(Comment Node)等所有其他类型的 Node。
- 我们为每个 Node 添加了唯一标识
id,用于后续的增量快照。
想象一下,如果我们录制了对同一页面上某个按钮的点击并进行回放,可以用以下格式记录该操作(也就是我们所说的增量快照):
typescript
type clickSnapshot = {
source: 'MouseInteraction';
type: 'Click';
node: HTMLButtonElement;
};通过 snapshot.node.click() 即可再次执行该操作。
然而在实际场景中,尽管我们重建了完整的 DOM,却没有办法将增量快照中发生交互的 DOM 节点与现有的 DOM 关联起来。
这正是引入标识 id 的原因。我们在录制端和回放端都维护着随时间推移完全一致的 id -> Node 映射,并且双方都会在 DOM 节点创建和销毁时更新它,确保快照中使用唯一递增的数字;回放时只需根据记录的 id 就能找到对应的 DOM 节点。
上面示例中的数据结构相应地变为:
typescript
type clickSnapshot = {
source: 'MouseInteraction';
type: 'Click';
id: Number;
};