ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

rrweb 序列化机制解析:如何把 DOM 转成可传输、可回放的数据结构

rrweb 序列化机制解析:如何把 DOM 转成可传输、可回放的数据结构 前端可观测性开发工具【免费下载链接】rrwebrecord and replay the web项目地址https://gitcode.com/gh_mirrors/rr/rrweb点击查看免费下载导读本文聚焦 rrweb 序列化Serialization模块的设计与实现为什么不能直接用深拷贝 DOM 的方式做远程录制rrweb 如何将一棵真实 DOM 树转换为一棵带唯一标识id的纯数据树以及序列化过程中脚本剥离、输入状态捕获、相对路径转绝对路径、样式表内联化等非标准处理是如何落到源码中的。读完本文你将掌握 rrweb-snapshot 包中全量快照的生成原理、id在增量快照中的纽带作用以及Mirror类如何保证录制端与回放端映射一致。一、为什么 DOM 深拷贝不能直接用于录制如果录制与回放都发生在本地同一浏览器环境最简单的方案是直接深拷贝 DOM// record const snapshot $(body).clone(); // replay $(body).replaceWith(snapshot);以上为 jQuery 简化示例仅保存 body 部分。这种方式将 DOM 对象整体保存在内存中实现了快照。但它有两个根本问题对象不可序列化DOM 节点是浏览器宿主对象无法直接保存为 JSON 等文本格式因而无法通过网络传输也就无法实现远程录制在用户页面录制、在服务端存储、在其他设备回放。快照附带大量冗余直接克隆会把脚本、样式、事件监听等一切内容原样复制既不能用于安全回放也浪费存储与带宽。因此 rrweb 必须实现自己的序列化方法。文档明确指出不直接采用 parse5 等现成开源解析方案原因有二需要实现一种非标准的序列化方法下文详细展开序列化代码要运行在被录制的页面中需要尽可能控制代码量只保留必要功能。从源码结构看这一设计体现在packages/rrweb-snapshot这一独立包中——它专门负责 DOM 序列化与重建与录制record和回放replay解耦packages/rrweb的 record 入口 与 replay 入口 分别复用它完成全量快照与节点重建。二、序列化中的四项特殊处理之所以说 rrweb 的序列化是非标准的是因为除常规的属性、子节点转换外还需要做以下四件事1. 去脚本化script 替换为 noscript被录制页面中的所有 JavaScript 都不应在回放时被执行。rrweb 在快照重建时将script标签改为noscript标签script 内部内容不再重要录制时只记录一个占位标记而不必记录页面上可能存在的大量脚本内容。与此同时脚本对 DOM 造成的变化由 MutationObserver 等机制以事件流的方式另行记录。这一逻辑在源码serializeTextNode中有直接体现snapshot.tsconst isScript parentTagName SCRIPT ? true : undefined; if (isScript) { textContent SCRIPT_PLACEHOLDER; }即script 元素的文本节点被替换为SCRIPT_PLACEHOLDER字符串真正内容被丢弃。重建侧rebuild.ts遇到script标签时会以noscript元素替代从而保证回放环境中不执行任何原始脚本。2. 记录 HTML 未反映的视图状态一些视图状态并不存在于 HTML 源码中。例如input typetext /输入后的值不会反映在其 HTML 中而是存储在 DOM 属性value上。序列化时必须读取该值并转存为属性回放后表现为input typetext valuerecordValue /。源码中的对应实现位于serializeElementNodesnapshot.ts// form fields if ([input, textarea, select].includes(tagName)) { const value (n as HTMLInputElement | HTMLTextAreaElement).value; const checked (n as HTMLInputElement).checked; if ( attributes.type ! radio attributes.type ! checkbox attributes.type ! submit attributes.type ! button value ) { attributes.value maskInputValue({ ... }); } else if (checked) { attributes.checked checked; } }可以看到非 radio/checkbox/submit/button 类型且存在value时将值写入attributes.value并经过maskInputValue的脱敏处理勾选状态则记录checked。option元素还会同步selected状态。类似的状态补录在源码中还有很多例如滚动位置rr_scrollLeft/rr_scrollTop仅在非新增节点时记录因为scrollTop/scrollLeft会触发 reflow而新增节点这两者恒为 0媒体元素播放状态rr_mediaState、rr_mediaCurrentTime、rr_mediaPlaybackRate、rr_mediaMuted、rr_mediaLoop、rr_mediaVolumeCanvas 图像在recordCanvas开启时记录rr_dataURLdialog 打开方式rr_open_mode标记modal/non-modal用于回放时决定调用showModal()还是show()。这些以rr_前缀标识的扩展属性统一约束在 types.ts 的mediaAttributes等类型中。3. 相对路径转换为绝对路径回放时被录制页面会被放置在一个iframe中此时页面 URL 是回放页面的地址。如果录制页面中存在相对路径图片、链接等用户点击或加载时会解析到错误位置。因此在录制时就要将相对路径转换为绝对路径CSS 样式表中的相对路径同样需要转换。源码中的转换入口是transformAttributesnapshot.ts它针对不同属性采用不同策略if (name src || (name href !(tagName use value[0] #))) { return absoluteToDoc(doc, value); } else if (name xlink:href value[0] ! #) { return absoluteToDoc(doc, value); } else if (name background [table, td, th].includes(tagName)) { return absoluteToDoc(doc, value); } else if (name srcset) { return getAbsoluteSrcsetString(doc, value); } else if (name style) { return absolutifyURLs(value, getHref(doc)); } else if (tagName object name data) { return absoluteToDoc(doc, value); }细节上值得注意href以#开头时表示 SVG 内部 id 指针不做转换srcset需要专门解析来自 parse-srcset 的思路仅提取 URL、保留描述符因为其中可能有多个 URL 且逗号可能出现在 URL 内部blob:与data:协议的值原样返回文本节点中STYLE父级的内容也会通过absolutifyURLs转换如url(...)中的相对路径。4. 尽量内联 CSS 样式表如果被录制页面加载了外部样式表尤其是同源样式表浏览器已经解析出 CSSOM 中的 rulesrrweb 可以读取并序列化这些规则生成包含全部规则的内联样式写入_cssText属性。这样即使录制后原始样式表不可访问例如位于内网或 localhost回放仍能正确还原样式。实现同样在serializeElementNode中snapshot.ts// remote css if (tagName link inlineStylesheet) { const stylesheet Array.from(doc.styleSheets).find((s) { return s.href (n as HTMLLinkElement).href; }); let cssText: string | null null; if (stylesheet) { cssText stringifyStylesheet(stylesheet); } if (cssText) { delete attributes.rel; delete attributes.href; attributes._cssText cssText; } } if (tagName style (n as HTMLStyleElement).sheet) { let cssText stringifyStylesheet((n as HTMLStyleElement).sheet as CSSStyleSheet); if (cssText) { if (n.childNodes.length 1) { cssText markCssSplits(cssText, n as HTMLStyleElement); } attributes._cssText cssText; } }对于link relstylesheet序列化时删除rel、href将stringifyStylesheet得到的 CSS 文本写入_cssText对于内联style同样以_cssText记录。stringifyStylesheet定义于 snapshot-utils.ts负责把 CSSStyleSheet 的 rules 序列化为文本。由于样式已捕获其子文本节点通过cssCaptured标志跳过重复记录减少体积。此外由于_cssText承载了样式重建时这些样式可以脱离原站点环境完整还原这也回应了文档所述内网如 localhost录制的效果也能比较好的设计目标。三、全量序列化DOM 树到数据树序列化包含**全量full与增量incremental**两种类型。全量序列化将一棵 DOM 树转化为对应的树状数据结构。以下 DOM 树html body header/header /body /html会被序列化为类似下面的 JSON{ 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 }这个结果有两点需要注意以 Node 为遍历单位除元素类型节点外Text Node、Comment Node 等所有类型的 Node 都会记录注释节点、CDATA 节点、DocumentType 节点也各有对应分支。每个 Node 都有唯一标识id为之后的增量快照做准备。对照源码节点类型通过 NodeType 枚举 区分export enum NodeType { Document, DocumentType, Element, Text, CDATA, Comment, }而serializeNodesnapshot.ts正是按n.nodeType分发到 Document / DocumentType / Element / Text / CDATA / Comment 各分支的serializeNodeWithIdsnapshot.ts则负责递归遍历子节点、分配 id、维护镜像并处理 iframe、外部样式表加载完成后的补充序列化。对外暴露的snapshot()入口位于 snapshot.ts默认选项包括blockClass rr-block、maskTextClass rr-mask、inlineStylesheet true、maskAllInputs false默认仅对password脱敏等。四、唯一标识id关联增量快照的纽带想象同一页面中记录一次点击按钮的操作并回放最直觉的格式是type clickSnapshot { source: MouseInteraction; type: Click; node: HTMLButtonElement; };回放时通过snapshot.node.click()即可再次执行该操作。但在实际场景中即使重建出了完整 DOM也没有办法把增量快照中被交互的 DOM 节点与回放端已存在的 DOM 关联起来——录制端的HTMLButtonElement对象无法穿越网络到达回放端。这正是id存在的意义录制端和回放端维护随时间变化完全一致的id - Node映射并随着 DOM 节点的创建与销毁做同样的更新从而保证增量快照中只需记录id回放时就能找到对应节点。相应地上面的数据结构变为type clickSnapshot { source: MouseInteraction; type: Click; id: Number; };id的分配逻辑在serializeNodeWithId中snapshot.ts若节点已存在于镜像中则复用旧 id否则调用genId()生成递增的新 id_id从 1 开始自增snapshot.ts被 slimDOM 规则排除或空白文本节点则标记为IGNORED_NODE -2。双端映射的载体是Mirror类utils.ts它同时维护两张表export class Mirror implements IMirrorNode { private idNodeMap: idNodeMap new Map(); // id - Node private nodeMetaMap: nodeMetaMap new WeakMap(); // Node - serializedNodeWithId getId(n: Node): number { ... } getNode(id: number): Node | null { ... } add(n: Node, meta: serializedNodeWithId) { ... } removeNodeFromMap(n: Node) { ... } replace(id: number, n: Node) { ... } reset() { ... } }idNodeMap完成id - Node的查找是增量快照回放时定位节点的依据nodeMetaMap使用 WeakMap 保存Node - 序列化元数据实现getId()反查且不阻碍节点被垃圾回收。录制端在serializeNodeWithId中调用mirror.add(n, serializedNode)建立映射回放端在rebuild过程中以相同顺序重建 DOM 并注册同样 id由此保证两侧映射一致性cleanupSnapshot()snapshot.ts将_id重置为 1用于开始新一轮录制。五、从文档到源码完整的链路回顾把上述内容串起来一次远程录制的序列化链路是录制端对document调用snapshot()→ 递归serializeNodeWithId每遇到一个节点serializeNode按nodeType生成对应的serializedNode含去脚本、输入状态、URL 绝对化、样式内联等特殊处理同时genId()分配全局递增id并写入录制端Mirror输出一棵带id的纯 JSON 树全量快照可直接 JSON 序列化后传输存储后续 DOM 变化含鼠标交互以增量快照形式记录——只需携带id而非完整节点引用回放端在rebuild过程中按同一顺序重建 DOM 与Mirror映射增量事件到来时用id精确命中目标节点执行回放。想要验证这套机制的正确性可以查看仓库中的相关测试snapshot.test.ts 覆盖了各类节点与属性的序列化断言rebuild.test.ts 验证了从序列化数据重建 DOM 的还原度integration.test.ts 则从整体上校验录制序列化与回放重建闭环其快照结果沉淀在 integration.test.ts.snap 中。小结rrweb 的序列化核心可以概括为三句话用纯数据树替代 DOM 对象用四项特殊处理保证安全、还原、可迁移用全局唯一id打通全量与增量快照之间的关联。这一设计使得录制数据既能压缩、传输、落库又能在任意环境的 iframe 中忠实还原——这正是 rrweb record and replay the web 能力的基础底座也是理解其后续事件模型参见 events.md与录制/回放流程参见 record-and-replay.md的起点。赞分享前端可观测性开发工具【免费下载链接】rrwebrecord and replay the web项目地址https://gitcode.com/gh_mirrors/rr/rrweb点击查看免费下载相关推荐构建高效iOS界面联动TableView与CollectionView的协同架构指南构建高效iOS界面联动TableView与CollectionView的协同架构指南 在iOS应用开发中复杂列表界面的联动交互是提升用户体验的关键技术。Lirrweb 序列化机制深度解析如何高效记录和回放网页变化rrweb 序列化机制深度解析如何高效记录和回放网页变化 前言 在现代Web开发中记录和回放用户操作是一项极具挑战性的任务。rrweb项目通过创新的序列化机前端可观测性开发工具终极rrweb沙箱机制安全指南保护Web回放的完整方案终极rrweb沙箱机制安全指南保护Web回放的完整方案 rrweb作为一款强大的Web录制与回放工具其核心价值在于能够精准记录并重现用户在网页上的所有操作。前端可观测性开发工具上一篇Hubs未来路线图WebVR协作平台的创新发展趋势下一篇终极Dokploy性能优化指南从卡顿到丝滑的完整教程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表