blog icon indicating copy to clipboard operation
blog copied to clipboard

rrweb前端web页面录制与回放插件的使用和原理

Open xianzou opened this issue 3 years ago • 0 comments

rrweb前端web页面录制与回放插件的使用和原理

可以将页面中的 DOM 以及用户操作保存为可序列化的数据,以实现远程回放;

主要模块:rrweb-snapshotrrweb-player

运用场景:

  • 记录⽤户使⽤产品的⽅式并加以分析,进⼀步优化产品。
    • 点击率、转换率、活动热度等来推进网站的运营和产品功能的迭代
  • 采集⽤户遇到 bug 的操作路径,予以复现。
  • 记录 CI 环境中的 E2E 测试的执⾏情况。
  • 录制体积更⼩、清晰度⽆损的产品演⽰。

rrweb工作背景:

对应模块解析

rrweb-snapshot

提供了 snapshot, resbuid接口

  • snapshot遍历 页面DOM 返回当前页面 DOM 视图的一个序列化的数据结构;
  • rebuild 则解析特定的数据还原DOM, 并插入文档中;

示例:

img

snapshot方法
  • 为每个节点提供一个id,并在快照完成时返回id的节点映射

    • 构建页面 DOM 树,同时生成了 id -> Node 的映射,即在构建 DOM 树时为每个节点生成一个唯一的id, 同时根据 id 生成一份映射,映射是为了方便后续的增量快照操作
  • 相对路径的处理,将hrefsrcCSS中的相对路径设为绝对路径

  • 将页面引用的样式变为内联样式,以确保可以使用本地样式

  • 将一些DOM状态内联到HTML属性中,例如HTMLInputElement的值

    • 记录没有反映在 HTML 中的视图状态。例如 checkbox输⼊后的值不会反映在其 HTML中,我们需要读取其 value 值并加以记录
  • 将script标记转换为noscript标记,以避免脚本被执行。

    • 在播放录制页面时,页面的脚本是不能够被执行的,需要禁掉

rebuild方法

​ 通过创建Dom, 设置属性等,并且将对应的DOM 插入文档中

Rrweb

提供了 record 和 replay 功能

**record **

还原dom结构

replay

解析events集合并还原

回放的基础: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 的⽂本格式,但其中会丢失⼀些视图状态,例如 元素的 value 就不⼀定会记录在 HTML 中。

序列号中的其他处理:

  • 去脚本化。被录制页面中的所有 JavaScript 都不应该被执行,例如我们会在重建快照时将 script 标签改为 noscript 标签,此时 script 内部的内容就不再重要,录制时可以简单记录一个标记值而不需要将可能存在的大量脚本内容全部记录;
  • 记录没有反映在 HTML 中的视图状态。例如 <input type="text" /> 输入后的值不会反映在其 HTML 中,而是通过 value 属性记录,我们在序列化时就需要读出该值并且以属性的形式回放成 <input type="text" value="recordValue" />
  • 相对路径转换为绝对路径。回放时我们会将被录制的页面放置在一个 <iframe> 中,此时的页面 URL为重放页面的地址,如果被录制页面中有一些相对路径就会产生错误,所以在录制时就要将相对路径进行转换,同样的 CSS 样式表中的相对路径也需要转换。
  • 尽量记录 CSS 样式表的内容。如果被录制页面加载了一些同源的 样式表,我们则可以获取到解析好的 CSS rules,录制时将能获取到的样式都 inline 化,这样可以让一些内网环境(如 localhost)的录制也有比较好的效果。

录制方案:快照 + 操作指令

这样我们只需要在开始录制时制作⼀个完整的 DOM 快照,之后则记录所有的操作数据,这些操作数据称之为 operations log操作指令,这⼀思路和 log-structured file system(日志结构的文件系统) 是类似的。

引发视图变更的操作归为以下⼏类:

  • DOM 变动

    • 节点创建、销毁
    • 节点属性变化
    • ⽂本变化
  • ⿏标交互

  • 鼠标移动

    • mouse up、mouse down
    • click、double click、context menu
    • focus、blur
    • touch start、touch move、touch end
  • ⻚⾯或元素滚动

  • 视窗⼤⼩改变

  • 输⼊

  • ⿏标移动(特指⿏标的视觉位置)

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

img

唯一标识

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

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

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

再通过 clickOp.node.click 就能将操作再执⾏⼀次。

但是在远程场景中,虽然我们已经重建出了完整的 DOM,但是却没有办法将 操作指令 中被交互的 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接口,它能监控目标元素的属性、子元素和数据的变化。一旦监控到变化,就会调用setAttributeAction方法。

/**
 * 监控元素变化
 */
observer() {
  const ob = new MutationObserver(mutations => {
    mutations.forEach(mutation => {
      const { type, target, oldValue, attributeName } = mutation;
      switch (type) {
        case "attributes":
          const value = target.getAttribute(attributeName);
          this.setAttributeAction(target);
      }
    });
  });
  ob.observe(document, {
    attributes: true,             //监控目标属性的改变
    attributeOldValue: true,      //记录改变前的目标属性值
    subtree: true                 //目标以及目标的后代改变都会监控
  });
  //ob.disconnect();
}

⾸先要了解 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 和 change 两个事件观察,需要注意的是对不同事件但值相同的情况进⾏去重。此外 也是⼀类特殊的控件,如果多个 radio 元素的组件 name 属性相同,那么当⼀个被选择时其他都会被反选,但是不会触发任何事件,因此需要单独处理。

程序设置

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

为了避免在 setter 中的逻辑阻塞被录制⻚⾯的正常交互,把逻辑放⼊ event loop (消息线程)中异步执⾏。

回放

回放的思路可以分为以下 3 个主要步骤:

  1. 在⼀个沙盒环境(iframe,原因:禁止表单提交、弹窗和执行JavaScript的行为、避免链接跳转)中将快照重建为对应的 DOM 树。

  2. Oplog 中的操作按照时间戳排列,放⼊⼀个操作队列中。

  3. 启动⼀个计时器,不断检查操作队列,将到时间的操作取出重现(requestAnimationFrame)。

/**
 * 创建iframe还原页面
 */
createIframe() {
  let iframe = document.createElement("iframe");
  iframe.setAttribute("sandbox", "allow-same-origin");
  iframe.setAttribute("scrolling", "no");
  iframe.setAttribute("style", "pointer-events:none; border:0;");
  iframe.width = `${window.innerWidth}px`;
  iframe.height = `${document.documentElement.scrollHeight}px`;
  iframe.onload = () => {
    const doc = iframe.contentDocument,
      root = doc.documentElement,
      html = this.deserialization(this.dom);          //反序列化
    //根元素属性附加
    for (const { name, value } of Array.from(html.attributes)) {
      root.setAttribute(name, value);
    }
    root.removeChild(root.firstElementChild);         //移除head
    root.removeChild(root.firstElementChild);         //移除body
    Array.from(html.children).forEach(child => {
      root.appendChild(child);
    });
    //加个定时器只是为了查看方便
    setTimeout(() => {
      this.replay();
    }, 5000);
  };
  document.body.appendChild(iframe);
}
/**
 * 回放
 */
function replay() {
  if (this.actions.length == 0) return;
  const timeOffset = 16.7;                         //一帧的时间间隔大概为16.7ms
  let startTime = this.actions[0].timestamp;       //开始时间戳
  const state = () => {
    const action = this.actions[0];
    let element = this.idMap.get(action.id);
    if (!element) {
      //取不到的元素直接停止动画
      return;
    }
    if (startTime >= action.timestamp) {
      this.actions.shift();
      switch (action.type) {
        case ACTION_TYPE_ATTRIBUTE:
          for (const name in action.attributes) {
            //更新属性
            element.setAttribute(name, action.attributes[name]);
          }
          //触发defineProperty拦截,拆分成两个插件会避免该问题
          action.value && (element.value = action.value);
          break;
      }
    }
    startTime += timeOffset;         //最大程度的模拟真实的时间差
    if (this.actions.length > 0)
      //当还有动作时,继续调用requestAnimationFrame()
      requestAnimationFrame(state);
  };
  state();
}
# 默认以第一个动作为起始时间,接下来每次调用requestAnimationFrame()函数,起始时间都加一次timeOffset变量。
#rrweb有个倍数回放,其实就是加大间隔,在间隔中多执行几个动作,从而模拟出倍速的效果。

示例

const video = new JSVideo(),
  input = document.querySelector("[name=name]"),
  mobile = document.querySelector("[name=mobile]");
//修改placeholder属性
setTimeout(function() {
  input.setAttribute("placeholder", "name");
}, 1000);
//修改姓名的value值
setTimeout(function() {
  input.value = "Strick";
}, 3000);
//修改手机的value值
setTimeout(function() {
  mobile.value = "13800138000";
}, 4000);
//在iframe中回放
setTimeout(function() {
  video.createIframe();
}, 5000);

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

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

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

  • 表单提交
  • window.open 等弹出窗
  • JS 脚本(包含 inline event handler 和 <URL>

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

高精度计时器

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

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

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

优化

特定场景优化:多个快照

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

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

可以设置每 n 次操作后制作⼀次快照或每 n 毫秒后制作⼀次快照,从⽽将⼀个⻓的 操作指令拆分为多个短的 操作指令

示例1:

const eventsMatrix = [[]];

rrweb.record({
  emit(event, isCheckout) {
    // isCheckout 是一个标识,告诉你重新制作了快照
    if (isCheckout) {
      eventsMatrix.push([]);
    }
    const lastEvents = eventsMatrix[eventsMatrix.length - 1];
    lastEvents.push(event);
  },
  checkoutEveryNth: 200, // 每 200 个 event 重新制作快照
});

// 向后端传送最新的两个 event 数组
window.onerror = function () {
  const len = eventsMatrix.length;
  const events = eventsMatrix[len - 2].concat(eventsMatrix[len - 1]);
  const body = JSON.stringify({ events });
  fetch('http://YOUR_BACKEND_API', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body,
  });
};
#可以拿到最新的 200-400 个 event 来发送给后端

示例2:

// 使用二维数组来存放多个 event 数组
const eventsMatrix = [[]];

rrweb.record({
  emit(event, isCheckout) {
    // isCheckout 是一个标识,告诉你重新制作了快照
    if (isCheckout) {
      eventsMatrix.push([]);
    }
    const lastEvents = eventsMatrix[eventsMatrix.length - 1];
    lastEvents.push(event);
  },
  checkoutEveryNms: 5 * 60 * 1000, // 每5分钟重新制作快照
});

// 向后端传送最新的两个 event 数组
window.onerror = function () {
  const len = eventsMatrix.length;
  const events = eventsMatrix[len - 2].concat(eventsMatrix[len - 1]);
  const body = JSON.stringify({ events });
  fetch('http://YOUR_BACKEND_API', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body,
  });
};
#最终会拿到最新的 5-10 分钟的 event 来发送给你的后端

优化方式:rrweb.pack

优化前 950K:

image-20210618115556084

优化后 383K:

image-20210618115522462

不录制无必要的dom元素

页面中可能存在一些隐私相关的内容不希望被录制,rrweb 为此做了以下支持:

  • 在 HTML 元素中添加类名 .rr-block 将会避免该元素及其子元素被录制,回放时取而代之的是一个同等宽高的占位元素。
  • 在 HTML 元素中添加类名 .rr-ignore 将会避免录制该元素的输入事件。
  • input[type="password"] 类型的密码输入框默认不会录制输入事件。

滚动条节流

 new SentryRRWeb({
     ...
     sampling: {
         scroll: 150, // 每 150ms 最多触发一次
         input: 'last' // 连续输入时,只录制最终值
     },
     ...
 }),

经过上述手段优化之后效果如下:

  • 4分钟录屏rrweb.json大小为283k
  • 1分48秒录屏rrweb.json大小为161kb
  • 3分钟录屏大小为254K

其他方式:

1、压缩数据,尝试用 pako.js 对数据进行压缩上传 2、替换元素,json中记录了大量的样式,可以将样式名称进行替换,例如:font-size替换成fzborder-bottom替换成bbpadding-top替换成pd等方法,播放的时候在进行回填即可;

性能影响

所属项目:户籍wx

接入rrweb

接入rrweb

所需要的加载时间、js计算时间、渲染时间、绘制时间、均略有增加,但影响不大;

通过chrome 测试FPS,帧率基本一致;

不同项目和页面的复杂的会影响不同;

问题

iframe

​ 跨域无法录制

echart

录制echart的时候因echart的dom的style不断的变化,会产生非常大的数据,有可能导致页面被堵塞,慎用

PDF.js

使用pdf.js场景下性能可能存在问题

总结

录制

​ 记录初始页面的DOM 状态,或者特定某个时刻的DOM 状态,后续收集的是不同时间点的操作指令 或者 某个时刻 某个DOM 的变化作为一个增量快照,在原先快照的基础上,不断加入根据行为解析的DOM 数据,构建了后续的快照,减少大量数据的存储或传输。

单独使用

const rrweb = require('rrweb');
import { uploadRrweb } from '../service/Service';
window.rrweb = rrweb;

export default class RrWeb {
    constructor(eventsMatrix){
        this.eventsMatrix = eventsMatrix || [[]];
        this.stopFn = null;
    }
    init () {
        const that = this;

        if (that.stopFn){
            that.stopFn();
            that.stopFn = null;
        }
        that.stopFn = rrweb.record({
            emit(event, isCheckout){
                // isCheckout 是一个标识,告诉你重新制作了快照
                if (isCheckout) {
                    that.eventsMatrix.push([]);
                }
                const lastEvents = that.eventsMatrix[that.eventsMatrix.length - 1];

                lastEvents.push(event);
            },
            sampling: {
                // 定义不录制的鼠标交互事件类型,可以细粒度的开启或关闭对应交互录制
                mouseInteraction: false,
                mousemove: false,
                ContextMenu: false,
                DblClick: false,
                scroll: 150, // 每 150ms 最多触发一次
                // 设置输入事件的录制时机
                input: 'last' // 连续输入时,只录制最终值
            },
            checkoutEveryNms: 5 * 60 * 1000, // 每3分钟重新制作快照
            packFn: rrweb.pack,
        });
    }

    uploadRrwebData (regInfo) {
        // 上传
        const newRegInfo = JSON.stringify(regInfo);
        const motionNew = this.eventsMatrix;
		const formData = new FormData();
        
        formData.append('rrweb', new Blob([JSON.stringify({ events: self.events })], {
            type: 'application/json',
        }), 'rrweb.json');
        //上传
        uploadRrweb(uploadRrweb);
        
        this.eventsMatrix = [[]];
        if (this.stopFn){
            this.stopFn();
            this.stopFn = null;
        }
    }
}

#播放
import 'rrweb/dist/rrweb.min.css';
const rrweb = require('rrweb');

const PlayCompontent = ()=> {
    const play = ()=>{
        const replayer = new rrweb.Replayer(events,{
            unpackFn: rrweb.unpack,
        });
        replayer.play();
    }
    
    return (
    	<div onClick={play}>
        	播放
        </div>
    )
}

其他思路

​ 直接加入代码中侵入性太大,并非所有用户需要这功能,也会增加用户请求带宽,所以也可以编写谷歌插件,用户(开发、有问题的用户),安装谷歌插件后,通过热键录制,生成数据后回看,最后决定是否要上传服务器,最后将bug和回放地址关联,指派给对应的开发者解决问题,这样流程清晰,代码层面上无侵入感;没有安装插件的用户对我们的系统是无感知的,拓展性很强不止单一系统可用,其他系统也可以使用这个插件完成屏幕录制。一处开发,处处使用。

步奏

  • 开启录制
  • 结束录制
  • 数据压缩
  • 数据保存(地址or服务器)
  • 数据解压
  • 回放录屏

xianzou avatar Jun 18 '21 08:06 xianzou