rrweb 原理详解:基于 DOM 变动的会话录制与回放技术解析
各位开发者朋友,大家好!今天我们来深入探讨一个在前端监控领域非常热门的技术——rrweb(Record Replayer Web)。它是一个开源项目,能够对用户在网页上的操作行为进行完整记录,并支持后续的回放。这项能力对于调试线上问题、分析用户行为、提升用户体验至关重要。
这篇文章将从原理出发,带你一步步理解 rrweb 是如何通过 MutationObserver 实现 DOM 级别的变化捕捉,并最终构建出可复用的会话录像功能。文章结构如下:
- 什么是 rrweb?
- 核心原理:MutationObserver 的作用
- 数据采集流程详解(含代码)
- 数据存储与传输机制
- 回放引擎设计逻辑
- 实际应用场景与局限性对比
- 总结与建议
1. 什么是 rrweb?
rrweb 是由 Zhihu 开源的一个轻量级前端录制工具,它不依赖浏览器插件或额外服务端组件,纯前端实现,适用于大多数现代浏览器(Chrome / Firefox / Safari / Edge)。
它的核心目标是:
- 记录用户的交互行为(点击、输入、滚动等)
- 捕获页面 DOM 结构的变化
- 将这些信息序列化为 JSON 格式的数据流
- 支持离线播放(replay)
✅ 它不是“屏幕录制”,而是基于 DOM 的语义级记录,体积小、精度高、可扩展性强。
2. 核心原理:MutationObserver 的作用
要理解 rrweb 的工作方式,必须掌握 MutationObserver API —— 这是浏览器原生提供的监听 DOM 变化的能力。
MutationObserver 是什么?
MutationObserver 是 W3C 提供的一种异步观察器接口,可以监听 DOM 节点及其子节点的变动,包括:
| 类型 | 描述 |
|---|---|
| childList | 子节点添加或删除 |
| attributes | 属性修改(如 class、style、data-*) |
| characterData | 文本内容变化(如 innerText 改变) |
| subtree | 是否监听整个子树(默认 false) |
const observer = new MutationObserver((mutationsList) => {
for (let mutation of mutationsList) {
console.log('DOM 发生了变化:', mutation.type);
// 处理每条 mutation
}
});
observer.observe(document.body, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
✅ 在 rrweb 中,这个 Observer 就是整个录制系统的“心跳”。
3. 数据采集流程详解(含代码)
rrweb 的数据采集分为三个阶段:事件捕获 → 数据转换 → 缓存提交
我们以最基础的 record() 函数为例,模拟其内部逻辑(简化版):
第一步:初始化 Recorder
class RrwebRecorder {
constructor(options = {}) {
this.recording = false;
this.buffer = []; // 存储事件数据
this.observer = new MutationObserver(this.handleMutations.bind(this));
this.setup();
}
setup() {
this.observer.observe(document.body, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
}
}
此时,一旦 DOM 发生任何变化,handleMutations 方法就会被触发。
第二步:处理每次 DOM 变化(关键逻辑)
handleMutations(mutations) {
const events = [];
mutations.forEach(mutation => {
const event = {
type: 'mutation',
timestamp: Date.now(),
data: {
type: mutation.type,
target: this.serializeNode(mutation.target),
addedNodes: mutation.addedNodes ? Array.from(mutation.addedNodes).map(n => this.serializeNode(n)) : [],
removedNodes: mutation.removedNodes ? Array.from(mutation.removedNodes).map(n => this.serializeNode(n)) : [],
attributeName: mutation.attributeName,
oldValue: mutation.oldValue
}
};
events.push(event);
});
this.buffer.push(...events);
}
这里的关键在于 serializeNode(node) —— 它负责把 DOM 节点转化为结构化的 JSON 对象。
serializeNode 示例(简化版)
serializeNode(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
const result = {
tagName: node.tagName.toLowerCase(),
attributes: {},
textContent: node.textContent || '',
children: []
};
// 获取属性(排除一些系统属性)
for (let attr of node.attributes) {
if (!attr.name.startsWith('data-')) continue; // 可选过滤
result.attributes[attr.name] = attr.value;
}
// 递归处理子节点
for (let child of node.childNodes) {
const childData = this.serializeNode(child);
if (childData) result.children.push(childData);
}
return result;
}
💡 注意:为了减少冗余数据,rrweb 还会对节点做 diff(比如只记录新增/删除的部分),而不是每次都全量保存整个 DOM。
第三步:缓冲区管理 & 上报
flushBuffer() {
if (this.buffer.length === 0) return;
const payload = {
events: this.buffer.splice(0, this.buffer.length),
sessionId: Math.random().toString(36).substr(2, 9)
};
// 发送到后端或本地存储
fetch('/api/replay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(() => console.log('上传成功'));
}
📌 这个过程就是典型的“增量采集 + 批量上报”策略,兼顾性能和准确性。
4. 数据存储与传输机制
rrweb 不仅记录 DOM 变化,还结合其他事件(鼠标移动、键盘输入、滚动)一起打包成统一格式。
全局事件收集(补充)
除了 DOM 变化外,还会监听以下事件:
| 事件类型 | 监听方式 | 用途 |
|---|---|---|
| mousemove | addEventListener | 记录光标轨迹 |
| click | addEventListener | 记录点击位置和时间 |
| input | addEventListener | 输入框内容变化 |
| scroll | addEventListener | 页面滚动位置 |
| resize | addEventListener | 浏览器窗口大小变化 |
这些事件会被封装进同一个 event 数组中,形成完整的会话快照。
例如,一条完整的 event 数据可能如下:
{
"type": "event",
"timestamp": 1698765432100,
"data": {
"type": "click",
"target": "#submit-btn",
"clientX": 120,
"clientY": 250
}
}
这样,在回放时就可以还原用户的每一个动作。
5. 回放引擎设计逻辑
回放的核心思想是:按时间顺序重建 DOM 和用户行为。
回放步骤
- 解析原始事件流(JSON)
- 初始化一个虚拟 DOM 树(使用 jsdom 或类似库)
- 按时间戳排序事件
- 依次执行每个事件(DOM 修改 + 用户行为模拟)
示例:简单回放逻辑伪代码
function replay(events) {
const sortedEvents = events.sort((a, b) => a.timestamp - b.timestamp);
let currentTime = 0;
sortedEvents.forEach(event => {
const delay = event.timestamp - currentTime;
setTimeout(() => {
switch (event.type) {
case 'mutation':
applyMutation(event.data); // 应用 DOM 修改
break;
case 'event':
simulateUserAction(event.data); // 模拟点击、输入等
break;
}
}, delay);
currentTime = event.timestamp;
});
}
⚠️ 注意:真实的 rrweb 使用更复杂的调度机制(如 requestAnimationFrame 控制帧率),避免过快回放导致视觉混乱。
关键挑战:如何准确还原 DOM?
因为 DOM 是动态变化的,rrweb 使用了一个叫 “patch” 的机制:
- 初始状态:记录根节点快照(通常是
<html>) - 后续变化:只保留差异部分(diff patch)
- 回放时:基于初始 DOM + patch 逐步构建新 DOM
这种设计极大减少了数据体积,同时保持了语义一致性。
6. 实际应用场景与局限性对比
| 场景 | 是否适用 | 说明 |
|---|---|---|
| 用户行为分析 | ✅ 适合 | 可清晰看到用户点击路径、停留时间、误操作等 |
| 线上 Bug 定位 | ✅ 强烈推荐 | 当用户反馈某个按钮无效时,直接回放即可复现问题 |
| A/B 测试验证 | ✅ 适合 | 观察不同版本页面的实际交互效果 |
| 敏感数据泄露防护 | ❗需谨慎 | 若页面包含密码框、银行卡号等敏感字段,应脱敏后再录制 |
| 单页应用(SPA)路由切换 | ✅ 适合 | rrweb 自动识别路由变化并记录对应 DOM 更新 |
| 移动端兼容性 | ⚠️ 需测试 | iOS Safari 的 MutationObserver 行为略有不同,建议做兼容测试 |
局限性总结:
| 限制项 | 描述 |
|---|---|
| 不记录 Canvas / WebGL 渲染 | 因为它们不在 DOM 中,无法通过 MutationObserver 捕获 |
| 不记录 iframe 内部 DOM | 默认情况下不会跨域监听 iframe,除非手动注入脚本 |
| 性能开销 | 对于高频 DOM 操作(如动画、表格渲染),可能影响页面流畅度 |
| 数据体积 | 如果页面复杂且用户操作频繁,单次录制可能达到 MB 级别 |
📌 建议:合理设置采样频率(如每秒最多记录 10 条事件)、启用压缩(gzip)、分段上传。
7. 总结与建议
rrweb 是一个强大而优雅的前端录制解决方案,它的底层依赖正是 MutationObserver —— 这个看似简单的 API,却支撑起了整个会话录制体系。
✅ 优点:
- 纯前端无侵入,无需部署服务器
- 数据粒度细,精确到 DOM 层级
- 支持多平台(Web、React/Vue 等框架通用)
- 社区活跃,文档完善,易于二次开发
🛠️ 使用建议:
- 生产环境务必开启缓存机制(localStorage / IndexedDB)
- 设置合理的事件过滤规则(避免重复记录)
- 结合 Sentry、LogRocket 等工具做综合监控
- 对敏感字段做脱敏处理(如隐藏手机号、身份证号)
如果你正在构建一个需要精细化用户行为追踪的产品,或者想快速定位线上 bug,rrweb 是值得投入学习和实践的技术方案。
希望今天的分享对你有帮助!欢迎留言交流你的使用经验 😊