rrdom:为 rrweb 回放引擎打造的虚拟 DOM 库
【免费下载链接】rrwebrecord and replay the web项目地址: https://gitcode.com/gh_mirrors/rr/rrweb
rrdom 是 rrweb 项目中负责「回放 DOM 变更」的核心虚拟 DOM 库:它既能独立运行,用于构造一棵虚拟 DOM 树并将补丁应用到真实 DOM,也是 rrweb 回放器在快进、拖拽(seek)场景下优化渲染性能的关键基础设施。读完本文,你将理解 rrdom 的节点体系、Mirror 映射机制、diff 算法以及它在 rrweb 回放器 中的实际接入方式,并掌握独立安装与使用它的方法。
rrdom 在 rrweb 生态中的定位
rrdom 的 README 对自己的定位表述得非常明确:
rrdom 是一个虚拟 DOM 库,被 rrweb 用于回放 DOM mutations。它是一个独立库,可以用来创建虚拟 DOM 树,并把补丁应用到真实 DOM。rrweb 使用它来优化回放性能,尤其是在 seek(时间轴跳转/快进)时。
这句话拆开看包含三个关键事实:
- 它是虚拟 DOM 库:与 React 的虚拟 DOM 思想类似,先在内存中维护一棵轻量级的 DOM 树(
RRDocument/RRElement等节点),再通过 diff 算法把增量变化映射到真实 DOM; - 服务于回放(replay)而非录制(record):录制的产物是一系列序列化事件,回放时要把这些事件重新变成真实页面状态,rrdom 负责其中「节点级结构变更」的部分;
- 独立可复用:虽然它是 rrweb monorepo 的一个子包(packages/rrdom),但其 API 设计不依赖回放器本身,可以脱离 rrweb 单独使用。
整个 rrweb 项目的工作流可以参考根目录的 guide.md,rrdom 属于回放链路(replay)一侧的组件。
整体架构:一棵「可补丁」的虚拟 DOM 树
rrdom 的源码只有四个核心文件,结构非常精简:
| 文件 | 职责 |
|---|---|
| packages/rrdom/src/document.ts | 虚拟节点基类与接口定义(IRRNode、IRRDocument、IRRElement等) |
| packages/rrdom/src/index.ts | RRDocument、各元素子类、Mirror、buildFromDom/buildFromNode等对外入口 |
| packages/rrdom/src/diff.ts | diff 算法与createOrGetNode,负责把虚拟树应用到真实 DOM |
| packages/rrdom/src/style.ts | CSS 文本与 camelCase 样式对象之间的互转工具 |
index.ts对外导出的核心符号包括:RRDocument、RRElement、RRMediaElement、RRCanvasElement、RRStyleElement、RRIFrameElement、RRDialogElement、RRText、RRComment、RRCDATASection、Mirror、createMirror、buildFromDom、buildFromNode、getDefaultSN、printRRDom、diff、createOrGetNode,以及ReplayerHandler类型。
节点类型体系
虚拟 DOM 树由BaseRRNode的各个子类构成,节点类型与标准 DOM 一一对应(见 document.ts 中的NodeType枚举,取值与标准Node.nodeType一致):
BaseRRDocument:文档节点,维护documentElement/head/body的访问器(通过遍历子节点查找HTML、HEAD、BODY标签),并保证同一文档内「只允许一个 RRElement 或一个 RRDoctype」;BaseRRDocumentType:doctype 节点,保存name/publicId/systemId;BaseRRElement:元素节点,用Record<string, string>保存属性,提供classList、id、className、style等与真实元素对齐的接口;其中style的 getter 会调用 style.ts 的parseCSSText把style="..."属性文本解析成 camelCase 对象,setProperty/removeProperty则反向用toCSSText写回;BaseRRText/BaseRRComment/BaseRRCDATASection:文本、注释与 CDATA 节点;BaseRRMediaElement:媒体元素,额外携带currentTime、volume、paused、muted、playbackRate、loop等回放状态字段;BaseRRDialogElement:<dialog>元素,用私有属性rr_open_mode区分show()(非模态)与showModal()(模态)两种打开方式。
所有节点都实现了appendChild/insertBefore/removeChild/contains等树操作,这些操作在 document.ts 底部的工具函数中完成双向链表式维护。
需要被 diff 算法「特殊照顾」的元素子类
在RRDocument.createElement的工厂方法中(index.ts),部分标签会被实例化为专门子类:
| 标签 | 子类 | 附加数据 |
|---|---|---|
AUDIO/VIDEO | RRMediaElement | 播放状态字段(见上) |
IFRAME | RRIFrameElement | 内嵌一棵contentDocument虚拟文档 |
CANVAS | RRCanvasElement | rr_dataURL初始图像、canvasMutations变更队列 |
STYLE | RRStyleElement | rules样式表规则数组 |
DIALOG | RRDialogElement | 模态状态 |
这些子类存在的意义在于:真实浏览器里这些元素具有「特殊行为」,无法仅靠属性/子节点表达完整状态(例如 canvas 的绘图内容、video 的播放位置、dialog 的模态层),因此 diff 算法在应用补丁时必须对它们做定向处理(见下文)。
从真实 DOM 构建虚拟树:buildFromDom 与 buildFromNode
要计算「旧树 → 新树」的差异,首先得把某一时刻的真实 DOM 快照转换成虚拟树。这正是 index.ts 中buildFromNode与buildFromDom的职责:
buildFromNode(node, rrdom, domMirror, parentRRNode?):把单个真实Node转换为对应的RRNode。它按nodeType分派:- 文档节点:若父节点是
IFRAME,则复用RRIFrameElement.contentDocument,否则复用rrdom本身,并同步compatMode(BackCompat/CSS1Compat); - 元素节点:读取
tagName(对HTMLFormElement有特殊处理,见 index.ts),逐项拷贝attributes,并记录scrollLeft/scrollTop; - 文本/注释/CDATA:直接创建对应虚拟节点;
DOCUMENT_FRAGMENT_NODE(即 shadow root):调用attachShadow({ mode: 'open' })挂到父虚拟元素上;- 其余类型返回
null。
- 文档节点:若父节点是
buildFromDom(dom, domMirror?, rrdom?):从根开始递归遍历整棵真实文档树(含 iframe 的contentDocument、元素的shadowRoot),构建完整虚拟树,默认返回新的RRDocument。
值得一提的是 index.ts 的「未序列化节点」设计:回放器中存在一些未经过序列化的事件(例如注入样式规则用的 style 元素),它们的 id 从-2开始递减(unserializedId),与序列化节点(id > 0)区分开,从而避免干扰 diff 算法的节点匹配。测试 test/virtual-dom.test.ts 中「can patch serialized ID for an unserialized node」和「can access a unique, decremented unserializedId every time」两个用例直接验证了这一机制。
Mirror:连接真实 DOM 与虚拟 DOM 的桥梁
diff 算法要高效工作,必须快速回答「这个真实节点对应哪个虚拟节点」。rrdom 为此维护了两套镜像:
NodeMirror(来自 rrweb-snapshot):记录真实 DOM 节点 → 序列化数据(serializedNodeWithId)的映射,属于回放器的既有设施;Mirror(rrdom 自带):记录虚拟RRNode→ 序列化数据的映射,实现在 index.ts,内部用Map<number, RRNode>存 id→节点、用WeakMap<RRNode, serializedNodeWithId>存节点→元数据。
Mirror提供getId/getNode/getMeta/has/hasNode/add/replace/removeNodeFromMap/reset/getIds等 API。核心约定是:同一 id 在两边镜像中指向「逻辑上相同」的节点,diff 算法正是依靠 id 相等性来判断节点是否匹配(见nodeMatching,diff.ts)。
RRDocument默认自带一个mirror实例,也可以通过构造函数传入外部 mirror 共享(index.ts),iframe 的contentDocument会与父文档共享同一个 mirror。
diff 算法:把虚拟树补丁应用到真实 DOM
diff 的核心实现在 packages/rrdom/src/diff.ts,入口签名如下:
export function diff( oldTree: Node, // 真实 DOM 树(被修改的目标) newTree: IRRNode, // 虚拟 DOM 树(期望状态) replayer: ReplayerHandler,// 回放器回调集合 rrnodeMirror?: Mirror, // 虚拟树的 mirror )整个过程分为三个阶段,对应三个函数:
1. diffBeforeUpdatingChildren:更新「自身」属性
先处理节点自身(属性、样式、滚动位置等),再处理子节点。这样做的原因在源码注释中有明确说明:如果父节点的样式/属性影响子节点高度,而applyScroll又依赖正确高度,那么先更新父节点属性才能保证滚动位置计算正确(diff.ts)。
该阶段还会处理两个前置问题:
- 节点类型不一致时的校准:如果新旧树对应位置的节点类型不同(如
sameNodeType返回 false),先用createOrGetNode创建正确的真实节点替换旧节点,避免后续 diff 出错; - Document 节点的特殊情况:当 iframe 的 contentDocument 被浏览器自动挂载了 html/head/body,或者新文档的序列化 id 与旧文档不一致时,需要关闭再重新 open 文档,并同步更新 NodeMirror(diff.ts)。
2. diffChildren:子节点列表的增删移
子节点对比采用了双端指针 + id 哈希表的经典 diff 策略(类似 Vue 的 diff 思路,diff.ts):
- 依次比较「旧首 vs 新首」「旧尾 vs 新尾」「旧首 vs 新尾」「旧尾 vs 新首」四对组合,能匹配就移动指针,避免不必要的重建;
- 若四对都不匹配,则为旧子节点建立
id → 下标的哈希表,尝试按 id 找到可移动的节点;找不到才调用createOrGetNode创建新节点; - 循环结束后,如果旧树还有剩余就删除(并同步
mirror.removeNodeFromMap),如果新树还有剩余就批量插入; - 最后递归地对逐对子节点再次调用
diff,完成深层结构的同步。
其中还处理了两个文档标准限制的边界情况:同一文档不允许同时存在两个 doctype,也不允许两个 HTML 根元素,因此插入新节点前要先移除旧的(diff.ts)。
3. diffAfterUpdatingChildren:应用「事后」效果
某些节点状态必须在子节点更新完成后才能正确应用(diff.ts):
- Document / Element:应用
scrollData(滚动位置);元素再应用inputData(输入值——注释说明:如果 select 的 options 还没填充就设置 value 会失效); - AUDIO / VIDEO:同步
paused、muted、volume、currentTime、playbackRate、loop; - CANVAS:若存在
rr_dataURL(iframe 场景下的初始图像数据,见 diff.ts),先绘制图像,再按序重放canvasMutations中的绘制指令; - STYLE:在子节点更新后再应用
rules,避免 textContent 覆盖属性(diff.ts); - DIALOG:对比新旧
open与模态状态,决定调用close()/show()/showModal(),并捕获异常避免中断渲染。
此外,ReplayerHandler.afterAppend回调会在新插入节点上以后序遍历顺序触发(createdNodeSet弱集合保证顺序与 rrweb-snapshot 包一致,diff.ts),rrweb 回放器用它来通知各插件执行onBuild钩子。
createOrGetNode:虚拟节点 → 真实节点
当 diff 需要插入一个新节点时,调用 diff.ts 的createOrGetNode:
- 先查 NodeMirror:若该 id 对应的真实节点已存在且类型一致,直接复用;
- 否则按虚拟节点类型创建真实节点:SVG 元素走
createElementNS(利用SVGTagMap把clippath之类的 tagName 还原为 camelCase,并正确设置xlink:href等命名空间,见 diff.ts),普通元素走createElement; - 创建后把序列化数据写入 NodeMirror,并登记到
createdNodeSet以便触发afterAppend。
回放器如何消费 rrdom:useVirtualDom 与 seek 优化
rrdom 真正发挥威力的场景是快进(fast-forward / seek)。在 rrweb 回放器 中:
- 回放器持有
public virtualDom: RRDocument = new RRDocument()(replay/index.ts); - 默认配置项
useVirtualDom: true,即虚拟 DOM 优化默认开启(replay/index.ts),可在playerConfig中关闭(类型定义见 packages/rrweb/src/types.ts); - 在
applyMutation中(replay/index.ts),只有当useVirtualDom 开启、尚未启用虚拟 DOM、且正在同步快进(isSync)三者同时满足时才启动优化,把真实 iframe 文档整树构建为虚拟树buildFromDom(this.iframe.contentDocument!, this.mirror, this.virtualDom); - 快进过程中产生的节点 mutation 只修改虚拟树(成本远低于直接操作真实 DOM),快进结束后一次性调用
diff(this.iframe.contentDocument, this.virtualDom, replayerHandler, this.virtualDom.mirror)把累积的差异批量应用到真实 DOM(replay/index.ts),随后destroyTree()复位(index.ts 会清空子节点并mirror.reset())。
源码注释解释了为什么只在「节点类 mutation + 同步快进」时启用:创建虚拟树并执行 diff 的成本通常高于直接应用其他类型的事件(如鼠标、滚动),因此优化只针对最耗时的场景(replay/index.ts)。
回放器把自身能力以ReplayerHandler的形式注入 diff 过程(replay/index.ts 中构造的replayerHandler),包含applyCanvas、applyInput、applyScroll、applyStyleSheetMutation和afterAppend,这样 rrdom 只负责结构 diff,而把 canvas 绘制、输入回填、滚动恢复等「副作用」委托给回放器执行——这也是 rrdom 能与 rrweb 解耦的关键设计。
独立安装与使用
rrdom 是发布到 npm 的独立包(当前版本 2.1.5,见 packages/rrdom/package.json),可通过包管理器直接安装:
yarn add rrdom # 或 npm install rrdom包同时提供 ESM(dist/rrdom.js)、CJS(dist/rrdom.cjs)与 UMD(dist/rrdom.umd.cjs,同时配置了 unpkg/jsdelivr CDN 入口),并导出 TypeScript 类型声明。它唯一的运行时依赖是rrweb-snapshot(用于复用NodeMirror与序列化类型)。
一个最小的独立使用示例——从当前页面构建虚拟树并打印:
import { RRDocument, buildFromDom } from 'rrdom'; const virtualDom = new RRDocument(); buildFromDom(document, undefined, virtualDom); console.log(virtualDom.documentElement?.tagName); // 'HTML' console.log(virtualDom.head?.tagName); // 'HEAD' console.log(virtualDom.body?.tagName); // 'BODY'调试时可使用printRRDom(rootNode, mirror)(index.ts)以带缩进的文本形式输出整棵虚拟树(含 shadowRoot 与 iframe 子文档),测试用例也大量依赖它做快照断言。
测试与正确性保障
rrdom 的测试(packages/rrdom/test)覆盖了三个层面:
- test/virtual-dom.test.ts:在 jsdom 与真实浏览器(puppeteer)中验证节点构建——包括 quirks 模式文档的
compatMode同步、未序列化节点的负 id 分配、滚动位置采集、iframecontentDocument与 shadow DOM 的构建、RRDocument的增删开合与 mirror 全量 API,以及对 main.html / iframe.html / shadow-dom.html / XML 页面的构建快照; - test/diff.test.ts:用
createTree辅助函数按描述性数据结构生成新旧两棵树,覆盖单节点 diff、子节点增删移、doctype/根元素冲突等边界,并严格要求测试过程中不触发console.warn(一旦 diff 抛错会冒泡到ReplayerEvents.Flush监听器中断渲染); - test/snapshots/virtual-dom.test.ts.snap:虚拟树打印结果的快照基线。
通过yarn test(vitest)即可运行整个包的全部用例。
小结
rrdom 以约四个源文件的精简体积,为 rrweb 回放器提供了「虚拟树构建 → id 镜像映射 → diff 补丁 → 特殊元素副作用」的完整闭环:
- 构建:
buildFromDom/buildFromNode把真实 DOM(含 iframe、shadow DOM)镜像为虚拟树; - 匹配:
Mirror双镜像机制用序列化 id 建立真实节点与虚拟节点的对应关系; - 补丁:
diff采用双端指针 + id 哈希策略高效同步子节点,并针对 media/canvas/style/dialog 等特殊元素在事后阶段应用状态; - 接入:回放器通过
useVirtualDom(默认开启)把虚拟 DOM 优化限定在「同步快进 + 节点 mutation」这一最高开销场景,seek 结束后一次性落盘到真实 DOM。
如果你正在研究会话回放(session replay)的性能优化,或者需要一个可在浏览器环境独立运行、可对真实 DOM 应用补丁的轻量虚拟 DOM 实现,rrdom 的源码(document.ts、diff.ts、index.ts)与测试都是值得精读的参考。
【免费下载链接】rrwebrecord and replay the web项目地址: https://gitcode.com/gh_mirrors/rr/rrweb
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考