rrweb 原理:基于 DOM 变动(MutationObserver)的会话录制与回放

rrweb 原理详解:基于 DOM 变动的会话录制与回放技术解析

各位开发者朋友,大家好!今天我们来深入探讨一个在前端监控领域非常热门的技术——rrweb(Record Replayer Web)。它是一个开源项目,能够对用户在网页上的操作行为进行完整记录,并支持后续的回放。这项能力对于调试线上问题、分析用户行为、提升用户体验至关重要。

这篇文章将从原理出发,带你一步步理解 rrweb 是如何通过 MutationObserver 实现 DOM 级别的变化捕捉,并最终构建出可复用的会话录像功能。文章结构如下:

  1. 什么是 rrweb?
  2. 核心原理:MutationObserver 的作用
  3. 数据采集流程详解(含代码)
  4. 数据存储与传输机制
  5. 回放引擎设计逻辑
  6. 实际应用场景与局限性对比
  7. 总结与建议

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 和用户行为

回放步骤

  1. 解析原始事件流(JSON)
  2. 初始化一个虚拟 DOM 树(使用 jsdom 或类似库)
  3. 按时间戳排序事件
  4. 依次执行每个事件(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 是值得投入学习和实践的技术方案

希望今天的分享对你有帮助!欢迎留言交流你的使用经验 😊

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注