Skip to content

增量快照 ​

在完成全量快照之后,我们还需要录制那些会改变页面状态的事件。

目前,rrweb 会录制以下事件(我们还会不断扩充):

  • DOM 变更
    • 节点的创建、删除
    • 节点属性的变化
    • 文本的变化
  • 鼠标移动
  • 鼠标交互
    • mouse up(鼠标抬起)、mouse down(鼠标按下)
    • click(单击)、double click(双击)、context menu(右键菜单)
    • focus(聚焦)、blur(失焦)
    • touch start、touch move、touch end(触摸开始、移动、结束)
  • 页面或元素滚动
  • 窗口大小变化
  • 输入

MutationObserver ​

由于在回放时不会执行任何 JavaScript,我们需要录制脚本对文档做出的所有更改。

可以考虑这样一个例子:

用户点击一个按钮,页面出现一个下拉菜单。用户选择了第一项,下拉菜单随即消失。

回放时,在执行“点击按钮”之后下拉菜单并不会自动出现,因为原始的 JavaScript 并不属于录制内容。因此,我们需要录制下拉菜单 DOM 节点的创建、第一项的选中,以及随后下拉菜单 DOM 节点的删除。这是最困难的部分。

幸运的是,现代浏览器为我们提供了一个非常强大的 API,恰好可以完成这项工作:MutationObserver。

本文档不会讲解 MutationObserver 的基本用法,只关注与 rrweb 特别相关的方面。

首先需要理解的是,MutationObserver 采用批量异步回调。具体来说,在一系列 DOM 变更发生之后只会触发一次回调,回调会收到一个包含多条变更记录的数组。

这一机制在常规使用中并没有问题,因为我们不仅拥有变更记录,还可以直接访问被变更节点的 DOM 对象,以及它的任意父节点、子节点和兄弟节点。

然而在 rrweb 中,由于存在序列化过程,我们需要更精细的方案来应对各种场景。

添加节点 ​

例如,以下两种操作会生成相同的 DOM 结构,但会产生不同的变更记录集合:

body
  n1
    n2
  1. 创建节点 n1 并将其添加为 body 的子节点,然后创建节点 n2 并将其添加为 n1 的子节点。
  2. 创建节点 n1 和 n2,先将 n2 添加为 n1 的子节点,再将 n1 添加为 body 的子节点。

第一种情况会生成两条变更记录,分别是添加节点 n1 和添加节点 n2;而第二种情况只会生成一条变更记录,即添加节点 n1(包含其子节点)。

注意:在第一种情况下,虽然 n1 被添加时还没有子节点,但由于上述批量异步回调机制,当我们收到变更记录并处理 n1 节点时,它在 DOM 中已经拥有子节点 n2。

受第二种情况影响,在处理新增节点时我们必须遍历它的所有后代节点,以确保所有新节点都被录制;然而这一策略会导致 n2 在第一条记录中被(错误地)录制。随后在处理第二条记录时再次添加该节点,就会导致回放时得到与原页面不一致的 DOM 结构。

因此,在一次回调中处理多条变更记录时,我们需要“惰性”地处理新增节点:即在遍历每条变更记录时,先收集所有原始的、未处理的节点,待所有变更记录都遍历完毕后,再确定这些节点被添加到 DOM 中的先后顺序。在添加这些新节点时,我们会执行去重,确保每个节点只被录制一次,并检查是否有节点被遗漏。

我们已经在序列化设计文档中介绍过,需要维护一个 id -> Node 的映射,因此当新节点出现时,我们需要将新节点序列化并加入该映射。但由于我们希望执行去重,即只在所有变更记录都处理完毕之后才进行序列化,可能会产生一些问题,如以下示例所示:

  1. 变更记录 1,添加节点 n1。我们暂时不会序列化它,因为在等待最终的去重。
  2. 变更记录 2,n1 新增了属性 a1。我们试图将它录制为一条增量快照,但却发现无法从映射中找到 n1 的 id,因为它还没有被序列化。

可以看到,由于我们延迟了对新增节点的序列化,所有变更记录也都需要先处理完毕,之后才能对新节点进行去重,这样才不会产生问题。

删除节点 ​

处理变更记录时,我们可能会遇到一个尚未被序列化的被删除节点。这说明它是一个新增节点,对应的“添加节点”变更记录也在我们收到的这批变更记录之中。我们将这类节点标记为“丢弃节点”。

这里需要处理两种情况:

  1. 由于该节点已经被删除,无需再回放它,因此我们将其从新增节点池中移除。
  2. 这一点同样适用于丢弃节点的后代,因此在处理新增节点时,我们需要检查它是否有一个被丢弃的祖先节点。

属性变化 ​

尽管 MutationObserver 是异步批量回调,我们仍然可以认为同一次回调中各变更发生的时间间隔极短,因此在录制 DOM 属性变化时,我们可以通过覆盖部分数据来优化增量快照的体积。

例如,调整 <textarea> 的大小会触发大量宽高属性各不相同的变更记录。虽然完整记录会让回放更逼真,但也会导致增量快照的数量大幅增加。经过权衡,我们认为在同一次变更回调中,同一节点的某个属性只需记录最终值,即每条后续的变更记录都会覆盖写入之前已存在的那条变更记录的属性变化部分。

鼠标移动 ​

通过录制鼠标的移动位置,我们可以在回放时模拟鼠标的移动轨迹。

为了既保证回放时鼠标移动平滑,又尽量减少对应增量快照的数量,我们需要在监听 mousemove 时进行两层节流。第一层最多每 20 毫秒记录一次鼠标坐标;第二层最多每 500 毫秒发送一次鼠标坐标集合,以确保单个快照不会因累积大量鼠标位置数据而变得过大。

时间倒转 ​

每个增量快照生成时我们都会记录一个时间戳,以便在回放时于正确的时间点应用它。然而,由于节流的影响,鼠标移动对应的增量快照的时间戳会晚于实际录制时间,因此我们需要记录一个负的时间差用于校正,并在回放时进行时间校准。

输入 ​

我们需要观察 <input>、<textarea>、<select> 这三种元素的输入,包括人工输入和程序修改。

人工输入 ​

对于人工输入,我们主要依靠监听 input 和 change 事件。对于同一次人工输入操作所触发的不同事件,有必要进行去重。此外,<input type="radio" /> 也是一种特殊的控件:如果多个 radio 元素具有相同的 name 属性,那么当其中一个被选中时,其他几个会被反选,但这些元素上不会触发任何事件,因此需要单独处理这种情况。

程序修改 ​

直接通过代码设置这些元素的属性不会触发 MutationObserver。我们仍然可以通过劫持相应属性的 setter 来实现监控。示例代码如下:

typescript
function hookSetter<T>(
  target: T,
  key: string | number | symbol,
  d: PropertyDescriptor,
): hookResetter {
  const original = Object.getOwnPropertyDescriptor(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 中的逻辑阻塞被录制页面的正常交互,我们应该将该逻辑放入事件循环中异步执行。