ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

rrweb 内部设计解析:序列化、增量快照、回放与沙箱机制全解

rrweb 内部设计解析:序列化、增量快照、回放与沙箱机制全解 前端可观测性开发工具【免费下载链接】rrwebrecord and replay the web项目地址https://gitcode.com/gh_mirrors/rr/rrweb点击查看免费下载rrwebrecord and replay the web是目前 Web 前端录制/回放领域最具代表性的开源方案之一。它通过快照 事件流的方式把真实页面完整还原为可传输、可回放的时序数据。本篇技术指南以仓库 docs/design/index.md 这份内部设计文档索引为骨架逐篇深入讲解 rrweb 的四大核心设计DOM 序列化Serialization、增量快照Incremental Snapshots、回放引擎Replay与沙箱隔离Sandbox。读完本文你将理解 rrweb 如何在不执行原始页面脚本的前提下做到像素级还原掌握id - Node映射、MutationObserver 批量去重、高精度回放定时器等底层原理并能循着源码路径深入验证每一个设计决策。一、内部设计文档总览docs/design/index.md是 rrweb 内部设计文档的入口页它汇总了四篇讲解rrweb 最初是如何设计的、底层如何工作的技术文档Serialization序列化 —— 如何捕获完整的 DOM 快照包括跨域 iframe、canvas 与媒体元素Incremental Snapshots增量快照 —— 初始快照之后如何记录 mutation、输入事件、滚动等增量变化Replay回放 —— 如何以高精度时序回放录制事件并最小化对宿主页面的影响Sandbox沙箱 —— 回放器如何借助 iframe sandbox 安全地回放页面而不执行其脚本。这四篇文档互为依赖序列化是基础增量快照依赖序列化建立的id - Node映射回放则依赖前两者产出的快照链沙箱又为回放提供安全边界。下面按此逻辑链逐一展开并在每个主题后补充仓库源码中的实现印证。二、Serialization将 DOM 树序列化为可传输的数据结构2.1 为什么不能直接深拷贝 DOM如果录制与回放都发生在本地浏览器内最简单的方案是直接深拷贝 DOM 对象——文档给出了一个用 jQuery 简化演示的思路// record const snapshot $(body).clone(); // replay $(body).replaceWith(snapshot);但 DOM 对象本身不可序列化not serializable无法保存为 JSON 等文本格式进行跨端传输而远程录制恰恰是 rrweb 的核心使用场景。因此必须实现一套将 DOM 数据序列化的方法。为什么不直接采用 parse5 之类的现成开源方案文档给出了两个原因rrweb 需要实现一种非标准的序列化方法特殊处理逻辑无法用通用解析器覆盖这部分代码要运行在被录制的页面上必须严格控制代码体积只保留必要功能。从源码结构看这套序列化逻辑被独立成rrweb-snapshot包packages/rrweb-snapshot/src/snapshot.ts与录制端解耦正体现了轻量、独立的设计意图。2.2 序列化的四类特殊处理之所以说非标准是因为序列化过程需要额外处理以下四件事输出要具备描述性去脚本化。回放时不应执行录制页面里的任何 JavaScript。rrweb 的做法是把script标签替换为占位符noscript标签脚本内容本身不再重要——脚本对 DOM 造成的变化会被单独记录因此无需保存原始页面上可能存在的海量脚本内容。记录 HTML 中不体现的视图状态。例如input typetext /的值并不会体现在其 HTML 结构里序列化时必须读取其value并作为属性记录最终形如input typetext valuerecordValue /。源码中maskInputValue、getInputType见 packages/rrweb-snapshot/src/snapshot-utils.ts正是负责这部分取值与脱敏处理。相对路径转换为绝对路径。回放时录制页面被放入iframe此时页面 URL 是回放页地址若录制内容中残留相对路径用户点击后会发生跳转错误。因此录制阶段要把相对路径转绝对路径CSS 样式表内的相对路径同样需要转换。对应源码实现为absolutifyURLsURL 绝对化以及getAbsoluteSrcsetString对srcset中的每个 URL 逐一绝对化见 packages/rrweb-snapshot/src/snapshot.ts。记录 CSS 样式表内容。若录制页面链接了外部样式表浏览器可以拿到解析后的 CSS 规则rrweb 会据此生成包含全部规则的内联样式表。这样即使样式表位于内网或 localhost 等回放时不可达的位置也能被完整收录并正确回放。对应实现为stringifyStylesheet与 packages/rrweb-snapshot/src/css.ts。2.3 唯一标识为增量快照铺路的id序列化同时要服务全量与增量两种形态。全量序列化把 DOM 树转换为对应的树形数据结构例如下面的 DOM 树html body header/header /body /html会被序列化为{ type: Document, childNodes: [ { type: Element, tagName: html, attributes: {}, childNodes: [ { type: Element, tagName: head, attributes: {}, childNodes: [], id: 3 }, { type: Element, tagName: body, attributes: {}, childNodes: [ { type: Text, textContent: \n , id: 5 }, { type: Element, tagName: header, attributes: {}, childNodes: [ { type: Text, textContent: \n , id: 7 } ], id: 6 } ], id: 4 } ], id: 2 } ], id: 1 }这个结果有两个要点遍历 DOM 树时以Node为单位因此除了元素类型节点文本节点Text、注释节点Comment等所有 Node 类型都会被记录每个 Node 都会被赋予唯一标识id用于后续增量快照的定位。为什么需要id设想我们录制了同页面上一次按钮点击并回放直观的增量快照格式是type clickSnapshot { source: MouseInteraction; type: Click; node: HTMLButtonElement; };回放时执行snapshot.node.click()即可复现。但实际场景中尽管重建了完整 DOM增量快照里交互的 DOM 节点与现有 DOM 之间却无法建立关联——你拿不到那个按钮对象的引用。因此设计上引入了id录制端与回放端在各自一侧维护完全一致且随时间同步更新的id - Node映射DOM 节点创建、销毁时两侧同步更新保证快照中只用id就能在回放时找到对应节点。上述结构相应变成type clickSnapshot { source: MouseInteraction; type: Click; id: Number; };源码印证packages/rrweb-snapshot/src/snapshot.ts中通过genId()生成自增 id起始_id 1并定义了IGNORED_NODE -2等特殊哨兵值Mirror类见 packages/rrweb-snapshot/src/utils.ts正是id - Node映射在录制/回放两侧的载体其 API 被录制端packages/rrweb/src/record/index.ts与回放端packages/rrweb/src/replay/index.ts共同使用。三、Incremental Snapshots记录全量快照之后的每一次状态变化完成全量快照后还需要记录一切改变页面状态的事件。文档列出的当前记录范围并会持续扩充DOM 变化节点创建、删除节点属性变化文本变化鼠标移动鼠标交互mouse up、mouse downclick、双击、右键菜单focus、blurtouch start、touch move、touch end页面或元素滚动窗口尺寸变化输入Input3.1 MutationObserver 的批量异步机制与处理难题回放时不执行任何 JavaScript因此脚本对文档的一切改动都要靠录制端记录下来。文档用了一个典型例子用户点击按钮 → 下拉菜单出现 → 用户选择第一项 → 下拉菜单消失回放时点击按钮执行后下拉菜单并不会自动出现——因为原始 JavaScript 不在录制内容中。所以必须记录下拉菜单 DOM 节点的创建、第一项的选中以及后续的删除。这是整个系统最困难的部分。现代浏览器为此提供了强大的 MutationObserver API。文档不展开其基础用法只强调与 rrweb 相关的关键点MutationObserver 是批量异步Bulk Asynchronous回调——一系列 DOM 变化发生后只有一次回调并传入包含多条 mutation record 的数组。这对普通使用没有问题拿到 mutation record 的同时还能直接访问被变更节点的 DOM 对象及父、子、兄弟节点。但 rrweb 存在序列化环节需要更精细地处理各种场景。源码中packages/rrweb/src/record/observer.ts 的initMutationObserver创建了MutationBufferpackages/rrweb/src/record/mutation.ts并以attributes: true、attributeOldValue: true、characterData: true、characterDataOldValue: true、childList: true、subtree: true的完整配置 observe 根节点把所有 mutation record 送入mutationBuffer.processMutations()统一处理。3.2 新增节点延迟序列化 去重两个操作可以产生相同的 DOM 结构但 mutation record 集合完全不同body n1 n2先创建 n1 追加到 body再创建 n2 追加到 n1——产生两条 record加 n1、加 n2先创建 n1、n2 并把 n2 挂到 n1 下再把 n1 追加到 body——只产生一条 record含子节点的 n1。注意第一种情况下n1 被加入时还没有子节点但由于批量异步回调机制当回调真正执行时 n1 在 DOM 中已经带着 n2 了。第二种情况迫使我们在处理新节点时必须遍历其全部后代以确保没有遗漏但这又会导致 n1 第一次记录时把 n2 也错误地记录下来随后处理第二条 record 时二次添加回放时 DOM 结构就会与原始页面不一致。因此处理同一回调内的多条 mutation record 时必须**惰性处理新增节点**先遍历所有 record 收集原始未处理节点全部 record 遍历完后再依据节点加入 DOM 的真实顺序进行去重保证每个节点只被记录一次且无遗漏。但惰性序列化会引入新问题。上文提到要维护id - Node映射新节点出现时需要序列化并加入映射而为了去重序列化必须等所有 record 处理完之后才进行。于是可能出现mutation record 1添加节点 n1暂不序列化等待最终去重mutation record 2n1 增加属性 a1尝试记录为增量快照但从映射中找不到 n1 的 id因为它还没被序列化。结论是既然延迟了新增节点的序列化所有 mutation record 也必须先整体处理完再去重新去重新增节点才不会出乱子。3.3 删除节点dropped nodes处理 mutation record 时可能遇到一个尚未序列化的被移除节点——这说明它是刚新增的节点其添加节点的 record 也一定在本次收到的 record 集合中。这些节点被标记为dropped nodes需要处理两种情况节点既然已被移除就无需回放直接从新增节点池中剔除该规则同样适用于被丢弃节点的后代因此在处理新增节点时要检查它是否有 dropped node 祖先。源码中MutationBuffer内部维护了droppedSet、addedSet等集合见 packages/rrweb/src/record/mutation.tsprocessMutations中先处理删除类 record 收集 dropped 节点再处理属性/文本/新增类 record最后统一序列化新增节点正是文档所述流程的工程实现。3.4 属性变化同节点属性覆盖优化尽管 MutationObserver 是异步批量回调仍可假设同一回调内各次 mutation 的时间间隔极短因此可以在记录 DOM 属性变化时覆盖写以压缩增量快照体积。典型场景是拖拽缩放textarea会触发大量 width/height 不断变化的 mutation record。完整记录会让回放更真实但增量快照数量会急剧膨胀。权衡后rrweb 决定同一节点在同一回调内只记录属性的最终值——后续 record 直接覆盖之前 record 中的属性变化部分。3.5 鼠标移动双层节流与时间逆转记录鼠标位置可以复现移动轨迹。为了既保证回放流畅、又控制增量快照数量mousemove 监听采用两层节流第一层鼠标坐标每 20ms 最多记录一次第二层鼠标坐标集合每 500ms 最多传输一次避免单条快照累积过多坐标数据而过大。补充说明这是设计文档中的原始参数。从当前源码看packages/rrweb/src/record/observer.ts 的initMoveObserver已把采样频率参数化——sampling.mousemove为false时完全不记录为数值时作为节流阈值默认 50mssampling.mousemoveCallback单独控制回调频率。也就是说两层节流的间隔如今可通过sampling配置按需调整设计思路与文档一脉相承。时间逆转Time reversal每条增量快照生成时都会记录时间戳以便回放时在正确时刻应用。但节流会让鼠标移动对应增量快照的时间戳晚于实际录制时间因此需要记录一个负的时间差用于修正并在回放时进行时间校准。3.6 输入Input观察需要观察input、textarea、select三类元素覆盖人类输入与程序化修改两种来源。人类输入主要依赖监听input与change事件并需对同一次输入触发的多个事件去重。另外input typeradio /是特例多个同名 radio 中一个被选中时其余 radio 会被反向取消选中但不会触发事件需要单独处理。程序化修改直接通过代码设置这些元素的属性不会触发 MutationObserver解决方案是劫持对应属性的 setter。文档给出的示例实现function hookSetterT( target: T, key: string | number | symbol, d: PropertyDescriptor, ): hookResetter { const original Object.getOwnPropertyDescriptor(target, key); Object.defineProperty(target, key, { set(value) { // put hooked setter into event loop to avoid of set latency setTimeout(() { d.set!.call(this, value); }, 0); if (original original.set) { original.set.call(this, value); } }, }); return () hookSetter(target, key, original || {}); }注意为了防止 setter 中的录制逻辑阻塞被录制页面的正常交互hook 逻辑被放进事件循环异步执行。源码中该工具函数位于 packages/rrweb/src/utils.ts导出hookSetter与hookResetter类型被 packages/rrweb/src/record/observer.ts 的initInputObserver使用并在录制停止时通过返回的 resetter 还原属性。四、Replay高精度时序回放与缺失节点补全rrweb 的设计原则是录制端尽可能少做处理把对被录制页面的影响降到最低。这意味着很多复杂工作被放到回放端完成。4.1 高精度定时器回放时会一次性拿到完整快照链。若按顺序同步执行所有快照只能直接得到录制页面的最终状态而我们需要的是同步初始化第一个全量快照再异步地按时间间隔逐条应用后续增量快照这需要一个高精度定时器。之所以强调高精度是因为原生setTimeout不保证在设定延时后准时执行——例如主线程被阻塞时。对回放而言这种不精确的延迟不可接受会导致各种怪异现象。rrweb 用requestAnimationFrame实现了一个不断校准的定时器保证大多数情况下增量快照的回放延迟不超过一帧。同时自定义定时器也是快进fast forward功能的基础。对应实现可查看 packages/rrweb/src/replay/timer.ts 中的Timer类以及配合平滑滚动使用的 packages/rrweb/src/replay/smoothscroll.ts。4.2 补全缺失节点missing node pool增量快照设计中提到的延迟序列化策略可能产生无法完整记录的增量快照。考虑如下场景parent node bar node foo节点foo作为 parent 的子节点被添加节点bar被插入到已有子节点foo之前。按实际执行顺序foo会先被 rrweb 序列化。但序列化新节点时除了父节点还需要记录相邻节点以保证回放时新节点能放到正确位置。此时bar已存在但尚未序列化于是把它的 id 记为-1若没有邻居则记null表示不存在。回放端处理新节点foo的增量快照时发现其邻居 id 为 -1、尚未插入于是把foo临时放入missing node pool缺失节点池暂不插入 DOM 树随后正常处理并插入bar。全部回放结束后检查foo的邻居节点 id 是否指向池中节点若匹配则将其从池中取出插入 DOM 树。4.3 模拟 Hover 状态很多网页的 CSS 依赖:hover选择器但 JavaScript 无法直接触发 hover 状态回放时需模拟 hover 以正确呈现样式。方法分两步遍历 CSS 样式表把:hover选择器的规则原样保留但额外附加一个特殊选择器类例如.:hover回放 mouse up 类鼠标交互事件时给事件目标及其所有祖先节点添加.:hover类名鼠标移开时再移除。源码印证在回放重建逻辑中hackCss选项专门控制这一 hover 样式适配行为见 packages/rrweb-snapshot/src/rebuild.ts 中RebuildOptions的hackCss字段及createCache()中的stylesWithHoverClass缓存映射。测试用例可参考 packages/rrweb/test/replay/hover.test.ts 与其对应的 hover 事件场景。4.4 从任意时间点开始播放除了基础回放还希望像rrweb-player这类播放器提供类似视频播放器的能力例如把进度条拖到任意时间点开始播放。实现上给方法传入一个开始时间将快照链切分为前后两段开始时间之前的快照链同步执行之后的快照链沿用正常的异步执行流程即可实现任意时间点起步。五、Sandbox用 iframe 沙箱兜底一切脚本行为5.1 过滤方案的局限序列化设计中提到的去脚本化过程是把script标签改写为noscript标签解决了一部分问题。但仍有大量不在script标签内的脚本化行为例如 HTML 内联事件、表单提交等。脚本行为种类繁多过滤式清理永远不可能完备——一旦某个脚本漏网被执行可能造成不可逆的后果。因此 rrweb 采用 HTML 提供的 iframesandbox特性在浏览器层面做硬性限制。5.2 iframe sandbox 隔离重建快照时rrweb 在iframe元素中重建录制的 DOM通过设置其sandbox属性禁用以下行为表单提交form submissionwindow.open之类的弹窗JS 脚本执行包括内联事件处理器和javascript:URL这与设计预期完全一致——尤其对 JS 脚本的处理用浏览器安全机制比自行实现更安全可靠。当前版本的强制边界rrweb-snapshot.rebuild()在浏览器环境中强制这一安全边界。浏览器端的重建应使用rebuildIntoSandboxedIframe()或使用createSandboxedIframe()创建的 iframe——后者会创建sandboxallow-same-origin的 iframe 再在其中重建若开发者坚持对自行创建的浏览器文档直接调用rebuild()必须显式传入UNSAFE_allowUnprotectedRebuild: true才能开启非受保护重建。源码印证以上 API 全部实现在 packages/rrweb-snapshot/src/rebuild.ts——createSandboxedIframe()强制将 sandbox 设为且仅为allow-same-origin忽略调用方传入的 sandbox 属性rebuildIntoSandboxedIframe()内部组合两者未走沙箱路径的rebuild()会抛出REBUILD_TARGET_ERROR错误提示改用受保护 API。这一安全要求的演进记录在 docs/adr/0001-require-sandboxed-browser-rebuilds.md配套的设计方案可见 docs/superpowers/specs/2026-05-18-sandboxed-rebuild-design.md。跨域 iframe 内容如何被安全地纳入录制另见 docs/recipes/cross-origin-iframes.md 与 ADR docs/adr/0001-record-source-boundary-resolver.md。5.3 避免链接跳转点击a元素的默认行为是跳转到其href对应 URL。回放时我们希望保证视觉上正确的回放——跳转后的页面 DOM 会由后续快照重建而原始跳转应当被禁止。通常做法是通过事件处理器代理捕获所有a的 click 事件并调用event.preventDefault()但回放页面放入沙箱后所有事件处理器都不会执行事件委托也随之失效。因此在回放交互事件时需要注意回放 JS 的 click 事件其实没有必要——禁用 JS 后 click 本身没有任何影响。不过为了优化回放观感可以附加特殊动画效果来可视化元素被鼠标点击让观看者清晰地看到一次点击发生。5.4 iframe 内部样式注入由于 DOM 重建在 iframe 内进行父页面的 CSS 样式表无法作用到 iframe 内的元素。而禁用 JS 后noscript标签会被浏览器显示出来需要隐藏它因此必须动态地向 iframe 注入样式。文档给出的示例const injectStyleRules: string[] [ iframe { background: #f1f3f5 }, noscript { display: none !important; }, ]; const styleEl document.createElement(style); const { documentElement, head } this.iframe.contentDocument!; documentElement!.insertBefore(styleEl, head); for (let idx 0; idx injectStyleRules.length; idx) { (styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx); }重要注意点这条注入的样式元素并不存在于原始录制页面中因此不能被序列化否则会破坏id - Node映射的一致性。回放端注入的辅助样式与录制内容之间的隔离正是保证两侧节点映射始终同步的关键细节之一。相关注入逻辑与样式定义可查看 packages/rrweb/src/replay/styles/inject-style.ts 和 packages/rrweb/src/replay/styles/style.css。六、延伸阅读四篇设计文档原文序列化、增量快照、回放、沙箱设计与实践对照序列化实现 packages/rrweb-snapshot/src/snapshot.ts、重建与沙箱 API packages/rrweb-snapshot/src/rebuild.ts、录制端事件观察 packages/rrweb/src/record/observer.ts、mutation 缓冲 packages/rrweb/src/record/mutation.ts、回放定时器 packages/rrweb/src/replay/timer.ts官方使用指南record-and-replay 实战、customize-replayer自定义回放器、live-mode直播模式、pagination大数据量分页更宏观的架构视野可参考 docs/design 的父级文档索引 对应的 docs/observer 中文版 等本地化版本以及事件类型总览 docs/events.md。以上设计闭环构成了 rrweb 的核心可序列化的全量快照 带唯一 id 的增量事件流 高精度回放引擎 浏览器级沙箱隔离。理解了这四层设计无论是二次开发录制插件、优化存储体积还是定制回放器行为都能有的放矢。赞分享前端可观测性开发工具【免费下载链接】rrwebrecord and replay the web项目地址https://gitcode.com/gh_mirrors/rr/rrweb点击查看免费下载相关推荐rrweb 序列化机制深度解析从 DOM 快照到唯一标识 id 的设计与实现rrweb 序列化机制深度解析从 DOM 快照到唯一标识 id 的设计与实现 序列化是 rrweb 实现远程录制与回放的基石它负责把浏览器中的 DOM前端可观测性开发工具rrweb 回放沙箱机制解析用 iframe sandbox 隔离脚本行为与重建安全边界rrweb 回放沙箱机制解析用 iframe sandbox 隔离脚本行为与重建安全边界 在 rrweb 的录制 回放体系中回放阶段绝不会重新执行录制页面中前端可观测性开发工具rrweb-snapshot 沙箱化重建Sandboxed Rebuild浏览器回放安全边界的设计与实现rrweb snapshot 沙箱化重建Sandboxed Rebuild浏览器回放安全边界的设计与实现 导读 本文深入剖析 rrweb 仓库中 docs前端可观测性开发工具创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表