云音乐内部有许多内容管理系统 (Content Management System,CMS),用来支撑业务的运营配置等工作,运营同学在使用过程中遇到问题时,期望开发人员可以及时给予反馈并解决问题;痛点是开发人员没有问题现场,很难去快速定位到问题,通常的场景是:
为了对运营同学在使用中遇到的相关问题及时给予反馈,尽快定位并解决 CMS 用户遇到的使用问题,设计实现了问题一键上报插件,用于还原问题现场,主要包括录制和展示两部分:
问题一键上报插件设计的主要流程如下图所示,在录屏期间,插件需要分别收集用户基础信息、API 请求数据、错误堆栈信息和录屏信息,并将数据上传到 NOS 云端和倾听平台。在整个上报的流程中,如何实现操作录屏和回放是一个难点,经过调研,发现 rrweb[1] 开源库可以很好的满足我们的需求。rrweb 库支持的场景有录屏回放、自定义事件、console 录制播放等多种场景,其中录屏回放是最常用的使用场景,具体使用详见场景示例[2]。
本文主要介绍的是 rrweb 库的录屏回放实现原理。
rrweb 主要由 rrweb
、 rrweb-player
和 rrweb-snapshot
三个库组成:
了解 rrweb 库的原理,可以从下面几个关键问题入手:
基于 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[3] 查看。
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 发生变化时,若每次都完整记录整个 DOM 树,数据会非常庞大,显然不是一个可行的方案,所以,rrweb 采用了增量快照的处理方式。
有三个关键的 Set:addedSet
、 movedSet
、 droppedSet
,对应三种节点操作:新增、移动、删除,这点和 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 的关联关系是通过 parentId
和 nextId
建立起来的,若该 DOM 节点的父节点、或下一个兄弟节点尚未被序列化,则该节点无法被准确定位,所以需要先将其存储下来,最后处理。
rrweb 使用了一个双向链表 addList
用来存储父节点尚未被添加的节点,向 addList
中插入节点时:
node.previousSibling
节点后node.nextSibling
节点前通过这种添加方式,可以保证兄弟节点的顺序,DOM 节点的 nextSibling
一定会在该节点的后面,previousSibling
一定在该节点的前面;addedSet
序列化处理完成后,会对 addList
链表进行倒序遍历,这样可以保证 DOM 节点的 nextSibling
一定是在 DOM 节点之前被序列化,下次序列化 DOM 节点的时候,就可以拿到 nextId
。
遍历 MutationRecord.addedNodes
节点,若记录的节点有 __sn
属性,则添加到 movedSet
中。有 __sn
属性代表是已经被序列化处理过的 DOM 节点,即意味着是对节点的移动。
在对 movedSet
中的节点序列化处理之前,会判断其父节点是否已被移除:
遍历 MutationRecord.removedNodes
节点:
addedSet
中移除该节点,同时记录到 droppedSet
中,在处理新增节点的时候需要用到:虽然我们移除了该节点,但其子节点可能还存在于 addedSet
中,在处理 addedSet
节点时,会判断其祖先节点是否已被移除;this.removes
中,记录了 parentId 和节点 id。MutationBuffer
实例会调用 snapshot
的 serializeNodeWithId
方法对 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,
);
_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
方法进行加密处理;serializedNode
,其中包含前面处理过的 attributes 属性,序列化的关键是每个节点都会有唯一的 id,其中 rootId
代表所属 document 的 id,帮助我们在回放的时候识别根节点。 return {
type: NodeType.Element,
tagName,
attributes,
childNodes: [],
isSVG,
needBlock,
rootId,
};
拿到序列化后的 DOM 节点,会统一调用wrapEvent
方法给事件添加上时间戳,在回放的时候需要用到。
function wrapEvent(e: event): eventWithTime {
return {
...e,
timestamp: Date.now(),
};
}
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
参数:
n
为事件队列总长度减一;timeOffset
;timestamp
和 timeOffset
计算出拖拽后的 基线时间戳(baselineTime)
;timestamp
截取 基线时间戳(baselineTime)
后的事件队列,即需要回放的事件队列。拿到事件队列后,需要遍历事件队列,根据事件类型转换为对应的回放 Action,并且添加到自定义计时器 Timer 的 Action 队列中。
actions.push({
doAction: () => {
castFn();
},
delay: event.delay!,
});
doAction
为回放的时候要调用的方法,会根据不同的 EventType
去做回放处理,例如 DOM 元素的变化对应增量事件 EventType.IncrementalSnapshot
。若是增量事件类型,回放 Action 会调用 applyIncremental
方法去应用增量快照,根据序列化后的节点数据构建出实际的 DOM 节点,为前面序列化 DOM 的反过程,并且添加到iframe容器中。delay
= event.timestamp - baselineTime,为当前事件的时间戳相对于基线时间戳
的差值Timer 自定义计时器是一个高精度计时器,主要是因为 start
方法内部使用了 requestAnimationFrame
去异步处理队列的定时回放;与浏览器原生的 setTimeout
和 setInterval
相比,requestAnimationFrame
不会被主线程任务阻塞,而执行 setTimeout
、 setInterval
都有可能会有被阻塞。
其次,使用了 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。
rrweb: https://github.com/rrweb-io/rrweb
[2]场景示例: https://github.com/rrweb-io/rrweb/blob/master/docs/recipes/index.zh_CN.md
[3]MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver