rrweb:打开 web 页面录制与回放的黑盒子

前段时间开源了我们的 web 录制、回放基础库 rrweb,它可以将页面中的 DOM 以及用户操作保存为可序列化的数据,以实现远程回放。

研发这一工具起初是为了解决我们在客户环境 debug 时遇到的一些问题。

我们的产品通常部署在客户的内网环境中,因此一旦出现问题只能通过各类远程操作工具登入客户环境中进行 debug,操作的空间和时间都非常有限。如果不幸遇到一些偶发性的问题,复现就变得难上加难,debug 更是无从谈起。

在这种情况下,前端的异常监控及对应数据的收集显得非常重要,但是传统的收集错误栈信息的方式并不能给我们提供足够的信息用于定位问题。

在进一步调研的过程中我们发现了 LogRocket 这样的工具能够提供像素级的录制与回放,非常适用于我们的场景。但该类产品通常为 SAAS 服务,客户的内网环境很可能无法连接,因此也无法被使用。

最终我们决定自行实现 web 录制与回放这一套功能,在开发的过程中我们发现它还可以被应用于很多场景,例如:

所以我们把其中最通用的部分作为独立的代码仓库开源,方便其他开发者使用。

下文中将具体说说 rrweb 设计的演进过程以及其中的关键技术细节。

回放的基础:DOM 快照

页面中的视图状态可以通过 DOM 树的形式描述,所以当我们尝试录制一个页面时,我们实际上是在记录 DOM 树在各个时间点上的状态,在 rrweb 中我们称一次这样的状态记录为一个快照。

序列化

如果仅仅需要在本地录制和回放,那么我们可以简单地深拷贝 DOM。例如以下的代码:

// deep clone document element
const docEl = document.documentElement.cloneNode(true);
// replay later
document.replaceChild(docEl, document.documentElement);

我们通过将 DOM 对象深克隆在内存中就实现了快照。

但是这个快照对象本身并不是可序列化的,因此我们不能将其保存为特定的文本格式(例如 JSON)进行传输,也就无法做到远程录制。所谓不可序列化是指虽然我们可以通过 innerHTML 等方式获取到描述 DOM 的文本格式,但其中会丢失一些视图状态,例如 <input/> 元素的 value 就不一定会记录在 HTML 中。

所以我们首先需要实现将 DOM 及其视图状态序列化的方法。在这里我们不使用一些开源方案例如 parse5 的原因包含两个方面:

  1. 我们需要实现一个“非标准”的序列化方法。
  2. 此部分代码需要运行在被录制的页面中,要尽可能的控制代码量,只保留必要功能。

之所以说我们的序列化方法是非标准的是因为我们还需要做以下几部分的处理:

  1. 去脚本化,被录制页面中的所有 JavaScript 都不应该被执行。
  2. 记录没有反映在 HTML 中的视图状态。例如 <input type="text" /> 输入后的值不会反映在其 HTML 中,我们需要读取其 value 值并加以记录。
  3. 相对路径转换为绝对路径。回放时页面 URL 为重放页面的地址,如果被录制页面中有一些相对路径就会产生错误。
  4. 尽量记录 CSS 样式表的内容。如果被录制页面加载了一些同源的样式表,我们则可以获取到解析好的 CSS rules,录制时将能获取到的样式都 inline 化,这样可以让一些内网环境(如 localhost)的录制也有比较好的回放效果。

初次尝试:定时快照

当我们完成了可序列化的 DOM 快照实现之后,映入脑海的第一个思路就是定时对页面制作快照完成录制,回放时只需按照时间间隔依次重建快照即可。

但稍加思考之后我们会发现这个方案有两大弊端。

首先是两次快照之间的时间间隔难以平衡,如果间隔过短那么可能产生大量无区别的快照,最终的总体积也会非常大,甚至大于同样时长的视频文件;而如果间隔过长那么就会遗漏两次间隔之间的视图变化,可能导致一些关键性操作没有被录制。

其次是我们无法感知视图变化的原因,也就无法从中解析出用户的行为加以分析。

虽然定时快照的方案并不可行,但是指明了我们需要解决的两个核心问题:

  1. 应该基于导致视图的变更制作快照。
  2. 要控制录制结果的体积。

再次尝试:基于变更制作快照

第一个优化的方向是明确制作快照的时机,应该在每次视图变更时制作一次快照。这样既不会有不必要的快照,也不会遗漏视图变化。

在实际的 web 应用中视图的变更非常频繁,而且绝大部分都是局部的变更,因此每一次变更对应一个完整快照的思路虽然保证了快照数量上没有浪费,但在每个快照的内容中依然有大量重复的部分,全部记录下来还是一种不必要的冗余。

基于快照 diff 的优化思路

为了消除上述的快照中的冗余数据,最直观的思路就是将每一个快照与其前一个快照进行 diff,找出变更的部分加以记录。

由于我们的快照数据结构是和 DOM 树相类似的树状结构,因此在 DOM 树较为复杂时 diff 的开销将会非常高,甚至阻塞被录制页面的正常交互,进而影响用户体验。

这样的高侵入性显然与我们的预期是不相符的,所以我们还需要追溯视图变更的根本原因——引发变更的操作。

最终录制方案:快照 + Oplog

我们可以把引发视图变更的操作归为以下几类:

对于每个操作我们只需要记录其操作类型和相关的数据,就可以在回放时重现对应的操作,也就回放了该操作对视图的改变。

这样我们只需要在开始录制时制作一个完整的 DOM 快照,之后则记录所有的操作数据,这些操作数据我们称之为 Oplog(operations log),这一思路和 log-structured file system 是类似的。

1551818239259

唯一标识

在分析各类操作需要采集的对应数据之前,我们首先要对之前的序列化快照进行一个拓展:为每一个 DOM 节点添加唯一标识。

想象一下如果我们在本地记录一次点击按钮的操作并回放,我们可以用以下格式记录该操作:

type clickOp = {
  source: "MouseInteraction",
  type: "Click",
  node: HTMLButtonElement,
};

再通过 clickOp.node.click() 就能将操作再执行一次。

但是在远程场景中,虽然我们已经重建出了完整的 DOM,但是却没有办法将 Oplog 中被交互的 DOM 节点和已存在的 DOM 关联在一起。

这就是唯一标识 id 的作用,我们在录制端和回放端维护一致的 id -> Node 映射,上述示例中的数据结构相应的变为:

type clickSnapshot = {
  source: "MouseInteraction";
  type: "Click";
  id: Number;
};

DOM 变动

以下场景在 web 应用中随处可见:

点击 button,出现 dropdown menu,选择第一项,dropdown menu 消失

因为回放时不会有 JavaScript 脚本执行这一动态变化,所以对于这一操作需要记录 DOM 节点的创建以及后续的销毁,这也是录制中的最大难点。

好在现代浏览器已经给我们提供了非常强大的 API —— MutationObserver 用来完成这一功能。

我们不会具体讲解 MutationObserver 的基本使用方式,只专注于在 rrweb 中我们需要做哪些特殊处理。

首先要了解 MutationObserver 的触发方式为批量异步回调,具体来说就是会在一系列 DOM 变化发生之后将这些变化一次性回调,传出的是一个 mutation 记录数组。

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

body
  n1
    n2
  1. 创建节点 n1 并 append 在 body 中,再创建节点 n2 并 append 在 n1 中。
  2. 创建节点 n1、n2,将 n2 append 在 n1 中,再将 n1 append 在 body 中。

第 1 种情况将产生两条 mutation 记录,分别为增加节点 n1 和增加节点 n2;第 2 种情况则只会产生一条 mutation 记录,即增加节点 n1。

想要同时正确地处理这两种情况,所有 mutation 记录都需要先收集,在新增节点去重并序列化之后再做处理。

鼠标移动

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

保证回放时鼠标移动流畅的同时也要尽量减少对应 Oplog 的数量,所以我们会做两层节流处理。第一层是每 50 ms 最多记录一次鼠标坐标,第二层是每 500 ms 最多发送一次鼠标坐标集合,第二层的主要目的是避免一次请求内容过多而做的分段。

输入

我们需要观察 <input>, <textarea>, <select> 三种元素的输入,包含人为交互和程序设置两种途径的输入。

人为交互

对于人为交互的操作我们主要靠监听 input 和 change 两个事件观察,需要注意的是对不同事件但值相同的情况进行去重。此外 <input type="radio" /> 也是一类特殊的控件,如果多个 radio 元素的组件 name 属性相同,那么当一个被选择时其他都会被反选,但是不会触发任何事件,因此我们需要单独处理。

程序设置

通过代码直接设置这些元素的属性也不会触发事件,我们可以通过劫持对应属性的 setter 来达到监听的目的。

为了避免我们在 setter 中的逻辑阻塞被录制页面的正常交互,我们应该把逻辑放入 event loop 中异步执行。

特定场景优化:多个快照

快照 + Oplog 的设计也有其弊端,比较明显的缺陷在于长时间的录制 Oplog 会记录很多操作,并且由于以增量的形式记录数据,所以必须用完整的 Oplog 才能够进行回放。

一类常见的需求是当异常发生时,收集异常之前一段时间的行为数据。为了更好的处理这类需求,我们实现了按时间和按次数重新制作快照的配置。

可以设置每 n 次操作后制作一次快照或每 n 毫秒后制作一次快照,从而将一个长的 Oplog 拆分为多个短的 Oplog。

回放

在确定了最终录制方案之后,我们就可以实现对应的回放功能。相对来说回放的思路更为明确,可以分为以下 3 个主要步骤:

  1. 在一个沙盒环境中将快照重建为对应的 DOM 树。
  2. 将 Oplog 中的操作按照时间戳排列,放入一个操作队列中。
  3. 启动一个计时器,不断检查操作队列,将到时间的操作取出重现。

沙盒

在序列化设计中我们提到了“去脚本化”的处理,即在回放时我们不应该执行被录制页面中的 JavaScript,在重建快照的过程中我们将所有 script 标签改写为 noscript 标签解决了部分问题。但仍有一些脚本化的行为是不包含在 script 标签中的,例如 HTML 中的 inline script、表单提交等。

因此我们通过 HTML 提供的 iframe 沙盒功能进行浏览器层面的限制。

我们在重建快照时将被录制的 DOM 重建在一个 iframe 元素中,通过设置它的 sandbox 属性,我们可以禁止以下行为:

这与我们的预期是相符的,尤其是对 JS 脚本的处理相比自行实现会更加安全、可靠。

高精度计时器

之所以强调回放所用的计时器是高精度的,是因为原生的 setTimeout 并不能保证在设置的延迟时间之后准确执行,例如主线程阻塞时就会被推迟。

对于我们的回放功能而言,这种不确定的推迟是不可接受的,可能会导致各种怪异现象的发生,因此我们通过 requestAnimationFrame 来实现一个不断校准的定时器,确保绝大部分情况下操作的重放延迟不超过一帧。

同时自定义的计时器也是我们实现“快进”功能的基础。

写在最后

作为 SmartX 的前端团队,我们也在不断思考如何更好地进行企业级 Web 应用的开发,持续不断创新,提升用户体验。

在我们的理解中,用户体验也应该包含用户遇到问题时我们如何快速 debug 和修复,而这对于内网部署并且逻辑非常复杂的应用而言并非易事。

rrweb 就是我们在不断尝试解决这一问题后衍生出的技术工具。我们将它开源是希望能够在更多场景中发挥它的作用,同时也希望更多优秀的开发者看到我们在前端开发中的实践和经验。之后也会分享更多我们在打磨高质量企业级前端项目过程中的心得,希望对大家有所帮助。