Skip to content

序列化 ​

如果只需要在浏览器本地录制和回放变化,我们可以通过深拷贝 DOM 对象来简单地保存当前视图。例如,以下代码实现(使用 jQuery 的简化示例,仅保存 body 部分):

javascript
// record
const snapshot = $('body').clone();
// replay
$('body').replaceWith(snapshot);

现在我们已经通过在内存中保存整个 DOM 对象实现了一个快照。

但该对象本身不是可序列化的,这意味着我们无法将它保存为某种特定的文本格式(例如 JSON)以便传输。而远程录制正需要这种能力,因此我们需要实现一种序列化 DOM 数据的方法。

我们没有使用现有的开源方案(例如 parse5),原因有两点:

  1. 我们需要实现一种“非标准”的序列化方法,后文会详细讨论。
  2. 这部分代码需要运行在被录制的页面上,我们希望尽可能控制代码量,只保留必要的功能。

序列化中的特殊处理 ​

我们的序列化方法之所以是非标准的,是因为还需要完成以下几部分工作:

  1. 输出必须是描述性的。原始被录制页面中的所有 JavaScript 在回放时都不应被执行。在 rrweb 中,我们通过在快照中将 script 标签替换为占位的 noscript 标签来实现这一点。script 内部的内容不再重要,我们会记录脚本对 DOM 造成的任何变化,因此无需完整记录原始网页上可能存在的大量脚本内容。
  2. 录制未反映在 HTML 中的视图状态。例如,<input type="text" /> 的值不会反映在其 HTML 中,而是由 value 属性承载。我们需要在序列化时读取该值并将其存储为属性,使其呈现为 <input type="text" value="recordValue" />。
  3. 将相对路径转换为绝对路径。回放时,我们会把录制的页面放入 <iframe> 中,此时页面 URL 是回放页面的地址。如果被录制页面中存在相对路径,用户尝试打开时就会出错,因此录制时我们需要转换相对路径。CSS 样式表中的相对路径同样需要转换。
  4. 我们希望记录 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
}

在这个序列化结果中,有两点需要注意:

  1. 遍历 DOM 树时,我们以 Node(节点)为单位。因此,除了 DOM 中的“元素类型”节点外,我们还记录了文本节点(Text Node)、注释节点(Comment Node)等所有其他类型的 Node。
  2. 我们为每个 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;
};