【第1580期】rrweb:打开 web 页面录制与回放的黑盒子
前言
在项目中有遇到需要回放用户记录的需求不?今日早读文章由@smartx授权分享。
正文从这开始~~
rrweb
Record and replay the web,rrweb is an open source web session replay library, which provides easy-to-use APIs to record user’s interactions and replay it remotely.
前段时间开源了我们的 web 录制、回放基础库 rrweb,它可以将⻚⾯中的 DOM 以及⽤户操作保存为可序列化的数据,以实现远程回放。
研发这⼀⼯具起初是为了解决我们在客户环境 debug 时遇到的⼀些问题。
我们的产品通常部署在客户的内⽹环境中,因此⼀旦出现问题只能通过各类远程操作⼯具登⼊客户环境中进⾏debug,操作的空间和时间都⾮常有限。如果不幸遇到⼀些偶发性的问题,复现就变得难上加难,debug 更是⽆从谈起。
在这种情况下,前端的异常监控及对应数据的收集显得⾮常重要,但是传统的收集错误栈信息的⽅式并不能给我们提供⾜够的信息⽤于定位问题。
在进⼀步调研的过程中我们发现了 LogRocket 这样的⼯具能够提供像素级的录制与回放,⾮常适⽤于我们的场景。但该类产品通常为 SAAS 服务,客户的内⽹环境很可能⽆法连接,因此也⽆法被使⽤。
最终我们决定⾃⾏实现 web 录制与回放这⼀套功能,在开发的过程中我们发现它还可以被应⽤于很多场景,例如:
记录⽤户使⽤产品的⽅式并加以分析,进⼀步优化产品。
采集⽤户遇到 bug 的操作路径,予以复现。
记录 CI 环境中的 E2E 测试的执⾏情况。
录制体积更⼩、清晰度⽆损的产品演⽰。
所以我们把其中最通⽤的部分作为独⽴的代码仓库开源,⽅便其他开发者使⽤。
下面中将具体说说 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 的原因包含两个⽅⾯:
我们需要实现⼀个“⾮标准”的序列化⽅法。
此部分代码需要运⾏在被录制的⻚⾯中,要尽可能的控制代码量,只保留必要功能。
之所以说我们的序列化⽅法是⾮标准的是因为我们还需要做以下⼏部分的处理:
去脚本化,被录制⻚⾯中的所有 JavaScript 都不应该被执⾏。
记录没有反映在 HTML 中的视图状态。例如
<input type="text" />
输⼊后的值不会反映在其 HTML中,我们需要读取其 value 值并加以记录。相对路径转换为绝对路径。回放时⻚⾯ URL为重放⻚⾯的地址,如果被录制⻚⾯中有⼀些相对路径就会产⽣错误。
尽量记录 CSS 样式表的内容。如果被录制⻚⾯加载了⼀些同源的样式表,我们则可以获取到解析好的 CSS rules,录制时将能获取到的样式都 inline 化,这样可以让⼀些内⽹环境(如 localhost)的录制也有⽐较好的回放效果。
初次尝试:定时快照
当我们完成了可序列化的 DOM 快照实现之后,映⼊脑海的第⼀个思路就是定时对⻚⾯制作快照完成录制,回放时只需按照时间间隔依次重建快照即可。
但稍加思考之后我们会发现这个⽅案有两⼤弊端。
⾸先是两次快照之间的时间间隔难以平衡,如果间隔过短那么可能产⽣⼤量⽆区别的快照,最终的总体积也会⾮常⼤,甚⾄⼤于同样时⻓的视频⽂件;⽽如果间隔过⻓那么就会遗漏两次间隔之间的视图变化,可能导致⼀些关键性操作没有被录制。
其次是我们⽆法感知视图变化的原因,也就⽆法从中解析出⽤户的⾏为加以分析。
虽然定时快照的⽅案并不可⾏,但是指明了我们需要解决的两个核⼼问题:
应该基于导致视图的变更制作快照。
要控制录制结果的体积。
再次尝试:基于变更制作快照
第⼀个优化的⽅向是明确制作快照的时机,应该在每次视图变更时制作⼀次快照。这样既不会有不必要的快照,也不会遗漏视图变化。
在实际的 web 应⽤中视图的变更⾮常频繁,⽽且绝⼤部分都是局部的变更,因此每⼀次变更对应⼀个完整快照的思路虽然保证了快照数量上没有浪费,但在每个快照的内容中依然有⼤量重复的部分,全部记录下来还是⼀种不必要的冗余。
基于快照 diff 的优化思路
为了消除上述的快照中的冗余数据,最直观的思路就是将每⼀个快照与其前⼀个快照进⾏ diff,找出变更的部分加以记录。
由于我们的快照数据结构是和 DOM 树相类似的树状结构,因此在 DOM 树较为复杂时 diff 的开销将会⾮常⾼,甚⾄阻塞被录制⻚⾯的正常交互,进⽽影响⽤户体验。
这样的⾼侵⼊性显然与我们的预期是不相符的,所以我们还需要追溯视图变更的根本原因——引发变更的操作。
最终录制方案:快照 + Oplog
我们可以把引发视图变更的操作归为以下⼏类:
DOM 变动
节点创建、销毁
节点属性变化
⽂本变化
⿏标交互
⻚⾯或元素滚动
视窗⼤⼩改变
输⼊
⿏标移动(特指⿏标的视觉位置)
对于每个操作我们只需要记录其操作类型和相关的数据,就可以在回放时重现对应的操作,也就回放了该操作对视图的改变。
这样我们只需要在开始录制时制作⼀个完整的 DOM 快照,之后则记录所有的操作数据,这些操作数据我们称之为 Oplog(operations log),这⼀思路和 log-structured file system 是类似的。
唯一标识
在分析各类操作需要采集的对应数据之前,我们⾸先要对之前的序列化快照进⾏⼀个拓展:为每⼀个 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
创建节点 n1 并 append 在 body 中,再创建节点 n2 并 append 在 n1 中。
创建节点 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 个主要步骤:
在⼀个沙盒环境中将快照重建为对应的 DOM 树。
将 Oplog 中的操作按照时间戳排列,放⼊⼀个操作队列中。
启动⼀个计时器,不断检查操作队列,将到时间的操作取出重现。
沙盒
在序列化设计中我们提到了“去脚本化”的处理,即在回放时我们不应该执⾏被录制⻚⾯中的 JavaScript,在重建快照的过程中我们将所有 script 标签改写为 noscript 标签解决了部分问题。但仍有⼀些脚本化的⾏为是不包含在 script 标签中的,例如 HTML 中的 inline script、表单提交等。
因此我们通过 HTML 提供的 iframe 沙盒功能进⾏浏览器层⾯的限制。
我们在重建快照时将被录制的 DOM 重建在⼀个 iframe 元素中,通过设置它的 sandbox 属性,我们可以禁⽌以下⾏为:
表单提交
window.open 等弹出窗
JS 脚本(包含 inline event handler 和 <URL> )
这与我们的预期是相符的,尤其是对 JS 脚本的处理相⽐⾃⾏实现会更加安全、可靠。
高精度计时器
之所以强调回放所⽤的计时器是⾼精度的,是因为原⽣的 setTimeout 并不能保证在设置的延迟时间之后准确执⾏,例如主线程阻塞时就会被推迟。
对于我们的回放功能⽽⾔,这种不确定的推迟是不可接受的,可能会导致各种怪异现象的发⽣,因此我们通过 requestAnimationFrame 来实现⼀个不断校准的定时器,确保绝⼤部分情况下操作的重放延迟不超过⼀帧。
同时⾃定义的计时器也是我们实现“快进”功能的基础。
写在最后
作为 SmartX 的前端团队,我们也在不断思考如何更好地进⾏企业级 Web 应⽤的开发,持续不断创新,提升⽤户体验。
在我们的理解中,⽤户体验也应该包含⽤户遇到问题时我们如何快速 debug 和修复,⽽这对于内⽹部署并且逻辑⾮常复杂的应⽤⽽⾔并⾮易事。
rrweb 就是我们在不断尝试解决这⼀问题后衍⽣出的技术⼯具。我们将它开源是希望能够在更多场景中发挥它的作⽤,同时也希望更多优秀的开发者看到我们在前端开发中的实践和经验。之后也会分享更多我们在打磨⾼质量企业级前端项⽬过程中的⼼得,希望对⼤家有所帮助。
rrweb官网:https://www.rrweb.io/
rrweb项目:https://github.com/rrweb-io/rrweb
关于本文
作者:@smartx
原文:https://zhuanlan.zhihu.com/p/60639266
最后,为你推荐
【第1337期】JavaScript 是如何工作的:用 MutationObserver 追踪 DOM 的变化