关于前端:rrweb-带你还原问题现场

7次阅读

共计 8277 个字符,预计需要花费 21 分钟才能阅读完成。

本文作者:令姜

背景

云音乐外部有许多内容管理系统 (Content Management System,CMS),用来撑持业务的经营配置等工作,经营同学在应用过程中遇到问题时,冀望开发人员能够及时给予反馈并解决问题;痛点是开发人员没有问题现场,很难去疾速定位到问题,通常的场景是:

  • 经营同学 Watson:「Sherlock,我在配置 mlog 标签的时候提醒该标签不存在,快帮我看下,急。」
  • 开发同学 Sherlock:「不慌,我看看。」(关上测试环境的经营治理后盾,一顿操作,所有十分的失常…)
  • 开发同学 Sherlock:「我这儿失常的啊,你的工位在哪,我去你那看看」
  • 经营同学 Watson:「我在北京…」
  • 开发同学 Sherlock:「我在杭州…」

为了对经营同学在应用中遇到的相干问题及时给予反馈,尽快定位并解决 CMS 用户遇到的应用问题,设计实现了问题一键上报插件,用于还原问题现场,次要包含录制和展现两局部:

  • ThemisRecord 插件:上报用户根底信息、用户权限、API 申请 & 后果、谬误堆栈、录屏
  • 聆听平台承接展现:显示录屏回放、用户、申请和谬误堆栈信息

上报流程

问题一键上报插件设计的次要流程如下图所示,在录屏期间,插件须要别离收集用户根底信息、API 申请数据、谬误堆栈信息和录屏信息,并将数据上传到 NOS 云端和聆听平台。

在整个上报的流程中,如何实现操作录屏和回放是一个难点,通过调研,发现 rrweb 开源库能够很好的满足咱们的需要。rrweb 库反对的场景有录屏回放、自定义事件、console 录制播放等多种场景,其中录屏回放是最罕用的应用场景,具体应用详见场景示例。

本文次要介绍的是 rrweb 库的录屏回放实现原理。

rrweb 库

rrweb 次要由 rrwebrrweb-playerrrweb-snapshot 三个库组成:

  • rrweb:提供了 record 和 replay 两个办法;record 办法用来记录页面上 DOM 的变动,replay 办法反对依据工夫戳去还原 DOM 的变动。
  • rrweb-player:基于 svelte 模板实现,为 rrweb 提供了回放的 GUI 工具,反对暂停、倍速播放、拖拽时间轴等性能。外部调用了 rrweb 的提供的 replay 等办法。
  • rrweb-snapshot:包含 snapshot 和 rebuilding 两大个性,snapshot 用来序列化 DOM 为增量快照,rebuilding 负责将增量快照还原为 DOM。

理解 rrweb 库的原理,能够从上面几个关键问题动手:

  • 如何实现事件监听
  • 如何序列化 DOM
  • 如何实现自定义计时器

如何实现事件监听

基于 rrweb 去实现录屏,通常会应用上面的形式去记录 event,通过 emit 回调办法能够拿到 DOM 变动对应所有 event。拿到 event 后,能够依据业务需要去做解决,例如咱们的一键上报插件会上传到云端,开发者能够在聆听平台拉取云端的数据并回放。

let events = [];

rrweb.record({
  // emit option is required
  emit(event) {
    // push event into the events array
    events.push(event);
  },
});

record 办法外部会依据事件类型去初始化事件的监听,例如 DOM 元素变动、鼠标挪动、鼠标交互、滚动等都有各自专属的事件监听办法,本文次要关注的是 DOM 元素变动的监听和解决流程。

要实现对 DOM 元素变动的监听,离不开浏览器提供的 MutationObserver API,该 API 会在一系列 DOM 变动后,通过 批量异步 的形式去触发回调,并将 DOM 变动通过 MutationRecord 数组传给回调办法。具体的 MutationObserver 介绍能够返回 MDN 查看。

rrweb 外部也是基于该 API 去实现监听,回调办法为 MutationBuffer 类提供的 processMutations 办法:

  const observer = new MutationObserver(mutationBuffer.processMutations.bind(mutationBuffer),
  );

mutationBuffer.processMutations 办法会依据 MutationRecord.type 值做不同的解决:

  • type === 'attributes': 代表 DOM 属性变动,所有属性变动的节点会记录在 this.attributes 数组中,构造为 {node: Node, attributes: {} },attributes 中仅记录本次变动波及到的属性;
  • type === 'characterData': 代表 characterData 节点变动,会记录在 this.texts 数组中,构造为 {node: Node, value: string},value 为 characterData 节点的最新值;
  • type === 'childList': 代表子节点树 childList 变动,比起后面两种类型,解决会较为简单。

childList 增量快照

childList 发生变化时,若每次都残缺记录整个 DOM 树,数据会十分宏大,显然不是一个可行的计划,所以,rrweb 采纳了增量快照的解决形式。

有三个要害的 Set:addedSetmovedSetdroppedSet,对应三种节点操作:新增、挪动、删除,这点和 React diff 机制类似。此处应用 Set 构造,实现了对 DOM 节点的去重解决。

节点新增

遍历 MutationRecord.addedNodes 节点,将未被序列化的节点增加到 addedSet 中,并且若该节点存在于被删除汇合 droppedSet 中,则从 droppedSet 中移除。

示例:创立节点 n1、n2,将 n2 append 到 n1 中,再将 n1 append 到 body 中。

body
  n1
    n2

上述节点操作只会生成一条 MutationRecord 记录,即减少 n1,「n2 append 到 n1」的过程不会生成MutationRecord 记录,所以在遍历 MutationRecord.addedNodes 节点,须要去遍历其子节点,不然 n2 节点就会被脱漏。

遍历完所有 MutationRecord 记录数组,会对立对 addedSet 中的节点做序列化解决,每个节点序列化解决的后果是:

export type addedNodeMutation = {
  parentId: number;
  nextId: number | null;
  node: serializedNodeWithId;
}

DOM 的关联关系是通过 parentIdnextId 建设起来的,若该 DOM 节点的父节点、或下一个兄弟节点尚未被序列化,则该节点无奈被精确定位,所以须要先将其存储下来,最初解决。

rrweb 应用了一个双向链表 addList 用来存储父节点尚未被增加的节点,向 addList 中插入节点时:

  1. 若 DOM 节点的 previousSibling 已存在于链表中,则插入在 node.previousSibling 节点后
  2. 若 DOM 节点的 nextSibling 已存在于链表中,则插入在 node.nextSibling 节点前
  3. 都不在,则插入链表的头部

通过这种增加形式,能够保障兄弟节点的程序,DOM 节点的 nextSibling 肯定会在该节点的前面,previousSibling 肯定在该节点的后面;addedSet 序列化解决实现后,会对 addList 链表进行倒序遍历,这样能够保障 DOM 节点的 nextSibling 肯定是在 DOM 节点之前被序列化,下次序列化 DOM 节点的时候,就能够拿到 nextId

节点挪动

遍历 MutationRecord.addedNodes 节点,若记录的节点有 __sn 属性,则增加到 movedSet 中。有 __sn 属性代表是曾经被序列化解决过的 DOM 节点,即意味着是对节点的挪动。

在对 movedSet 中的节点序列化解决之前,会判断其父节点是否已被移除:

  1. 父节点被移除,则无需解决,跳过;
  2. 父节点未被移除,对该节点进行序列化。

节点删除

遍历 MutationRecord.removedNodes 节点:

  1. 若该节点是本次新增节点,则疏忽该节点,并且从 addedSet 中移除该节点,同时记录到 droppedSet 中,在解决新增节点的时候须要用到:尽管咱们移除了该节点,但其子节点可能还存在于 addedSet 中,在解决 addedSet 节点时,会判断其先人节点是否已被移除;
  2. 须要删除的节点记录在 this.removes 中,记录了 parentId 和节点 id。

如何序列化 DOM

MutationBuffer 实例会调用 snapshotserializeNodeWithId 办法对 DOM 节点进行序列化解决。
serializeNodeWithId 外部调用 serializeNode 办法,依据 nodeType 对 Document、Doctype、Element、Text、CDATASection、Comment 等不同类型的 node 进行序列化解决,其中的要害是对 Element 的序列化解决:

  • 遍历元素的 attributes 属性,并且调用 transformAttribute 办法将资源门路解决为绝对路径;
    for (const { name, value} of Array.from((n as HTMLElement).attributes)) {attributes[name] = transformAttribute(doc, tagName, name, value);
    }
  • 通过查看元素是否蕴含 blockClass 类名,或是否匹配 blockSelector 选择器,去判断元素是否须要被暗藏;为了保障元素暗藏不会影响页面布局,会给返回一个等同宽高的空元素;
    const needBlock = _isBlockedElement(
        n as HTMLElement,
        blockClass,
        blockSelector,
    );
  • 辨别外链 style 文件和内联 style,对 CSS 款式序列化,并对 css 款式中援用资源的相对路径转换为绝对路径;对于外链文件,通过 CSSStyleSheet 实例的 cssRules 读取所有的款式,拼接成一个字符串,放到 _cssText 属性中;
    if (tagName === 'link' && inlineStylesheet) {
        // document.styleSheets 获取所有的外链 style
        const stylesheet = Array.from(doc.styleSheets).find((s) => {return s.href === (n as HTMLLinkElement).href;
        });
        // 获取该条 css 文件对应的所有 rule 的字符串
        const cssText = getCssRulesString(stylesheet as CSSStyleSheet);
        if (cssText) {
            delete attributes.rel;
            delete attributes.href;
            // 将 css 文件中资源门路转换为绝对路径
            attributes._cssText = absoluteToStylesheet( 
                cssText,
                stylesheet!.href!,
            );
        }
    }
  • 对用户输出数据调用 maskInputValue 办法进行加密解决;
  • 将 canvas 转换为 base64 图片保留,记录 media 以后播放的工夫、元素的滚动地位等;
  • 返回一个序列化后的对象 serializedNode,其中蕴含后面解决过的 attributes 属性,序列化的要害是每个节点都会有惟一的 id,其中 rootId 代表所属 document 的 id,帮忙咱们在回放的时候辨认根节点。
    return {
        type: NodeType.Element,
        tagName,
        attributes,
        childNodes: [],
        isSVG,
        needBlock,
        rootId,
    };

Event 工夫戳

拿到序列化后的 DOM 节点,会对立调用 wrapEvent 办法给事件增加上工夫戳,在回放的时候须要用到。

function wrapEvent(e: event): eventWithTime {
  return {
    ...e,
    timestamp: Date.now(),};
}

序列化 id

serializeNodeWithId 办法在序列化的时候会从 DOM 节点的 __sn.id 属性中读取 id,若不存在,就调用 genId 生成新的 id,并赋值给 __sn.id 属性,该 id 是用来惟一标识 DOM 节点,通过 id 建设起 id -> DOM 的映射关系,帮忙咱们在回放的时候找到对应的 DOM 节点。

function genId(): number {return _id++;}

const serializedNode = Object.assign(_serializedNode, { id});

若 DOM 节点存在子节点,则会递归调用 serializeNodeWithId 办法,最初会返回一个上面这样的 tree 数据结构:

{
    type: NodeType.Document,
    childNodes: [{
        {
            type: NodeType.Element,
            tagName,
            attributes,
            childNodes: [{//...}],
            isSVG,
            needBlock,
            rootId,
        }
    }],
    rootId,
};

如何实现自定义计时器


回放的过程中为了反对进度条的随便拖拽,以及回放速度的设置(如上图所示),自定义实现了高精度计时器 Timer,要害属性和办法为:

export declare class Timer {
    // 回放初始地位,对应进度条拖拽到的任意工夫点
    timeOffset: number;
    // 回放的速度
    speed: number;
    // 回放 Action 队列
    private actions;
    // 增加回放 Action 队列
    addActions(actions: actionWithDelay[]): void;
    // 开始回放
    start(): void;
    // 设置回放速度
    setSpeed(speed: number): void;
}

回放入口

通过 Replayer 提供的 play 办法能够将上文记录的事件在 iframe 中进行回放。

const replayer = new rrweb.Replayer(events);
replayer.play();

第一步,初始化 rrweb.Replayer 实例时,会创立一个 iframe 作为承载事件回放的容器,再别离调用创立两个 service:createPlayerService 用于处理事件回放的逻辑,createSpeedService 用于管制回放的速度。

第二步,会调用 replayer.play() 办法,去触发 PLAY 事件类型,开始事件回放的解决流程。

// this.service 为 createPlayerService 创立的回放管制 service 实例
// timeOffset 值为鼠标拖拽后的工夫偏移量
this.service.send({type: 'PLAY', payload: { timeOffset} });

基线工夫戳生成

回放反对随便拖拽的关键在于传入工夫偏移量 timeOffset 参数:

  • 回放的总时长 = events[n].timestamp – events[0].timestamp,n 为事件队列总长度减一;
  • 时间轴的总时长为回放的总时长,鼠标拖拽的起始地位对应时间轴上的坐标为timeOffset
  • 依据初始事件的 timestamptimeOffset 计算出拖拽后的 基线工夫戳(baselineTime)
  • 再从所有的事件队列中依据事件的 timestamp 截取 基线工夫戳(baselineTime) 后的事件队列,即须要回放的事件队列。

回放 Action 队列转换

拿到事件队列后,须要遍历事件队列,依据事件类型转换为对应的回放 Action,并且增加到自定义计时器 Timer 的 Action 队列中。

actions.push({doAction: () => {castFn();
    },
    delay: event.delay!,
});
  • doAction 为回放的时候要调用的办法,会依据不同的 EventType 去做回放解决,例如 DOM 元素的变动对应增量事件 EventType.IncrementalSnapshot。若是增量事件类型,回放 Action 会调用 applyIncremental 办法去利用增量快照,依据序列化后的节点数据构建出理论的 DOM 节点,为后面序列化 DOM 的反过程,并且增加到 iframe 容器中。
  • delay = event.timestamp – baselineTime,为以后事件的工夫戳绝对于 基线工夫戳 的差值

requestAnimationFrame 定时回放

Timer 自定义计时器是一个 高精度 计时器,次要是因为 start 办法外部应用了 requestAnimationFrame 去异步解决队列的定时回放;与浏览器原生的 setTimeoutsetInterval 相比,requestAnimationFrame 不会被主线程工作阻塞,而执行 setTimeoutsetInterval 都有可能会有被阻塞。

其次,应用了 performance.now() 工夫函数去计算以后已播放时长;performance.now()会返回一个用浮点数示意的、精度高达微秒级的工夫戳,精度高于其余可用的工夫类函数,例如 Date.now()只能返回毫秒级别。

 public start() {
    this.timeOffset = 0;
    // performance.timing.navigationStart + performance.now() 约等于 Date.now()
    let lastTimestamp = performance.now();
    // Action 队列
    const {actions} = this;
    const self = this;
    function check() {const time = performance.now();
      // self.timeOffset 为以后播放时长:已播放时长 * 播放速度(speed) 累加而来
      // 之所以是累加,因为在播放的过程中,速度可能会更改屡次
      self.timeOffset += (time - lastTimestamp) * self.speed;
      lastTimestamp = time;
      // 遍历 Action 队列
      while (actions.length) {const action = actions[0];
        // 差值是绝对于 ` 基线工夫戳 ` 的,以后已播放 {timeOffset}ms
        // 所以须要播放所有「差值 <= 以后播放时长」的 action
        if (self.timeOffset >= action.delay) {actions.shift();
          action.doAction();} else {break;}
      }
      if (actions.length > 0 || self.liveMode) {self.raf = requestAnimationFrame(check);
      }
    }
    this.raf = requestAnimationFrame(check);
  }

实现回放 Action 队列转换后,会调用 timer.start() 办法去依照正确的工夫距离顺次执行回放。在每次 requestAnimationFrame 回调中,会正序遍历 Action 队列,若以后 Action 绝对于 基线工夫戳 的差值小于以后的播放时长,则阐明该 Action 在本次异步回调中须要被触发,会调用 action.doAction 办法去实现本次增量快照的回放。回放过的 Action 会从队列中删除,保障下次 requestAnimationFrame 回调不会从新执行。

总结

在理解了「如何实现事件监听」、「如何序列化 DOM」、「如何实现自定义计时器」这几个关键问题后,咱们根本把握了 rrweb 的工作流程,除此之外,rrweb 在回放的时候还应用的 iframe 的沙盒模式,去实现对一些 JS 行为的限度,感兴趣的同学能够进一步去理解。

总之,基于 rrweb 能够不便地帮忙咱们实现录屏回放性能,例如当初在 CMS 业务中落地应用的一键上报性能,通过联合 API 申请、谬误堆栈信息和录屏回放性能,能够帮忙开发对问题进行定位并解决,让你也成为一个 Sherlock。

本文公布自 网易云音乐大前端团队,文章未经受权禁止任何模式的转载。咱们长年招收前端、iOS、Android,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe (at) corp.netease.com!

正文完
 0