Hello~大家好。我是秋天的一阵风
rrweb 的核心魅力在于 "用极小的数据量复现完整的页面操作",这背后是 DOM 快照、增量更新、事件序列化等技术的精密协作。本文将从原理本质出发,用通俗类比 + 源码解析的方式,拆解每个技术模块的实现逻辑,同时揭示 rrweb 在兼容性、性能优化上的关键设计。
一、 DOM 快照生成原理(snapshot)
通俗类比:DOM 快照就像给页面拍 "全景工程图"------ 不是简单记录像素(如截图),而是把页面的 "骨架"(DOM 层级结构)、"皮肤"(CSS 样式)、"零件参数"(元素属性、文本内容)都转化为结构化数据,后续能根据这张 "工程图" 1:1 还原出与原页面一致的初始状态。区别于普通照片,这张 "工程图" 包含所有可编辑的 "零件信息",而非固定的视觉图像。
1.1 核心目标与技术路径
DOM 快照的核心目标是生成 "可序列化、可重建、体积小" 的 DOM 数据,技术路径分为三步:DOM 遍历与节点过滤 →节点属性与样式序列化 →资源引用处理,最终输出 JSON 格式的FullSnapshot事件(rrweb 事件类型标识为 2)。
1.2 关键实现逻辑(参考 rrweb-snapshot 源码)
rrweb-snapshot 库的 takeSnapshot 函数是快照生成的核心,关键步骤如下:
1.2.1 根节点遍历与过滤
从document.documentElement(HTML 根节点)开始深度优先遍历,跳过无需录制的节点(如script、style标签,或带屏蔽类名的元素),避免无效数据占用空间:
js
// 伪代码:DOM节点遍历逻辑
function traverseNode(node, config) {
// 过滤规则:跳过脚本、样式、屏蔽元素、不可见元素
const isIgnored =
node.tagName === 'SCRIPT' ||
node.tagName === 'STYLE' ||
node.classList.contains(config.blockClass) ||
window.getComputedStyle(node).display === 'none';
if (isIgnored) return null;
// 序列化当前节点
const nodeSnapshot = serializeNode(node);
// 递归处理子节点(构建DOM树结构)
const children = [];
for (const child of node.childNodes) {
const childSnapshot = traverseNode(child, config);
if (childSnapshot) children.push(childSnapshot);
}
nodeSnapshot.children = children;
// 内联计算样式(确保回放样式一致)
inlineComputedStyle(node, nodeSnapshot);
return nodeSnapshot;
}
1.2.2 节点序列化(serializeNode)
提取节点关键属性,转化为 JSON 结构,重点处理元素节点与文本节点:
js
// 伪代码:节点序列化
function serializeNode(node) {
const snapshot = {
type: node.nodeType, // 1=元素节点,3=文本节点,8=注释节点(跳过)
};
if (node.nodeType === 1) { // 元素节点
snapshot.tagName = node.tagName.toLowerCase(); // 统一小写(如DIV→div)
snapshot.attributes = {};
// 收集核心属性(id、class、src、href等,跳过自定义无关属性)
const coreAttrs = ['id', 'class', 'src', 'href', 'alt', 'title', 'value', 'checked'];
for (const attr of node.attributes) {
if (coreAttrs.includes(attr.name) || attr.name.startsWith('data-')) {
snapshot.attributes[attr.name] = attr.value;
}
}
// 特殊处理表单元素(记录当前值,而非初始值)
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(node.tagName)) {
snapshot.value = node.value;
snapshot.checked = node.checked || false;
snapshot.selectedIndex = node.tagName === 'SELECT' ? node.selectedIndex : -1;
}
} else if (node.nodeType === 3) { // 文本节点
snapshot.text = node.textContent.trim() || ''; // 过滤空文本,减少体积
}
return snapshot;
}
1.2.3 样式收集与内联
页面样式可能来自link、style或内联style,为避免回放时样式丢失,rrweb 会将计算样式(computedStyle) 内联到节点快照中,同时过滤默认样式减少数据量:
js
// 伪代码:样式内联处理
function inlineComputedStyle(node, snapshot) {
const computedStyle = window.getComputedStyle(node);
const styles = {};
// 仅收集影响视觉的关键样式属性(排除默认值)
const criticalStyles = [
'display', 'position', 'top', 'left', 'width', 'height',
'color', 'background', 'font-size', 'border', 'padding', 'margin'
];
for (const prop of criticalStyles) {
const value = computedStyle.getPropertyValue(prop);
// 过滤默认样式(如div的display:block、body的margin:8px)
if (!isDefaultStyle(snapshot.tagName, prop, value)) {
styles[prop] = value;
}
}
if (Object.keys(styles).length > 0) {
snapshot.styles = styles; // 仅当有非默认样式时才添加,减少体积
}
}
// 辅助函数:判断是否为标签默认样式(基于rrweb内置的默认样式表)
function isDefaultStyle(tagName, prop, value) {
const defaultStyles = {
div: { display: 'block', margin: '0' },
body: { margin: '8px', color: 'rgb(0, 0, 0)' },
input: { border: '1px solid rgb(169, 169, 169)' }
// 其他标签默认样式...
};
return defaultStyles[tagName]?.[prop] === value;
}
1.3 技术难点与解决方案
-
难点 1:样式体积过大与浏览器兼容性
直接收集所有计算样式会导致数据量激增(单个元素可能有上百个样式属性),且不同浏览器默认样式存在差异(如 Chrome 与 Safari 的body默认margin不同)。
解决方案: ① 仅收集 "影响视觉的关键样式"(如display、position),过滤无关样式(如webkit-font-smoothing);
② 维护 "标签默认样式表",仅记录与默认值不同的样式,数据量可减少 60% 以上;
③ 对浏览器私有前缀样式(如-webkit-border-radius)进行兼容处理,统一转化为标准样式。
-
难点 2:跨域资源无法加载
页面中的跨域图片(如 CDN 图片)、字体等资源,回放时可能因 CORS 限制无法加载,导致样式错乱。
解决方案:
-
① 配置
inlineImages: true时,将图片转化为 Base64 编码内联到快照中(适合小图片); -
② 对大图片,回放时通过 "同源代理服务" 转发请求(如后端部署代理接口,将跨域图片 URL 转为同源 URL);
-
③ 记录资源加载失败时的降级样式(如图片占位符),确保回放体验一致。
1.4 DOM 快照生成流程图

二、增量更新机制(MutationObserver)
通俗类比 :如果说 DOM 快照是 "初始工程图",增量更新就是 "工程变更记录"------ 就像建筑施工时,不需要每次都重新绘制完整图纸,只需记录 "在 3 层增加 1 个窗户" "修改 2 层墙体颜色" 这类变化。
rrweb 通过监听 DOM 变化,只记录快照后的增量修改,大幅减少录制数据量。
2.1 核心技术:MutationObserver API
rrweb 的增量更新完全依赖浏览器原生的MutationObserver API,该 API 可监听 DOM 树的 "节点变化、属性变化、文本变化" ,并异步批量触发回调(避免阻塞主线程)。
其核心优势:
- ① 精准监听(可指定监听类型);
- ② 批量处理(短时间内多次变化合并为一次回调);
- ③ 性能友好(异步执行,不阻塞用户交互)。
2.2 监听配置与事件转化
rrweb 初始化时创建 MutationObserver实例,将原生变化事件转化为自定义的Mutation事件(rrweb 事件类型标识为 3),关键逻辑如下:
2.2.1 MutationObserver 初始化
js
// 伪代码:rrweb中MutationObserver配置
function initMutationObserver(emit, config) {
// 回调函数:批量处理DOM变化
const observerCallback = (mutations) => {
// 过滤无需记录的微小变化(如文本节点空字符修改)
const validMutations = mutations.filter(m => isMutationValid(m, config));
if (validMutations.length === 0) return;
// 批量转化为rrweb增量事件并发送
validMutations.forEach(mutation => {
const incrementalEvent = transformMutation(mutation);
emit(incrementalEvent); // 发送到事件队列,后续存储/上传
});
};
// 监听配置:覆盖所有关键变化类型
const observerConfig = {
childList: true, // 监听子节点新增/删除
attributes: true, // 监听元素属性变化
characterData: true, // 监听文本节点内容变化
subtree: true, // 深度监听(子树所有节点,不仅是直接子节点)
attributeOldValue: true, // 记录属性变化前的旧值(便于回放时还原)
characterDataOldValue: true // 记录文本变化前的旧值
};
// 开始监听根节点
const observer = new MutationObserver(observerCallback);
observer.observe(document.documentElement, observerConfig);
return observer; // 返回实例,便于后续停止监听
}
// 辅助函数:过滤无效变化(如屏蔽元素内的变化、空文本修改)
function isMutationValid(mutation, config) {
// 屏蔽元素内的变化不记录
if (mutation.target.closest(`.${config.blockClass}`)) return false;
// 文本节点空字符修改不记录(如用户输入后删除为空)
if (mutation.type === 'characterData') {
return mutation.oldValue.trim() !== '' || mutation.target.textContent.trim() !== '';
}
return true;
}
2.2.2 原生变化事件转化(transformMutation)
将浏览器原生的MutationRecord转化为 "可回放的结构化数据",核心是明确 "变化目标、变化类型、变化内容":
js
// 伪代码:转化原生MutationRecord
function transformMutation(mutation) {
const event = {
type: 3, // Mutation事件类型标识
timestamp: Date.now(), // 事件发生时间戳(用于回放排序)
data: {}
};
// 1. 子节点变化(新增/删除节点)
if (mutation.type === 'childList') {
event.data.type = 'childList';
// 记录父节点路径(便于回放时定位目标父节点)
event.data.parentPath = getNodeUniquePath(mutation.target);
// 序列化新增/删除的节点(仅核心属性,减少体积)
event.data.addedNodes = mutation.addedNodes.map(n => serializeNode(n)).filter(Boolean);
event.data.removedNodes = mutation.removedNodes.map(n => serializeNode(n)).filter(Boolean);
// 记录插入位置参考节点(确保回放时插入顺序正确)
event.data.nextSiblingId = mutation.nextSibling
? getNodeUniqueId(mutation.nextSibling)
: null;
}
// 2. 属性变化(如class、src修改)
else if (mutation.type === 'attributes') {
event.data.type = 'attributes';
event.data.targetPath = getNodeUniquePath(mutation.target);
event.data.attributeName = mutation.attributeName;
event.data.oldValue = mutation.oldValue;
event.data.newValue = mutation.target.getAttribute(mutation.attributeName);
}
// 3. 文本变化(如span内文本修改)
else if (mutation.type === 'characterData') {
event.data.type = 'characterData';
event.data.targetPath = getNodeUniquePath(mutation.target);
event.data.oldValue = mutation.oldValue;
event.data.newValue = mutation.target.textContent;
}
return event;
}
// 辅助函数:生成节点唯一路径(如"body>div.container>ul>li:nth-child(2)")
function getNodeUniquePath(node) {
if (node === document.documentElement) return 'html';
if (node === document.body) return 'body';
const parentPath = getNodeUniquePath(node.parentElement);
const siblings = Array.from(node.parentElement.children);
// 用"标签名+索引"确保唯一性(如li:nth-child(2))
const index = siblings.indexOf(node) + 1;
const nodeName = node.tagName.toLowerCase();
const classAttr = node.classList.length > 0
? `.${Array.from(node.classList).join('.')}`
: '';
const idAttr = node.id ? `#${node.id}` : '';
return `${parentPath}>${nodeName}${idAttr}${classAttr}:nth-child(${index})`;
}
2.3 技术难点与解决方案
-
难点 1:节点路径定位不准确
增量变化需要明确 "哪个节点发生了变化",但 DOM 节点没有天生的唯一标识,动态生成的节点(如 Vue 列表渲染的li)在刷新后路径可能变化,导致回放时无法定位目标节点。
解决方案:
-
① 生成 "唯一路径"(结合标签名、ID、类名、兄弟节点索引),如body>div.container>ul>li:nth-child(2),确保即使节点动态更新,仍能通过路径找到;
-
② 维护 "节点 ID 映射表",录制时为每个节点分配临时 ID(如node.__rrwebId),回放时通过 ID 快速定位,路径作为降级方案(应对 ID 丢失场景)。
-
-
难点 2:高频变化导致性能卡顿
页面中的高频 DOM 变化(如倒计时、动画、滚动加载列表)会触发MutationObserver频繁回调(如每秒几十次),导致前端主线程阻塞,影响用户交互体验。
解决方案:
- ① 结合 "采样率控制",对高频变化事件(如文本倒计时)进行节流处理(如 100ms 内仅记录 1 次变化);
- ② 批量合并短时间内的同类变化(如 100ms 内的多次文本修改合并为 1 次);
- ③ 过滤 "无意义变化"(如元素scrollTop的微小波动、空文本修改),减少事件数量。
2.4 增量更新流程示意图

三、事件捕获和序列化
通俗类比:如果说 DOM 快照是 "初始场景",增量更新是 "场景变化",那么事件捕获就是 "用户动作剧本"------ 就像电影拍摄时,不仅要搭建场景,还要记录演员的 "肢体动作""台词""表情"。
rrweb 需要捕获用户的点击、输入、滚动等交互动作,将其转化为结构化的 "剧本数据",确保回放时能精准还原用户操作轨迹。
3.1 核心事件类型与捕获策略
rrweb 主要捕获 6 类高频用户交互事件,覆盖 90% 以上的前端操作场景,采用 "全局事件委托 + 精准过滤" 的捕获策略,避免给每个节点绑定事件导致内存泄漏:
| 事件类型 | 核心用途 | 关键数据字段 | rrweb 事件类型标识 |
|---|---|---|---|
| 鼠标点击(click) | 记录按钮、链接等点击操作 | 点击坐标(x/y)、目标节点路径 | 4 |
| 键盘输入(keydown) | 记录文本输入、功能键操作 | 按键码(keyCode)、输入内容、目标节点 | 5 |
| 鼠标移动(mousemove) | 记录鼠标位置变化 | 鼠标坐标(x/y)、时间戳 | 6 |
| 滚动(scroll) | 记录页面 / 元素滚动位置 | 滚动目标路径、scrollTop/scrollLeft | 7 |
| 窗口 resize | 记录窗口尺寸变化 | 窗口宽(width)、高(height) | 8 |
| 表单提交(submit) | 记录表单提交操作 | 表单节点路径、提交时间戳 | 9 |
3.2 关键实现逻辑(全局事件委托)
通过在document上绑定事件监听器,利用事件冒泡机制捕获所有子节点的交互,核心代码如下:
js
// 伪代码:rrweb事件捕获核心逻辑
function initEventCapture(emit, config) {
// 需捕获的事件类型与对应的处理函数
const eventHandlers = {
click: handleClick,
keydown: handleKeydown,
mousemove: handleMousemove,
scroll: handleScroll,
resize: handleResize,
submit: handleSubmit
};
// 绑定全局事件委托
Object.entries(eventHandlers).forEach(([type, handler]) => {
document.addEventListener(type, (e) => {
// 过滤规则:1. 屏蔽元素内的事件 2. 非用户触发的事件(如脚本触发的click)
if (isIgnoredEvent(e, config)) return;
// 处理事件并序列化为结构化数据
const eventData = handler(e, config);
// 发送事件(携带时间戳,确保回放顺序)
emit({
type: getRRwebEventType(type), // 转化为rrweb事件标识
timestamp: Date.now(),
data: eventData
});
}, {
passive: type === 'scroll' || type === 'resize', // passive优化:避免滚动阻塞
capture: false // 冒泡阶段捕获,确保能获取最终目标节点
});
});
}
// 辅助函数:过滤无效事件
function isIgnoredEvent(e, config) {
// 1. 屏蔽元素内的事件(如带.rr-block类名的元素)
if (e.target.closest(`.${config.blockClass}`)) return true;
// 2. 排除脚本触发的事件(仅保留用户手动触发)
if (e.isTrusted === false) return true;
// 3. 排除右键点击(contextmenu)和滚轮事件(默认不捕获)
if (e.type === 'click' && e.button === 2) return true;
return false;
}
3.3 典型事件序列化实现(以键盘输入和滚动为例)
不同事件的序列化重点不同,需针对性处理敏感数据(如密码)和冗余信息:
3.3.1 键盘输入事件(keydown)序列化
需区分 "普通字符输入" 和 "功能键",同时对密码等敏感输入进行掩码处理:
js
// 伪代码:键盘输入事件处理
function handleKeydown(e, config) {
const target = e.target;
// 隐私处理:密码输入框且开启掩码,不记录真实内容
const isPasswordInput = target.tagName === 'INPUT' && target.type === 'password';
const shouldMask = isPasswordInput && config.maskInputPassword;
return {
targetPath: getNodeUniquePath(target), // 目标输入框路径
key: shouldMask ? '*' : e.key, // 敏感输入替换为*
keyCode: e.keyCode, // 按键码(回放时模拟输入需用到)
value: shouldMask ? '*' : target.value, // 输入框当前值(非敏感场景)
isFunctionalKey: ['Enter', 'Backspace', 'Tab'].includes(e.key) // 是否为功能键
};
}
3.3.2 滚动事件(scroll)序列化
需区分 "页面滚动" 和 "元素滚动",避免重复记录高频滚动事件:
js
// 伪代码:滚动事件处理(含节流优化)
let lastScrollTime = 0;
function handleScroll(e, config) {
const now = Date.now();
// 节流优化:50ms内仅记录1次,减少高频滚动导致的数据量
if (now - lastScrollTime < 50) return null;
lastScrollTime = now;
// 区分页面滚动和元素滚动
const target = e.target === document ? document.documentElement : e.target;
return {
targetPath: getNodeUniquePath(target),
scrollTop: target.scrollTop,
scrollLeft: target.scrollLeft,
isPageScroll: e.target === document // 是否为页面滚动
};
}
3.4 技术难点与解决方案
-
难点 1:事件顺序错乱
不同事件的触发存在时间差(如 "点击按钮→输入文本→提交表单"),若录制时事件顺序错误,回放会出现逻辑混乱(如未输入就提交)。
解决方案:
-
① 所有事件携带精确时间戳(Date.now()),回放时按时间戳升序执行;
-
② 对存在依赖关系的事件(如 "click" 后触发的 "keydown"),记录事件间的关联 ID(如parentEventId),确保回放时顺序一致。
-
3.5 事件捕获流程示意图

四、回放还原技术
通俗类比:回放就像 "按剧本复现舞台剧"------DOM 快照是 "初始舞台布置",增量事件是 "舞台道具变化指令",用户交互事件是 "演员动作指令",rrweb-player 则是 "导演",按时间顺序执行所有指令,最终还原完整场景。
4.1 回放核心流程
回放过程分为 "初始化准备" "事件调度" "状态同步" 三步,核心是 "按时间戳排序事件" 与 "精准执行指令":
4.1.1 初始化准备(加载快照)
回放开始时,先基于FullSnapshot事件重建初始 DOM,再初始化节点路径映射表(便于后续定位目标节点):
js
// 伪代码:回放初始化
class RRwebPlayer {
constructor(options) {
this.events = options.events.sort((a, b) => a.timestamp - b.timestamp); // 按时间戳排序
this.container = options.target; // 回放容器
this.nodeMap = new Map(); // 节点ID→DOM节点映射表
this.initSnapshot(); // 加载初始快照
}
// 基于FullSnapshot重建初始DOM
initSnapshot() {
// 找到首屏快照事件
const fullSnapshot = this.events.find(e => e.type === 2);
if (!fullSnapshot) throw new Error('缺少首屏快照,无法回放');
// 清空容器,重建DOM
this.container.innerHTML = '';
const rootNode = this.rebuildNode(fullSnapshot.data.node);
this.container.appendChild(rootNode);
// 初始化节点映射表(记录每个节点的唯一ID)
this.buildNodeMap(rootNode);
}
// 从快照节点重建真实DOM
rebuildNode(snapshotNode) {
let node;
if (snapshotNode.type === 1) { // 元素节点
node = document.createElement(snapshotNode.tagName);
// 还原属性(id、class、src等)
Object.entries(snapshotNode.attributes || {}).forEach(([key, value]) => {
node.setAttribute(key, value);
});
// 还原样式(内联快照中的非默认样式)
if (snapshotNode.styles) {
Object.entries(snapshotNode.styles).forEach(([key, value]) => {
node.style[key] = value;
});
}
// 还原表单状态(value、checked)
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(snapshotNode.tagName.toUpperCase())) {
node.value = snapshotNode.value || '';
if (snapshotNode.checked !== undefined) node.checked = snapshotNode.checked;
}
} else if (snapshotNode.type === 3) { // 文本节点
node = document.createTextNode(snapshotNode.text || '');
}
// 递归重建子节点
if (snapshotNode.children && snapshotNode.children.length > 0) {
snapshotNode.children.forEach(childSnapshot => {
const childNode = this.rebuildNode(childSnapshot);
if (childNode) node.appendChild(childNode);
});
}
return node;
}
}
4.1.2 事件调度(按时间顺序执行)
采用 "定时器 + 事件队列" 模式,模拟真实时间流逝,按事件 timestamp 差值执行指令,支持倍速播放(如 1x、2x、4x):
js
// 伪代码:事件调度逻辑
class RRwebPlayer {
// ... 初始化逻辑 ...
// 开始回放
play(speed = 1) {
this.isPlaying = true;
this.playSpeed = speed;
this.currentEventIndex = 0; // 当前执行到的事件索引
this.startTime = Date.now(); // 回放开始时间
this.firstEventTime = this.events[0].timestamp; // 第一个事件的时间戳
// 启动调度器
this.scheduler = setInterval(() => this.executeEvents(), 16); // 约60fps,流畅度优先
}
// 执行当前时间点应触发的事件
executeEvents() {
if (!this.isPlaying) return;
const currentPlayTime = this.firstEventTime + (Date.now() - this.startTime) * this.playSpeed;
// 执行所有timestamp <= 当前播放时间的事件
while (this.currentEventIndex < this.events.length) {
const event = this.events[this.currentEventIndex];
if (event.timestamp > currentPlayTime) break;
// 根据事件类型执行对应操作
this.executeEvent(event);
this.currentEventIndex++;
}
// 回放结束,清除定时器
if (this.currentEventIndex >= this.events.length) {
this.pause();
this.onFinish?.();
}
}
// 执行单个事件
executeEvent(event) {
switch (event.type) {
case 3: // Mutation事件(增量更新)
this.executeMutation(event.data);
break;
case 4: // click事件
this.executeClick(event.data);
break;
case 5: // 键盘事件
this.executeKeyEvent(event.data);
break;
// 其他事件类型执行逻辑...
}
}
}
4.1.3 增量事件执行(以 Mutation 为例)
根据增量事件类型,执行 "节点新增 / 删除""属性修改""文本更新" 等操作:
js
// 伪代码:执行Mutation增量事件
class RRwebPlayer {
// ... 其他方法 ...
executeMutation(mutationData) {
// 定位目标节点(通过路径或节点ID)
const targetNode = this.getTargetNode(mutationData.targetPath || mutationData.parentPath);
if (!targetNode) return;
switch (mutationData.type) {
case 'childList': // 子节点变化
// 新增节点
mutationData.addedNodes.forEach(childSnapshot => {
const childNode = this.rebuildNode(childSnapshot);
if (mutationData.nextSiblingId) {
// 插入到参考节点之前
const nextSibling = this.nodeMap.get(mutationData.nextSiblingId);
targetNode.insertBefore(childNode, nextSibling);
} else {
// 插入到末尾
targetNode.appendChild(childNode);
}
// 更新节点映射表
this.buildNodeMap(childNode);
});
// 删除节点
mutationData.removedNodes.forEach(childSnapshot => {
const childNode = this.getTargetNodeBySnapshot(childSnapshot);
if (childNode && childNode.parentElement === targetNode) {
targetNode.removeChild(childNode);
this.nodeMap.delete(childNode.__rrwebId); // 从映射表移除
}
});
break;
case 'attributes': // 属性修改
targetNode.setAttribute(mutationData.attributeName, mutationData.newValue);
break;
case 'characterData': // 文本修改
targetNode.textContent = mutationData.newValue;
break;
}
}
}
4.2 技术难点与解决方案
- 难点 1:事件执行顺序偏差
录制时事件按真实时间戳存储,但回放时若定时器精度不足(如 setInterval 存在延迟),可能导致 "先执行点击、后执行 DOM 更新" 的顺序错误,引发场景混乱。
解决方案: ① 事件队列按 timestamp 严格排序,执行时通过 "当前播放时间 = 首事件时间 +(当前时间 - 回放开始时间)× 倍速" 精准计算应执行的事件;
② 对依赖 DOM 状态的事件(如 click),增加 "DOM 就绪检查",确保增量更新执行完成后再触发交互事件。
- 难点 2:回放时节点定位失败
若录制时 DOM 结构动态变化(如 Vue 列表重新渲染),回放时可能出现 "路径找不到节点" 的问题。
解决方案:
- ① 采用 "节点 ID 优先、路径降级" 的定位策略,录制时为每个节点分配
\_\_rrwebId,回放时优先通过 ID 定位; - ② 路径定位时支持 "模糊匹配"(如忽略动态索引,通过类名 + 标签名组合定位);
- ③ 定位失败时触发 "降级处理"(如跳过该事件,避免整个回放崩溃)。