前端录制回放初体验

发表于 3年以前  | 总阅读数:432 次

前端录制回放系统初体验

问题背景

什么是前端录制回放?

顾名思义,就是录制用户在网页中的各种操作,并且支持能随时回放操作。

为什么需要?

说到需要就不得不说一个经典的场景,一般前端做异常监控和错误上报,会采用自研或接入第三方 SDK 的形式,来收集和上报网站交互过程中 JavaScript 的报错信息和其它相关数据,也就是埋点。

在传统的埋点方案中,根据 SourceMap 能定位到具体报错代码文件和行列信息等。基本能定位大部分场景问题,但有一些情况下是很难复现错误,多是在测试扯皮的时候,程序员口头禅之一(我这里没有报错呀,是不是你电脑有问题)。

要是能把出错的操作过程录制下来就好了,这样就能方便我们复现场景了,且留存证据,好像是自己给自己挖了个坑。

如何实现?

前端能实现录视频?我第一反应就是质疑,接着我就是一波 Google ,发现确实有可行方案。

Google 之前,我想到了通过设定定时器,对视图窗口进行截图,截图可用 canvas2html 的方式来实现,但这种方式无疑会造成性能问题,立马否决。

下面介绍我所「知道」的 Google 的方案,如有问题,欢迎指正。

思路初现

网页本质上是一个 DOM 节点形式存在,通过浏览器渲染出来。我们是否可以把 DOM 以某种方式保存起来,并且在不同时间节点持续记录 DOM 数据状态。再将数据还原成 DOM 节点渲染出来完成回放呢?

操作记录

通过 document.documentElement.cloneNode() 克隆到 DOM 的数据对象,此时这个数据不能直接通过接口传输给后端,需要进行一些格式化预处理,处理成方便传输及存储的数据格式。最简单的方式就是进行序列化,也就是转换成 JSON 数据格式。

// 序列化后
let docJSON = {
  "type": "Document",
  "childNodes": [
    {
      "type": "Element",
      "tagName": "html",
      "attributes": {},
      "childNodes": [
        {
          "type": "Element",
          "tagName": "head",
          "attributes": {},
          "childNodes": []
        }
      ]
    }
  ]
}

有完整的 DOM 数据之后,还需要在 DOM 变化时进行监听,记录每次变化的 DOM 节点信息。对数据进行监听可用 MutationObserver,它是一个可以监听 DOM 变化的 API

const observer = new MutationObserver(mutationsList => {
    console.log(mutationsList); // 发生变化的数据
});
// 以上述配置开始观察目标节点
observer.observe(document, {});

除了对 DOM 变化进行监听以外,还有一个就是事件监听,用户与网页的交互多是通过鼠标,键盘等输入设备来进行。而这些交互的背后就是 JavaScript 的事件监听。事件监听可以通过绑定系统事件来完成,同样是需要记录下来,以鼠标移动为例:

// 鼠标移动
document.addEventListener('mousemove', e => {
  // 伪代码 获取鼠标移动的信息并记录下来
  positions.push({
    x: clientX,
    y: clientY,
    timeOffset: Date.now() - timeBaseline,
  });
});

addEventListener可以绑定多个相同事件,不影响开发者的事件绑定

回放操作

数据已经有了,接着就是回放,回放本质上是将 JSON 数据还原成 DOM 节点渲染出来。那就将快照数据还原就可以啊「嘴强王者」,数据还原并非那么容易啊!

渲染环境

首先为了确保回放过程代码隔离,需要沙箱环境, iframe 标签可以做到,并且 iframe 提供了 sandbox 属性可配置沙箱。沙箱环境的作用是确保代码安全并且不被干扰。

<iframe sandbox srcdoc></iframe>

sanbox 属性可以做到沙箱作用,点击查看文档

srcdoc 可以直接设置成一段 html 代码

数据还原

快照重组主要是 DOM 节点的重组,有点像虚拟 DOM 转成真实文档节点的过程,但是事件类型快照是不需要重组。

定时器

有了数据和环境,还需要定时器。通过定时器不停渲染 DOM ,实质上就是一个播放视频的效果, requestAnimationFrame 是最合适的。

requestAnimationFrame 会在每一帧中执行,从而避免堵塞,是比 setTimeout 更合适的选择

至此有一个大概的想法,距离落地还是有段距离。得益于开源,我们可上 Github 看看有没有合适的轮子可复制(借鉴),刚好有现成的一框架 「「rrweb」」,不妨一起看看。

rrweb 框架

rrweb 是一个前端录制和回放的框架。全称 record and replay the web ,顾名思义就是可以录制和回放 web 界面中的操作,其核心原理就是上面介绍的方案。

rrweb 组成

rrweb 包含三个部分:

  • rrweb-snapshot 主要处理 DOM 结构序列化和重组;
  • rrweb 主要功能是录制和回放;
  • rrweb-player 一个视频播放器 UI 空间

rrweb 使用

npm 安装习以为常,import/require 引入问题不大

录制

通过 rrweb.record 方法来录制页面,emit 回调可接受到录制的数据。

// 1.录制
let events = []; // 记录快照

rrweb.record({
  emit(event) {
    // 将 event 存入 events 数组中
    events.push(event);
  },
});

回放

通过 rrweb.Replayer 可回放视频,需要传递录制好的数据。

// 2.回放
const replayer = new rrweb.Replayer(events);
replayer.play();

点击查看官方案例效果

rrweb 源码

按照以上所说的思路,接下来会解析其中一些关键代码,当然只是在我个人理解上做的一些分析,实际上 rrweb 源码远不止这些。

rrweb 组成

核心部分为三大块: record (录制)、 replay 回放、 snapshot 快照。

Record 录制

DOM 加载完成后,record 会做一次完整的 DOM 序列化,我们把它叫做全量快照,全量快照记录了整个 HTML 数据结构。

record.ts 中找到关键的入口函数的定义 init,入口函数是会在 document 加载完成或(可交互,完成)时调用了 takeFullSnapshot 以及 observe(document) 函数。

if (
    document.readyState === 'interactive' ||
    document.readyState === 'complete'
) {
    init();
} else {
    //...
    on('load',() => { init(); },),
}
const init = () => {
    takeFullSnapshot(); // 生成全量快照
    handlers.push(observe(document)); //监听器
};

document.readyState 包含三种状态:

  1. 可交互 interactive
  2. 正在加载中 loading
  3. 完成 complete

takeFullSnapshot 从字面意思能看出其作用是生成「完整」的快照,也就是会将 document 序列化出一个完整的数据,称之为 「「全量快照」」

所有序列化相关操作都是使用 snapshot 完成,snapshot 接受一个 dom 对象和一个配置对象传递 document 将整个页面序列化得到完成的快照数据。

// 生成全量快照
takeFullSnapshot = (isCheckout = false) => {
    //...
    const [node, idNodeMap] = snapshot(document, {
        //...一些配置项
    });
    //...
}

idNodeMap 是一个 idkey ,DOM 对象为 valuekey-value 键值对对象

observe(document) 是一些监听器的初始化,同样是将整个 document 对象传过去进行监听,通过调用 initObservers 来初始化一些监听器。

const observe = (doc: Document) => {
    return initObservers(//...)
}

observer.ts 文件中可以找到 initObservers 函数定义,该函数初始化了 11 个监听器,可以分为 DOM 类型 / Event 事件类型 / Media 媒体三大类:

export function initObservers(
    // dom
    const mutationObserver = initMutationObserver();
    const mousemoveHandler = initMoveObserver();
    const mouseInteractionHandler = initMouseInteractionObserver();
    const scrollHandler = initScrollObserver();
    const viewportResizeHandler = initViewportResizeObserver();
    // ...
)
  • DOM 变化监听器,主要有 DOM 变化(增删改), 样式变化,核心是通过 MutationObserver 来实现
let mutationObserverCtor = window.MutationObserver;

const observer = new mutationObserverCtor(
    // 处理变化的数据
    mutationBuffer.processMutations.bind(mutationBuffer),
);
observer.observe(doc, {});
return observer;
  • 交互监听-以鼠标移动 initMoveObserver 为例
// 鼠标移动记录
function initMoveObserver() {
    const updatePosition = throttle<MouseEvent | TouchEvent>(
        (evt) => {
            positions.push({
                x: clientX,
                y: clientY,
            });
    });
    const handlers = [
        on('mousemove', updatePosition, doc),
        on('touchmove', updatePosition, doc),
    ];
}
  • 媒体类型监听器,有 canvas / video / audio,以 video 为例,本质上记录播放和暂停状态,mediaInteractionCbplay / pause 状态回调出来。
function initMediaInteractionObserver(): listenerHandler {
    mediaInteractionCb({
        type: type === 'play' ? MediaInteractions.Play : MediaInteractions.Pause,
        id: mirror.getId(target as INode),
    });
}

Snapshot 快照

snapshot 负责序列化和重组的功能,主要通过 serializeNodeWithId 处理 DOM 序列化和 rebuildWithSN 函数处理 DOM 重组。

serializeNodeWithId 函数负责序列化,主要做了三件事:

  • 调用 serializeNode 序列化 Node
  • 通过 genId() 生成唯一ID 并绑定到 Node 中;
  • 递归实现序列化子节点,并最终返回一个带 ID 的对象
// 序列化一个带有ID的DOM
export function serializeNodeWithId(n) {
  // 1. 序列化 核心函数 serializeNode
  const _serializedNode = serializeNode(n);
  // 2. 生成唯一ID
  let id = genId();
  // 绑定ID
  const serializedNode = Object.assign(_serializedNode, { id });

  // 3.子节点序列化-递归
  for (const childN of Array.from(n.childNodes)) {
    const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
    if (serializedChildNode) {
      serializedNode.childNodes.push(serializedChildNode);
    }
  }
}

serializeNodeWithId 核心是通过 serializeNode 序列化 DOM ,针对不同的节点分别做了一些特殊处理。

节点属性的处理:

for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
    attributes[name] = transformAttribute(doc, tagName, name, value);
}

处理外联 css 样式,通过 getCssRulesString 获取到具体样式代码,并且储存到 attributes 中。

const cssText = getCssRulesString(stylesheet as CSSStyleSheet);
if (cssText) {
    attributes._cssText = absoluteToStylesheet(
        cssText,
        stylesheet!.href!,
    );
}

处理 form 表单,逻辑是保存选中状态,并且做了一些安全处理,例如密码框内容替换成 *

if (
    attributes.type !== 'radio' &&
    attributes.type !== 'checkbox' &&
    // ...
) {
    attributes.value = maskInputOptions[tagName] 
        ? '*'.repeat(value.length) 
        : value;
  } else if (n.checked) {
    attributes.checked = n.checked;
  }

canvas 状态保存通过 toDataURL 保存 canvas 数据:

attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL();

「rebuild」 负责重建 DOM :

  • 通过 buildNodeWithSN 函数重组 Node
  • 递归调用 重组子节点
export function buildNodeWithSN(n) {
  // DOM 重组核心函数 buildNode
  let node = buildNode(n, { doc, hackCss });
  // 子节点重建并且appendChild
  for (const childN of n.childNodes) {
    const childNode = buildNodeWithSN(childN);
    if (afterAppend) {
      afterAppend(childNode);
    }
  }
}

Replay 回放

回放部分在 replay.ts 文件中,先创建沙箱环境,接着或进行重建 document 全量快照,在通过 requestAnimationFrame 模拟定时器的方式来播放增量快照。

replay 的构造函数接收两个参数,快照数据 events 和 配置项 config

export class Replayer {
    constructor(events, config) {
        // 1.创建沙箱环境
        this.setupDom();
        // 2.定时器
        const timer = new Timer();
        // 3.播放服务
        this.service = new createPlayerService(events, timer);
        this.service.start();
    }
}

构造函数中最核心三步,创建沙箱环境,定时器,和初始化播放器并且启动。播放器创建依赖 eventstimer ,本质上还是使用 timer 来实现播放。

沙箱环境

首先,在 replay.ts 的构造函数中可以找打 this.setupDom 的调用,setupDom 核心是通过 iframe 来创建出一个沙箱环境。

private setupDom() {
  // 创建iframe
  this.iframe = document.createElement('iframe');
  this.iframe.style.display = 'none';
  this.iframe.setAttribute('sandbox', attributes.join(' '));
}

播放服务

同样在 replay.ts 构造函数中,调用 createPlayerService 函数来创建播放器服务器,该函数在同级目录下的 machine.ts 中定义了,核心思路是通过给定时器 timer 加入需要执行的快照动作 actions , 在调用 timer.start() 开始回放快照。

export function createPlayerService() {
    //...
    play(ctx) {
        // 获取每个 event 执行的 doAction 函数
        for (const event of needEvents) {
            //..
            const castFn = getCastFn(event);
            actions.push({
                doAction: () => {
                    castFn();
                }
            })
            //..
         }
         // 添加到定时器队列中
         timer.addActions(actions);
         // 启动定时器播放 视频
         timer.start();
    },
    //...
}

播放服务使用到第三方库 @xstate/fsm 状态机来控制各种状态(播放,暂停,直播)

定时器 timer.ts 也是在同级目录下,核心是通过 requestAnimationFrame 实现了定时器功能, 并对快照回放,以队列的形式存储需要播放的快照 actions ,接着在 start 中递归调用 action.doAction 来实现对应时间节点的快照还原。

export class Timer {
    // 添加队列
    public addActions(actions: actionWithDelay[]) {
        this.actions = this.actions.concat(actions);
    }
    // 播放队列
    public start() {
        function check() {
            // ...
            // 循环调用actions中的doAction 也就是 castFn 函数
            while (actions.length) {
                const action = actions[0];
                actions.shift();
                // doAction 会对快照进行回放动作,针对不同快照会执行不同动作
                action.doAction();
            }
            if (actions.length > 0 || self.liveMode) {
                self.raf = requestAnimationFrame(check);
            }
        }
        this.raf = requestAnimationFrame(check);
    }
}

doAction 在不同类型快照会执行不同动作,在播放服务中 doAction 最终会调用 getCastFn 函数来做了一些 case:

private getCastFn(event: eventWithTime, isSync = false) {
    switch (event.type) {
        case EventType.DomContentLoaded: //dom 加载解析完成
        case EventType.FullSnapshot: // 全量快照
        case EventType.IncrementalSnapshot: //增量
            castFn = () => {
                this.applyIncremental(event, isSync);
            }
    }
}

applyIncremental 函数会增对不同的增量快照做不同处理,包含 DOM 增量, 鼠标交互,页面滚动等,以DOM 增量快照的 case 为例,最终会走到 applyMutation中:

private applyIncremental(){
  switch (d.source) {
      case IncrementalSource.Mutation: {
        this.applyMutation(d, isSync); // DOM变化
        break;
      }
      case IncrementalSource.MouseMove: //鼠标移动
      case IncrementalSource.MouseInteraction: //鼠标点击事件
      //...
}

applyMutation 才是最终执行 DOM 还原操作的地方,包含 DOM 的增删改步骤:

private applyMutation(d: mutationData, useVirtualParent: boolean) {
    d.removes.forEach((mutation) => {
        //.. 移除dom
    });
    const appendNode = (mutation: addedNodeMutation) => {
        // 添加dom到具体节点下
    };
    d.adds.forEach((mutation) => {
        // 添加
        appendNode(mutation);
    });
    d.texts.forEach((mutation) => {
        //...文本处理
    });
    d.attributes.forEach((mutation) => {
        //...属性处理
    });
}

以上就是回放的关键流程实现代码,rrweb 中不仅仅是做了这些,还包含数据压缩,移动端处理,隐私问题等等细节处理,有兴趣可自行查看源码。

最后

这种实现录制回放思路确实值得学习,读 rrweb 源码的过程也受益颇多,源码中对数据结构的一些使用,例如双链表,队列,树等也值得一览。

以上便是本次分享的全部内容,希望对你有所帮助 ^_^

参考文章

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

本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/MgBkL2ZmbKKApzSXEfdctg

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237231次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8065次阅读
 目录