🎥解决前端 “复现难”:rrweb 录制回放从入门到精通

引言:为什么需要前端录制回放技术

Hello~大家好。我是秋天的一阵风

前端开发的核心痛点之一,是 "问题不可见"------ 当用户反馈 "按钮点击无响应""页面空白" 时,开发者只能靠有限日志推测场景,难以复现真实操作路径。这种信息差不仅拖慢排查效率,更可能让关键业务故障持续影响用户体验。而前端录制回放技术,正是通过精准复现 "操作 - 状态 - 结果" 全链路,为问题诊断、行为分析提供 "上帝视角",打破 "事后难追溯" 的困境。

从实际业务看,其价值在多场景中尤为突出:

  • 电商支付环节:用户反馈 "支付后订单未生成但余额已扣",传统日志无法确认是否因网络延迟导致重复点击,而录制回放可快速定位 "多次提交引发的前端状态混乱",排查时间从小时级缩至分钟级;

  • 企业 SaaS 运维:CRM 系统 "客户资料保存后字段丢失",因涉及多模块联动且仅触发于特定操作顺序,传统排查陷入僵局,录制回放直接发现 "标签页快速切换导致组件未提交数据就销毁" 的边缘场景;

  • 金融风控合规:录制转账、实名认证等关键操作的页面状态,满足监管 "操作可追溯" 要求;

  • 远程技术支持:客服无需依赖用户描述,通过录制文件直观了解故障,大幅提升支持效率。

这些场景的共性,是对 "前端行为可观测性" 的极致需求。传统监控(错误日志、性能上报)仅能提供碎片化信息,而录制回放构建了完整链路,让前端问题从 "难以复现" 变为 "精准可追溯"。rrweb 这类工具的出现,正是为中高级前端工程师提供了应对复杂场景的全新技术方案。

二、 rrweb 介绍:核心概念和特性

2.1 rrweb 的定义与核心定位

rrweb(Record and Replay the Web)是开源的前端录制回放工具,核心定位是 "轻量级、高保真、可扩展的 Web 页面录制与回放解决方案"。它并非通过视频流录制,而是捕获 DOM 变化、用户交互与页面状态,生成结构化数据,再基于数据重建操作流程 ------ 这种 "数据化录制" 模式,使其在体积、兼容性、可扩展性上远超传统方案,尤其适配 Vue3、React 等复杂项目的问题排查、行为分析与异常监控。

从架构看,rrweb 由三大核心模块构成:

  • rrweb:核心录制库,负责捕获页面变化与事件;
  • rrweb-snapshot:DOM 快照处理库,将 DOM 序列化为可传输、可重建的数据;
  • rrweb-player:回放组件库,提供录制数据的回放与交互功能。

三者既可独立使用(如用rrweb-snapshot单独处理 DOM 序列化),也可组合成完整链路,这种模块化设计是其适配现代框架生态的关键。

2.2 与传统方案的对比:为什么选择 rrweb?

rrweb 出现前,前端录制主要依赖 "截图拼接""视频录制",但两者均存在明显局限,难以满足中高级工程师对 "精准性、轻量性、可分析性" 的需求:

2.2.1 视频录制方案的缺陷

基于 Chrome 插件或客户端工具的视频录制,核心问题是 "重" 与 "不可分析":

  • 体积庞大:1 分钟操作约生成数 10MB 文件,大规模监控会激增带宽与存储成本;
  • 无法深析:仅能提供视觉证据,无法关联 DOM 结构、JS 变量状态 ------ 例如看到 "按钮点击无反应",却不知是事件未绑定、接口报错还是状态管理异常;
  • 性能影响:对设备配置要求高,低配置设备可能出现卡顿,干扰用户操作。

2.2.2 截图拼接方案的不足

定时截图拼接成 "伪回放",虽解决体积问题,但牺牲了 "连续性" 与 "高保真":

  • 间隔难平衡:短间隔体积大,长间隔易丢失关键操作;
  • 交互缺失:无法复现鼠标悬停、输入框实时输入等动态行为;
  • 技术信息断层:无 DOM 树、CSS 样式数据,若问题源于 DOM 结构异常(如嵌套错误、样式覆盖),完全无法定位根源。

2.2.3 rrweb 的核心优势

相比之下,rrweb 的 "数据化录制" 完美规避上述问题,核心优势集中在四点:

  1. 轻量:JSON 格式数据,体积仅为视频的 1/100 ~ 1/10(1 分钟操作约 100KB~1MB),无传输与存储负担;
  1. 高保真:精准复现 DOM 变化、用户交互与页面状态,甚至能还原 "网络延迟 loading""组件异步渲染顺序" 等细节;
  1. 可分析:录制数据包含完整 DOM 序列化信息与事件日志,回放时可查看任意时刻的 DOM、CSS,还能关联 Vuex/Redux 状态,实现 "现象→根源" 直接跳转;
  1. 高兼容:支持 Chrome、Firefox、Safari 10+,对框架无侵入 ------Vue3、React、jQuery 项目均可简单集成,无需修改业务代码。

2.3 rrweb 的核心特性:适配中高级前端工程师的技术需求

除对比优势外,rrweb 还提供定制化特性,精准解决复杂项目痛点:

2.3.1 隐私保护机制

支持通过配置过滤敏感信息,可设 "黑名单"(指定不录制元素)或 "白名单"(仅录特定区域),满足金融、医疗行业合规要求。例如登录页可通过ignoreElements过滤密码输入框,避免敏感数据泄露。

2.3.2 增量录制与断点续录

  • 增量录制:先生成首屏 DOM 快照,后续仅捕获增量变化(如元素新增、属性修改),大幅缩减数据量;
  • 断点续录:网络中断恢复后可继续录制,无需重启,适配表单填写等长时间操作场景。

2.3.3 框架友好性

针对 Vue3、React 做专项适配,避免虚拟 DOM 更新导致的录制偏差:

  • Vue3 项目中,能识别组件onMounted周期,捕获 DOM 最终状态,而非虚拟 DOM 未挂载的临时结构;
  • 支持配置customEvent捕获框架自定义事件(如 Vue 的emit),让录制数据贴合开发逻辑。

2.3.4 可扩展插件体系

提供丰富接口,支持开发自定义插件:

  • 性能监控插件:同步捕获 FCP、LCP 等指标;
  • 接口关联插件:将 Axios/fetch 日志与时间轴绑定,复现 "接口报错时的页面状态";
  • 错误捕获插件:自动关联window.onerror错误与录制片段,实现 "错误 - 操作" 精准匹配。

这些特性从隐私合规、框架适配到扩展能力,全面覆盖中高级工程师的实际需求,让 rrweb 不仅是 "录制工具",更是前端可观测性体系的核心组件。

三、快速上手 - 实现基础录制回放

对于中高级前端工程师而言,快速搭建可运行的技术方案是落地工具的第一步。

本章将基于实际代码,从依赖安装核心配置解析录制功能实现回放功能落地四个环节,带你完成 rrweb 基础录制回放的全流程,所有代码均可直接复用,同时深入讲解关键配置的设计逻辑,帮助你理解 "为什么这么配" 而非单纯 "怎么配"。

3.1 前置准备:依赖安装与环境配置

rrweb 的录制回放能力依赖三个核心包,但实际项目中无需单独安装 rrweb-snapshot(rrweb 已内置依赖),只需显式安装 rrweb(录制核心)和 rrweb-player(回放组件)即可,同时需引入播放器样式保证 UI 正常渲染。

3.1.1 依赖安装命令

根据项目包管理器选择对应命令,推荐使用 pnpm 或 npm(yarn 命令类似,将 pnpm install 替换为 yarn add 即可):

bash 复制代码
# 方案1:使用 pnpm 安装(推荐,速度更快且依赖树更扁平)
pnpm install rrweb rrweb-player rrweb-snapshot
# 方案2:使用 npm 安装
npm install rrweb rrweb-player rrweb-snapshot

注意:rrweb-snapshot 虽可被 rrweb 自动引入,但显式安装可避免版本兼容问题(尤其是在多人协作项目中,锁定版本能减少 "我这能跑他那报错" 的情况)。

3.1.2 模块引入

在 Vue3 组件(或 React 组件、原生 JS 文件)中引入所需模块,需特别注意 rrweb-player 的样式文件必须引入,否则回放控制器会出现布局错乱:

js 复制代码
// 1. 引入录制核心函数(从 rrweb 中导出)
import { record } from 'rrweb';
// 2. 引入回放组件(rrweb-player 默认导出类)
import rrwebPlayer from 'rrweb-player';
// 3. 引入回放组件样式(关键!否则控制器无样式)
import 'rrweb-player/dist/style.css';
// 4. Vue3 项目需引入响应式API(非Vue项目可忽略,用普通变量存储状态)
import { ref } from 'vue';

3.2 核心状态管理:定义关键变量

在实现功能前,需先定义存储 "录制状态" "录制数据" "配置项" 的变量,这些变量是连接 "录制" 与 "回放" 的核心纽带。以下以 Vue3 响应式变量为例:

js 复制代码
// 1. 存储录制的事件数组(rrweb 录制的核心数据,回放时需传入此数组)
const events = ref([]);
// 2. 存储录制配置项(用对象统一管理,便于后续扩展和修改)
const recordConfig = ref({
  maskAllInputs: true,        // 隐私保护:是否对所有输入框内容脱敏
  maskInputPassword: true,    // 隐私保护:单独控制密码输入框脱敏(优先级高于 maskAllInputs)
  blockClass: 'rrweb-block',  // 隐私保护:带此类名的元素会被完全屏蔽(不录制其内容)
  recordCanvas: false,        // 功能配置:是否录制 Canvas 内容(开启会增加数据量)
  samplingInterval: 100,      // 性能优化:滚动事件采样间隔(单位ms,0表示不采样)
});
// 3. 录制状态标识(控制按钮显示/隐藏、防止重复录制)
const isRecording = ref(false);
// 4. 是否有录制数据(控制回放按钮是否可用)
const hasRecording = ref(false);
// 5. 存储停止录制的函数(rrweb.record() 会返回停止函数,需保存以便后续调用)
let stopFn = null;
// 6. 存储回放实例(控制回放的暂停、销毁等操作)
let player = null;
// 7. 回放容器DOM引用(需绑定到页面中的回放容器元素)
const replayContainer = ref(null);

设计思路:将配置项用 recordConfig 统一管理,而非散落在代码中,既能提高可读性,也便于后续开发 "配置修改面板"(如让产品经理通过 UI 调整采样间隔)。

3.3 实现录制功能:从配置到启停

录制是 rrweb 的核心能力,核心逻辑是调用 rrweb.record(options) 生成录制实例,通过 options.emit 收集 DOM 变化事件,同时通过配置项平衡 "录制精度""数据体积""隐私安全"。

3.3.1 核心录制函数(startRecord)

js 复制代码
// 开始录制函数:整合配置、创建录制实例、收集事件
const startRecord = () => {
  // 1. 初始化状态:清空历史录制数据,避免与新数据混淆
  events.value = [];
  console.log('### 开始录制,当前配置:', recordConfig.value);
  
  // 2. 配置录制选项(rrweb 的核心配置,每一项都影响录制效果)
  const options = {
    /**
     * 关键回调:每次DOM变化/用户交互时触发
     * @param {Object} event - rrweb 生成的事件对象(包含事件类型、目标元素、时间戳等)
     * 作用:收集所有事件到 events 数组,为后续回放提供数据
     */
    emit(event) {
      events.value.push(event);
    },
    // -------------------------- 隐私保护配置 --------------------------
    /**
     * maskAllInputs:是否对所有输入框(input/textarea)内容脱敏
     * 效果:输入框内容会被替换为 "●●●",避免录制手机号、密码等敏感信息
     * 场景:适用于整站录制,无需区分输入框类型的场景
     */
    maskAllInputs: recordConfig.value.maskAllInputs,
    
    /**
     * maskInputOptions:精细化控制输入框脱敏(优先级高于 maskAllInputs)
     * 支持的键:password/email/number/tel/text 等 input 类型
     * 场景:需要"密码脱敏但普通文本不脱敏"的场景(如仅保护密码框)
     */
    maskInputOptions: {
      password: recordConfig.value.maskInputPassword,
    },
    
    /**
     * blockClass:指定"完全屏蔽"的类名
     * 效果:带此类名的元素会被录制为"黑色块",内容和交互都不记录
     * 场景:屏蔽广告、隐私弹窗、用户头像等绝对不能录制的元素
     * 使用方式:在DOM元素上添加 class="rrweb-block" 即可
     */
    blockClass: recordConfig.value.blockClass,
    // -------------------------- 功能配置 --------------------------
    /**
     * recordCanvas:是否录制 Canvas 内容
     * 原理:通过截取 Canvas 帧并转为 base64 存储(会大幅增加数据量)
     * 注意:若页面有高频刷新的 Canvas(如游戏、图表),不建议开启
     */
    recordCanvas: recordConfig.value.recordCanvas,
    
    /**
     * inlineStylesheet:是否将样式表内联到录制数据中
     * 效果:回放时无需依赖原页面的CSS文件,保证样式一致性
     * 必要性:若回放环境与录制环境样式不同(如测试服vs生产服),必须开启
     */
    inlineStylesheet: true,
    // -------------------------- 性能优化:采样配置 --------------------------
    /**
     * sampling:控制高频事件的录制频率,减少数据体积
     * 适用事件:mousemove(鼠标移动)、scroll(滚动)、mouseInteraction(鼠标交互)
     * 设计逻辑:高频事件(如滚动)无需每帧记录,间隔100ms记录一次即可满足回放需求
     */
    sampling: recordConfig.value.samplingInterval > 0 ? {
      mousemove: true,          // 鼠标移动:开启采样(默认每100ms记录一次)
      mouseInteraction: true,   // 鼠标交互(点击、悬停):开启采样
      scroll: recordConfig.value.samplingInterval, // 滚动事件:按配置间隔采样
      input: 'all'              // 输入事件:记录所有(不采样,避免丢失输入内容)
    } : {
      input: 'all' // 若采样间隔为0,仅记录输入事件(适用于对数据体积要求极高的场景)
    }
  };
  // 3. 调用 rrweb.record() 开始录制,返回"停止函数"并保存
  // 注意:每次调用 record() 都会生成新实例,需先停止旧实例再开启新录制
  stopFn = record(options);
  // 4. 更新状态:控制UI显示(如录制按钮变"停止按钮")
  isRecording.value = true;
  hasRecording.value = false;
};

3.3.2 停止录制函数(stopRecord)

停止录制的逻辑相对简单,核心是调用 startRecord 中保存的 stopFn,并更新状态标识:

js 复制代码
const stopRecord = () => {
  // 防止重复调用:若 stopFn 不存在(未开始录制),直接返回
  if (!stopFn) return;
  
  // 调用停止函数:销毁录制实例,停止收集事件
  stopFn();
  // 清空 stopFn:避免后续误调用
  stopFn = null;
  
  // 更新状态:录制状态置为false,判断是否有有效录制数据
  isRecording.value = false;
  hasRecording.value = events.value.length > 0;
  
  // 日志输出:便于调试(如录制数据量异常时,可查看事件数量)
  console.log(`### 停止录制,共收集 ${events.value.length} 个事件`);
};

调试技巧:若发现录制后 events 数组为空,可检查是否有以下问题:

  1. 页面无 DOM 变化 / 交互;

  2. maskAllInputs 等配置误屏蔽了所有元素;

  3. 组件销毁导致 emit 回调未执行。

3.4 实现回放功能:基于录制数据重建场景

回放的核心是通过 rrweb-player 类创建回放实例,将 events 数组传入,让播放器按时间戳依次执行事件,重建录制时的页面场景。

3.4.1 核心回放函数(replay)

js 复制代码
const replay = () => {
  // 防护逻辑:若无录制数据,直接返回(避免报错)
  if (events.value.length === 0) {
    console.warn('### 无录制数据,无法回放');
    return;
  }
  // 清理旧实例:若已有回放实例,先暂停并销毁(避免多实例冲突)
  if (player) {
    player.pause(); // 暂停回放
    player = null;  // 清空实例引用(触发垃圾回收)
  }
  // 创建回放实例:核心是传入录制的 events 数组和容器
  player = new rrwebPlayer({
    /**
     * target:回放容器DOM元素
     * 要求:容器必须已挂载到DOM树(Vue3中需用 ref 绑定,确保不是 null)
     * 建议:容器设置固定宽高(如 width: 100%; height: 600px),避免回放时变形
     */
    target: replayContainer.value,
    
    /**
     * props:回放配置项(控制播放行为和UI)
     */
    props: {
      events: events.value, // 核心数据:录制的事件数组(必须传入)
      width: replayContainer.value.clientWidth, // 回放宽度(与容器一致,避免拉伸)
      height: replayContainer.value.clientHeight, // 回放高度(与容器一致)
      autoPlay: true, // 自动播放:无需手动点击"播放"按钮
      showController: true, // 显示控制器:包含播放/暂停、速度调节、进度条
      speedOption: [1, 2, 4, 8], // 播放速度选项:支持1x(正常)、2x(倍速)等
      showTime: true, // 显示录制时间戳(便于定位特定时间点的问题)
    },
  });
};

3.4.2 页面 UI 绑定(Vue3 模板示例)

回放功能需要绑定 "录制 / 停止 / 回放按钮" 和 "回放容器",以下是 Vue3 模板代码,可直接整合到组件中:

js 复制代码
<template>
  <div class="rrweb-demo">
    <!-- 1. 操作按钮区域:控制录制启停和回放 -->
    <div class="control-buttons">
      <!-- 录制/停止按钮:根据 isRecording 切换文本和事件 -->
      <button @click="isRecording ? stopRecord() : startRecord()">
        {{ isRecording ? '停止录制' : '开始录制' }}
      </button>
      <!-- 回放按钮:仅当有录制数据时可用 -->
      <button @click="replay()" :disabled="!hasRecording">
        开始回放
      </button>
    </div>
    <!-- 2. 回放容器:必须设置宽高,绑定 ref 供 JS 调用 -->
    <div class="replay-container" ref="replayContainer"></div>
  </div>
</template>
<style scoped>
.rrweb-demo {
  padding: 20px;
}
.control-buttons {
  margin-bottom: 20px;
  gap: 10px;
  display: flex;
}
.control-buttons button {
  padding: 8px 16px;
  cursor: pointer;
}
/* 回放容器:固定高度,添加边框便于识别 */
.replay-container {
  width: 100%;
  height: 600px;
  border: 1px solid #e5e7eb;
  border-radius: 4px;
  overflow: hidden;
}
</style>

3.5 验证与调试:确保功能正常运行

完成代码编写后,需通过简单操作验证功能是否正常,同时掌握常见问题的调试方法:

3.5.1 功能验证结果

3.5.2 常见问题调试

  • 问题 1:回放容器空白

排查方向:

  1. 检查 replayContainer 是否绑定正确(控制台打印 replayContainer.value 确认不是 null);
  2. 检查 events 数组是否有数据(若为空,需检查 startRecordemit 回调是否执行)。
  • 问题 2:输入框内容未脱敏

排查方向:

  1. 确认 maskAllInputsmaskInputPassword 配置为 true
  2. 检查输入框是否有 rrweb-block 类(此类会完全屏蔽元素,而非脱敏)。
  • 问题 3:回放数据体积过大

优化方向:

  1. 开启 sampling 配置(如 samplingInterval: 100);
  2. 关闭 recordCanvas(若页面无必要录制的 Canvas);
  3. 通过 blockClass 屏蔽无需录制的区域(如广告栏)。

四、高级功能详解

在基础录制回放功能之上,rrweb 提供的高级特性是其适配企业级场景的核心能力 ------ 从用户隐私保护到录制性能优化,再到录制数据的全生命周期管理,这些功能直接决定了 rrweb 在实际项目中的可用性与安全性。

4.1 隐私保护机制(输入掩码、元素屏蔽)

前端录制本质是对用户操作与页面内容的 "全量捕获",但金融、医疗、电商等场景中,页面常包含身份证号、银行卡号、密码等敏感信息,若直接录制会引发隐私合规风险。 rrweb 提供的 "输入掩码" 与 "元素屏蔽" 机制,正是为了解决这一核心痛点,实现 "精准录制" 与 "隐私保护" 的平衡。

4.1.1 输入掩码:定向隐藏敏感输入内容

输入掩码功能通过配置,让 rrweb 在录制时自动将指定类型的输入内容替换为占位符(如 "*"),回放时仅显示占位符,不泄露真实数据。核心适配两类场景:"全量输入掩码"(隐藏所有输入框内容)与 "定向输入掩码"(仅隐藏密码等特定类型输入)。

代码实现与配置解析

js 复制代码
// 录制配置中的隐私保护选项(核心配置)
const recordConfig = ref({
  maskAllInputs: false,        // 全局开关:是否掩码所有输入字段
  maskInputPassword: true,     // 定向开关:仅掩码密码类型输入框
});
// 集成到 rrweb 录制选项中
const options = {
  // 其他配置...
  maskAllInputs: recordConfig.value.maskAllInputs, // 全局掩码开关
  maskInputOptions: {
    password: recordConfig.value.maskInputPassword, // 密码字段单独控制
  },
};

在 Vue 模板中,通过开关组件让用户(或开发者)动态控制掩码规则:

js 复制代码
<el-form-item label="隐私保护">
  <div class="config-group">
    <!-- 全局输入掩码开关:开启后所有输入框内容回放时显示为 * -->
    <el-switch 
      v-model="recordConfig.maskAllInputs" 
      :disabled="isRecording"  <!-- 录制中不可修改配置 -->
      active-text="掩码所有输入"
    />
    <!-- 密码定向掩码开关:仅对 type="password" 的输入框生效 -->
    <el-switch 
      v-model="recordConfig.maskInputPassword" 
      :disabled="isRecording"
      active-text="掩码密码输入"
    />
  </div>
</el-form-item>

效果说明与应用场景

  • maskInputPassword: true 时:所有 输入框的内容,在录制数据中会被替换为 "*",回放时用户看不到真实密码(如登录页、支付密码输入场景);
  • maskAllInputs: true 时:无论输入框类型(文本、手机号、银行卡号),内容都会被掩码,适用于 "页面包含多种敏感输入" 的场景(如金融 App 的实名认证页面、医疗系统的患者信息填写页)。

最佳实践建议

  1. 优先使用 "定向掩码" 而非 "全局掩码":全局掩码会隐藏所有输入内容,可能导致排查问题时无法获取关键操作(如用户输入的搜索关键词),建议仅对敏感字段单独配置;
  1. 结合业务场景预设默认值:例如电商支付页默认开启 "密码掩码",普通信息填写页默认关闭全局掩码;
  1. 录制前提示用户:若录制涉及用户操作,需在开始录制前告知 "敏感输入会被掩码处理",避免用户隐私顾虑。

4.1.2 元素屏蔽:整体隐藏敏感区域

除了输入内容,页面中可能存在整体敏感区域(如用户头像、收货地址卡片、医疗报告详情),此时需要 "元素屏蔽" 功能 ------ 通过给元素添加指定类名,让 rrweb 在录制时直接将该元素替换为灰色块,回放时完全看不到原始内容。

代码实现与配置解析

首先在配置中定义屏蔽类名:

js 复制代码
const recordConfig = ref({
  blockClass: 'rr-block',      // 屏蔽元素的类名(可自定义)
});
// 集成到 rrweb 录制选项
const options = {
  // 其他配置...
  blockClass: recordConfig.value.blockClass, // 指定屏蔽类名
};

在 Vue 模板中,给敏感元素添加该类名即可实现屏蔽:

js 复制代码
<!-- 普通内容:无屏蔽类名,正常录制 -->
<div style="padding: 16px; background: #f0f9ff;">
  <strong>普通订单信息</strong>
  <p>订单号:202405201234567</p>
  <p>商品:前端开发实战教程</p>
</div>
<!-- 敏感内容:添加 rr-block 类名,录制时被屏蔽 -->
<div class="rr-block" style="padding: 16px; background: #fef2f2; margin-top: 16px;">
  <strong>收货地址(敏感信息)</strong>
  <p>收件人:张三</p>
  <p>电话:138****5678</p>
  <p>地址:北京市朝阳区XX小区1号楼1单元101</p>
</div>

效果说明与应用场景

  • 录制时:rrweb 会检测所有带 rr-block 类名的元素,将其内容替换为 "灰色占位块",且不会捕获该元素内部的任何 DOM 变化与输入事件;
  • 回放时:用户看到的是灰色块,完全无法获取屏蔽区域的原始内容,适用于 "整段内容均敏感" 的场景(如用户个人信息卡片、企业内部数据报表、医疗诊断结果)。

最佳实践建议

  1. 屏蔽类名避免与业务类名冲突:建议使用前缀(如 rr-),防止误屏蔽正常业务元素;
  1. 屏蔽区域不宜过大:若将整个页面的大部分区域屏蔽,会导致录制数据失去排查价值,建议仅对 "必要敏感区域" 使用;
  1. 动态添加屏蔽类名:对于 Vue 组件,可通过 :class="{ 'rr-block': isSensitive }" 实现 "条件屏蔽"(如仅当用户角色为 "普通用户" 时屏蔽管理员数据)。

4.2 录制配置优化(采样率、Canvas 录制)

在复杂页面(如包含大量动画、Canvas 绘图、滚动操作的页面)中,默认录制配置可能导致 "数据量过大""性能消耗高" 等问题。rrweb 提供的 "采样率控制" 与 "Canvas 录制开关",可帮助开发者在 "录制精度" 与 "性能 / 数据量" 之间找到最佳平衡点。

4.2.1 采样率控制:减少冗余录制数据

页面中的高频事件(如鼠标移动、滚动)会产生大量冗余数据(例如 1 秒内可能触发数十次滚动事件),但多数场景下无需记录每一次事件。

通过 "采样率配置",可控制这类事件的录制频率,大幅减少数据量。

代码实现与配置解析

js 复制代码
const recordConfig = ref({
  samplingInterval: 200,       // 采样间隔(单位:ms),0表示不限制
});
// 集成到 rrweb 录制选项
const options = {
  // 其他配置...
  sampling: recordConfig.value.samplingInterval > 0 ? {
    mousemove: true,           // 开启鼠标移动事件采样
    mouseInteraction: true,    // 开启鼠标交互(点击、hover)事件采样
    scroll: recordConfig.value.samplingInterval, // 滚动事件采样间隔
    input: 'all'               // 输入事件不采样(记录所有输入,确保数据完整)
  } : {
    input: 'all'               // 采样间隔为0时,仅确保输入事件完整
  },
};

在 Vue 模板中,通过数字输入框动态调整采样间隔:

js 复制代码
<el-form-item label="采样间隔 (ms)">
  <el-input-number 
    v-model="recordConfig.samplingInterval" 
    :disabled="isRecording"
    :min="0" 
    :max="5000"  <!-- 最大间隔5秒,避免数据丢失过多 -->
    :step="100"  <!-- 步长100ms,便于精细调整 -->
  />
  <span class="config-hint">0 表示不限制,数值越大录制数据越少(推荐200-500ms)</span>
</el-form-item>

效果说明与应用场景

  • 当 samplingInterval: 200 时:滚动事件每 200ms 仅记录 1 次,1 秒内最多记录 5 次,相比不采样(可能数十次),数据量减少 70% 以上;
  • 鼠标移动事件:采样后仅记录关键移动轨迹点,而非每一个像素的移动,既保证回放时 "视觉流畅",又减少数据量;
  • 适用场景:包含长列表滚动(如电商商品列表)、复杂动画(如数据可视化图表)、高频鼠标操作(如绘图工具)的页面。

最佳实践建议

  1. 推荐采样间隔:200-500ms 是 "数据量" 与 "回放精度" 的平衡点,低于 200ms 数据量下降不明显,高于 500ms 可能导致回放时 "操作跳跃";
  1. 输入事件不采样:input: 'all' 必须保留,因为输入内容(如用户填写的表单)是排查问题的关键,采样会导致内容缺失;
  1. 动态调整采样率:可根据页面类型自动切换(如列表页采样间隔 300ms,表单页采样间隔 0ms,确保输入完整)。

4.2.2 Canvas 录制:按需捕获绘图内容

Canvas 绘图(如游戏、数据可视化图表、在线绘图工具)的录制需要特殊处理 ------ 默认情况下,rrweb 不会捕获 Canvas 内部的像素变化,回放时只能看到 Canvas 初始状态。通过 recordCanvas 配置,可开启 Canvas 录制,但需注意其对性能与数据量的影响。

代码实现与配置解析

js 复制代码
const recordConfig = ref({
  recordCanvas: true,          // 是否录制 Canvas 内容(默认关闭)
  inlineImages: false          // 是否内联 Canvas 图片(影响数据量)
});
// 集成到 rrweb 录制选项
const options = {
  // 其他配置...
  recordCanvas: recordConfig.value.recordCanvas, // Canvas 录制开关
  inlineImages: recordConfig.value.inlineImages, // 图片内联开关
};

效果说明与应用场景

  • recordCanvas: true 时:rrweb 会定期捕获 Canvas 的像素数据(以图片形式),并记录到录制事件中,回放时可完整复现绘图过程(如用户在在线白板上的绘画操作、数据图表的动态渲染过程);
  • inlineImages: true 时:Canvas 图片会以 Base64 格式内联到 JSON 录制数据中,无需额外加载图片,但会导致数据量大幅增加(1 张 100KB 的图片会让 JSON 体积增加 100KB 以上);
  • 适用场景:包含动态 Canvas 内容的页面(如在线绘图工具、游戏 Demo、实时数据可视化大屏),不适用场景:静态 Canvas 图片(如仅展示一张图表,无动态变化)。

最佳实践建议

  1. 按需开启 Canvas 录制:仅在需要复现 Canvas 动态变化时开启,静态 Canvas 无需录制;
  1. 谨慎使用图片内联:若 Canvas 变化频繁(如每秒多次刷新),建议关闭 inlineImages,并将图片上传到服务器,录制数据中仅保留图片 URL(需自定义数据处理逻辑),减少 JSON 体积;
  1. 控制 Canvas 捕获频率:通过 rrweb 的 canvasCaptureInterval 配置(需额外扩展)调整捕获间隔(如 1 秒 1 次),避免高频捕获导致前端卡顿。

4.3 数据管理(下载 / 上传、大小计算)

录制数据的 "存储、传输、复用" 是 rrweb 落地的关键环节 ------ 开发者需要将录制数据下载到本地用于调试,或上传到服务器用于团队共享、异常监控分析。本节将详解录制数据的大小计算、下载导出与上传导入功能。

4.3.1 数据大小计算:实时掌握数据体积

录制数据的大小直接影响下载速度、存储成本与传输效率,通过实时计算数据大小,可帮助开发者判断是否需要优化录制配置(如调整采样率、关闭 Canvas 录制)。

代码实现与逻辑解析

js 复制代码
// 计算录制数据大小(基于响应式事件数组)
const dataSize = computed(() => {
  // 将事件数组序列化为 JSON 字符串,创建 Blob 对象获取大小
  const size = new Blob([JSON.stringify(events.value)]).size;
  // 单位转换:B → KB → MB
  if (size < 1024) return `${size} B`;
  if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`;
  return `${(size / 1024 / 1024).toFixed(2)} MB`;
});

效果说明与应用场景

  • 实时更新:当 events.value(录制事件数组)变化时,dataSize 会自动重新计算,开发者可在界面上实时看到 "当前录制数据大小";
  • 数据量预警:若数据大小超过阈值(如 10MB),可触发提示(如 "数据量过大,建议调整采样率"),避免生成超大文件;
  • 适用场景:所有录制场景,尤其适合长时间录制(如用户操作 10 分钟以上)或复杂页面录制。

最佳实践建议

  1. 显示数据大小位置:将 dataSize 显示在 "录制状态" 旁边(如 "录制中 | 数据大小:2.34 KB"),让开发者实时感知;
  1. 设置数据量阈值预警:通过 watch 监听 dataSize,当超过阈值(如 5MB)时,用 ElMessage 提示 "数据量较大,可能影响下载与回放速度,建议优化配置";
  1. 结合采样率联动:若数据量增长过快,可自动建议提高采样间隔(如 "当前数据量增长较快,是否将采样间隔从 200ms 调整为 300ms?")。

4.3.2 下载录制数据:本地存储与调试

将录制数据下载为 JSON 文件,可用于本地调试(如复现用户反馈的问题)、离线分析(如无网络时查看录制内容)或归档备份(如保存关键异常场景的录制数据)。

代码实现与逻辑解析

js 复制代码
// 下载录制数据为 JSON 文件
const downloadRecording = () => {
  // 校验数据是否存在
  if (events.value.length === 0) {
    ElMessage.warning('没有录制数据可下载');
    return;
  }
  
  // 1. 序列化事件数组(缩进2空格,便于阅读)
  const data = JSON.stringify(events.value, null, 2);
  // 2. 创建 Blob 对象(指定 JSON 类型)
  const blob = new Blob([data], { type: 'application/json' });
  // 3. 生成临时 URL
  const url = URL.createObjectURL(blob);
  
  // 4. 创建隐藏的 a 标签实现下载
  const a = document.createElement('a');
  a.href = url;
  // 文件名包含时间戳,避免重复(如 rrweb-recording-1716234567890.json)
  a.download = `rrweb-recording-${Date.now()}.json`;
  document.body.appendChild(a);
  a.click(); // 触发下载
  
  // 5. 清理临时资源(避免内存泄漏)
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
  
  console.log('### 下载录制数据,大小:', dataSize.value);
};

效果说明与应用场景

  • 下载文件:点击 "下载" 按钮后,浏览器会自动下载一个 JSON 文件,文件内容为结构化的录制事件(包含 DOM 快照、用户交互、时间戳等信息);
  • 本地回放:将下载的 JSON 文件重新上传到 rrweb 播放器,可在本地复现录制场景;
  • 适用场景:开发者本地调试(复现用户反馈的 bug)、异常场景归档(保存生产环境出现的偶发问题)、跨团队共享(将录制文件发送给其他开发者协助排查)。

最佳实践建议

  1. 文件名规范:包含 "rrweb-recording" 前缀 + 时间戳,便于识别文件用途与创建时间;若涉及业务场景,可额外添加场景标识(如 "rrweb-recording-20240520-order-fail-1716234567890.json"),快速定位文件对应的问题场景;
  1. 下载前校验:除了校验数据长度,还可校验数据格式(如是否包含首屏 DOM 快照事件FullSnapshot),避免因数据损坏导致下载的文件无法回放;可添加如下校验逻辑:
js 复制代码
const hasValidSnapshot = events.value.some(event => event.type === 2); // FullSnapshot 事件类型为2
if (!hasValidSnapshot) {
  ElMessage.error('录制数据无效,缺少首屏快照,无法回放');
  return;
}
  1. 大文件处理:若录制数据超过 10MB,可在下载前提示用户 "文件体积较大,下载可能需要较长时间",并提供 "取消下载" 选项,避免占用过多带宽;
  1. 下载状态反馈:添加下载进度提示(如 "正在生成下载文件...""下载完成"),让用户清晰感知操作进度,提升体验。

4.3.3 上传录制数据:复用与共享

上传录制数据功能可将本地保存的 JSON 文件导入到 rrweb 播放器中,实现 "跨设备回放""团队共享调试""异常数据上报" 等场景,是录制数据 "复用" 的核心环节。

代码实现与逻辑解析

上传功能分为 "触发文件选择""读取文件内容""解析数据并导入" 三个核心步骤,完整代码如下:

js 复制代码
// 1. 触发文件选择(隐藏原生input,通过按钮触发)
const triggerUpload = () => {
  // 获取隐藏的文件输入框并触发点击
  document.getElementById('file-upload').click();
};
// 2. 处理文件上传逻辑
const uploadRecording = (event) => {
  // 获取用户选择的第一个文件(仅支持JSON格式)
  const file = event.target.files[0];
  if (!file) return;
  // 校验文件格式:仅允许JSON文件
  if (file.type !== 'application/json' && !file.name.endsWith('.json')) {
    ElMessage.error('请上传JSON格式的录制文件');
    event.target.value = ''; // 重置输入框,避免重复触发
    return;
  }
  // 3. 读取文件内容(使用FileReader)
  const reader = new FileReader();
  // 读取成功回调
  reader.onload = (e) => {
    try {
      // 解析JSON内容
      const data = JSON.parse(e.target.result);
      // 4. 校验录制数据格式
      // 要求:数据为数组,且至少包含一个事件(推荐包含首屏快照)
      if (!Array.isArray(data) || data.length === 0) {
        throw new Error('录制数据为空或格式错误(需为事件数组)');
      }
      // 可选校验:是否包含首屏快照,确保能正常回放
      const hasFullSnapshot = data.some(item => item.type === 2);
      if (!hasFullSnapshot) {
        ElMessage.warning('录制数据缺少首屏快照,可能无法正常回放');
      }
      // 5. 导入数据并更新状态
      events.value = data; // 将上传的事件数组赋值给响应式变量
      hasRecording.value = true; // 标记存在可回放数据
      ElMessage.success(`上传成功!共导入 ${data.length} 个录制事件`);
      console.log(`### 上传录制数据成功,大小:`, new Blob([e.target.result]).size / 1024.toFixed(2) + ' KB');
    } catch (error) {
      // 捕获解析错误(如JSON格式错误、数据校验失败)
      console.error('### 上传失败:', error);
      ElMessage.error(`上传失败:${error.message}`);
    }
  };
  // 读取失败回调
  reader.onerror = () => {
    ElMessage.error('文件读取失败,请检查文件是否损坏');
  };
  // 以文本形式读取文件(JSON文件本质是文本)
  reader.readAsText(file);
  event.target.value = ''; // 重置输入框,允许重复上传同一文件
};

在 Vue 模板中,需要添加隐藏的文件输入框与触发按钮:

js 复制代码
<!-- 隐藏的文件输入框:仅用于接收文件选择,不直接显示 -->
<input
  id="file-upload"
  type="file"
  accept=".json" <!-- 仅允许选择JSON文件 -->
  style="display: none;"
  @change="uploadRecording" <!-- 文件选择变化时触发上传逻辑 -->
/>
<!-- 上传按钮:用户可见,点击触发文件选择 -->
<el-button 
  type="primary" 
  icon="el-icon-upload" 
  @click="triggerUpload"
  :disabled="isRecording"
>
  上传录制文件
</el-button>

效果说明与应用场景

  • 上传流程:用户点击 "上传录制文件" 按钮,选择本地 JSON 格式的录制文件后,系统会自动校验文件格式、解析数据,若校验通过则更新录制事件数组,用户可直接点击 "回放" 按钮查看上传的录制内容;
  • 核心场景:
  1. 跨设备调试:开发者在本地录制用户反馈的 bug 场景,将 JSON 文件上传到测试环境或其他设备,复现问题进行调试;
  1. 团队共享:将关键异常场景的录制文件上传到团队共享平台(如 Git、云存储),其他开发者下载后可快速复现问题,无需重复搭建场景;
  1. 异常数据上报:在生产环境中,当前端发生严重异常(如白屏、崩溃)时,自动将录制数据上传到后端监控系统,运维人员可通过上传功能导入数据,分析异常原因;
  1. 回归测试:将历史 bug 的录制文件保存,每次版本迭代后上传回放,验证 bug 是否复现,确保问题彻底修复。

最佳实践建议

  1. 严格文件校验:除了格式校验(JSON、.json 后缀),还需校验数据结构(是否为数组、是否包含关键事件类型),避免无效数据导致播放器崩溃;
  1. 大文件上传优化:若上传文件超过 5MB,建议添加 "分片上传" 逻辑(需后端配合),避免一次性读取大文件导致前端内存溢出;可使用slice方法将文件分片:
js 复制代码
// 简单分片示例(需后端支持分片接收)
const chunkSize = 1024 * 1024; // 1MB每片
const totalChunks = Math.ceil(file.size / chunkSize);
for (let i = 0; i < totalChunks; i++) {
  const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
  // 上传分片...
}
  1. 上传状态反馈:添加 "上传中" loading 状态、上传进度条(如 "已上传 30%"),以及失败重试按钮,提升用户体验;
  1. 数据安全:若上传的录制数据包含敏感信息(即使已做掩码处理),建议通过 HTTPS 传输,并在后端添加权限控制(如仅团队成员可访问),避免数据泄露。

4.4 高级功能联动实践

在实际项目中,rrweb 的高级功能并非孤立使用,而是需要结合业务场景联动配置,才能最大化发挥价值。以下以 "电商支付页面录制" 为例,展示隐私保护、录制优化、数据管理的联动方案:

4.4.1 场景需求

  • 需录制用户支付操作(如选择支付方式、输入验证码),用于排查 "支付失败""订单状态异常" 等问题;
  • 需隐藏敏感信息(银行卡号、支付密码、手机验证码);
  • 需控制数据量(避免录制过长导致文件过大);
  • 需支持录制数据上传到后端监控系统。

4.4.2 联动配置方案

  1. 隐私保护配置
js 复制代码
const recordConfig = ref({
  maskAllInputs: false, // 不全局掩码(需保留支付方式选择等非敏感输入)
  maskInputPassword: true, // 掩码支付密码输入框
  blockClass: 'rr-block-sensitive', // 自定义屏蔽类名
});
// 模板中给敏感区域添加屏蔽类
<div class="rr-block-sensitive">
  <el-input v-model="bankCard" placeholder="银行卡号" />
  <el-input v-model="verifyCode" placeholder="手机验证码" />
</div>
  1. 录制优化配置
js 复制代码
recordConfig.value.samplingInterval = 300; // 滚动采样间隔300ms(支付页滚动操作少,降低数据量)
recordConfig.value.recordCanvas = false; // 支付页无Canvas,关闭Canvas录制
  1. 数据管理联动
  • 录制自动停止:设置录制时长上限(如 5 分钟),避免用户长时间停留导致数据过大;
  • 自动上传异常数据:若检测到支付失败(如接口返回错误),自动触发downloadRecording生成文件,并调用后端接口上传:
js 复制代码
// 支付失败时自动上传
const handlePayFail = async (error) => {
  // 先停止录制
  stopRecord();
  // 生成录制文件Blob
  const data = JSON.stringify(events.value);
  const blob = new Blob([data], { type: 'application/json' });
  // 构造FormData上传到后端
  const formData = new FormData();
  formData.append('recordingFile', blob, `pay-fail-${Date.now()}.json`);
  formData.append('orderId', orderId.value); // 关联订单ID,便于排查
  formData.append('errorMsg', error.message); // 关联错误信息
  // 调用后端接口
  try {
    await axios.post('/api/upload-recording', formData);
    ElMessage.success('异常录制数据已自动上传,工作人员将尽快处理');
  } catch (err) {
    ElMessage.error('录制数据上传失败,可手动下载后反馈');
  }
};

4.4.3 实践效果

  • 隐私保护:银行卡号、验证码等敏感信息被屏蔽,支付密码显示为 "*",符合合规要求;
  • 数据量控制:5 分钟录制数据约 2-3MB,无明显性能消耗,上传速度快;
  • 问题排查效率:后端收到上传的录制文件后,运维人员可快速导入回放,复现支付失败场景,定位问题根源(如用户输入验证码超时、支付方式切换时的前端状态异常)。

五、实现原理深度剖析

rrweb 的核心魅力在于 "用极小的数据量复现完整的页面操作",这背后是 DOM 快照、增量更新、事件序列化等技术的精密协作。本章将从原理本质出发,用通俗类比 + 源码解析的方式,拆解每个技术模块的实现逻辑,同时揭示 rrweb 在兼容性、性能优化上的关键设计。

5.1 DOM 快照生成原理(snapshot)

通俗类比:DOM 快照就像给页面拍 "全景工程图"------ 不是简单记录像素(如截图),而是把页面的 "骨架"(DOM 层级结构)、"皮肤"(CSS 样式)、"零件参数"(元素属性、文本内容)都转化为结构化数据,后续能根据这张 "工程图" 1:1 还原出与原页面一致的初始状态。区别于普通照片,这张 "工程图" 包含所有可编辑的 "零件信息",而非固定的视觉图像。

5.1.1 核心目标与技术路径

DOM 快照的核心目标是生成 "可序列化、可重建、体积小" 的 DOM 数据,技术路径分为三步:DOM 遍历与节点过滤节点属性与样式序列化资源引用处理,最终输出 JSON 格式的FullSnapshot事件(rrweb 事件类型标识为 2)。

5.1.2 关键实现逻辑(参考 rrweb-snapshot 源码)

rrweb-snapshot 库的takeSnapshot函数是快照生成的核心,关键步骤如下:

  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. 节点序列化(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. 样式收集与内联

页面样式可能来自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;
}

5.1.3 技术难点与解决方案

  • 难点 1:样式体积过大与浏览器兼容性

    直接收集所有计算样式会导致数据量激增(单个元素可能有上百个样式属性),且不同浏览器默认样式存在差异(如 Chrome 与 Safari 的body默认margin不同)。

    解决方案:① 仅收集 "影响视觉的关键样式"(如display、position),过滤无关样式(如webkit-font-smoothing);② 维护 "标签默认样式表",仅记录与默认值不同的样式,数据量可减少 60% 以上;③ 对浏览器私有前缀样式(如-webkit-border-radius)进行兼容处理,统一转化为标准样式。

  • 难点 2:跨域资源无法加载

    页面中的跨域图片(如 CDN 图片)、字体等资源,回放时可能因 CORS 限制无法加载,导致样式错乱。

    解决方案

  • ① 配置inlineImages: true时,将图片转化为 Base64 编码内联到快照中(适合小图片);

  • ② 对大图片,回放时通过 "同源代理服务" 转发请求(如后端部署代理接口,将跨域图片 URL 转为同源 URL);

  • ③ 记录资源加载失败时的降级样式(如图片占位符),确保回放体验一致。

5.1.4 DOM 快照生成流程图

5.2 增量更新机制(MutationObserver)

通俗类比 :如果说 DOM 快照是 "初始工程图",增量更新就是 "工程变更记录"------ 就像建筑施工时,不需要每次都重新绘制完整图纸,只需记录 "在 3 层增加 1 个窗户" "修改 2 层墙体颜色" 这类变化。

rrweb 通过监听 DOM 变化,只记录快照后的增量修改,大幅减少录制数据量。

5.2.1 核心技术:MutationObserver API

rrweb 的增量更新完全依赖浏览器原生的MutationObserver API,该 API 可监听 DOM 树的 "节点变化、属性变化、文本变化" ,并异步批量触发回调(避免阻塞主线程)。

其核心优势:

  • ① 精准监听(可指定监听类型);
  • ② 批量处理(短时间内多次变化合并为一次回调);
  • ③ 性能友好(异步执行,不阻塞用户交互)。

5.2.2 监听配置与事件转化

rrweb 初始化时创建 MutationObserver实例,将原生变化事件转化为自定义的Mutation事件(rrweb 事件类型标识为 3),关键逻辑如下:

  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;
}
  1. 原生变化事件转化(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})`;
}

5.2.3 技术难点与解决方案

  • 难点 1:节点路径定位不准确

    增量变化需要明确 "哪个节点发生了变化",但 DOM 节点没有天生的唯一标识,动态生成的节点(如 Vue 列表渲染的li)在刷新后路径可能变化,导致回放时无法定位目标节点。

    解决方案

    • ① 生成 "唯一路径"(结合标签名、ID、类名、兄弟节点索引),如body>div.container>ul>li:nth-child(2),确保即使节点动态更新,仍能通过路径找到;
    • ② 维护 "节点 ID 映射表",录制时为每个节点分配临时 ID(如node.__rrwebId),回放时通过 ID 快速定位,路径作为降级方案(应对 ID 丢失场景)。
  • 难点 2:高频变化导致性能卡顿

    页面中的高频 DOM 变化(如倒计时、动画、滚动加载列表)会触发MutationObserver频繁回调(如每秒几十次),导致前端主线程阻塞,影响用户交互体验。

    解决方案

    • ① 结合 "采样率控制"(见第 4 章),对高频变化事件(如文本倒计时)进行节流处理(如 100ms 内仅记录 1 次变化);
    • ② 批量合并短时间内的同类变化(如 100ms 内的多次文本修改合并为 1 次);
    • ③ 过滤 "无意义变化"(如元素scrollTop的微小波动、空文本修改),减少事件数量。

5.2.4 增量更新流程示意图

5.3 事件捕获和序列化

通俗类比:如果说 DOM 快照是 "初始场景",增量更新是 "场景变化",那么事件捕获就是 "用户动作剧本"------ 就像电影拍摄时,不仅要搭建场景,还要记录演员的 "肢体动作""台词""表情"。

rrweb 需要捕获用户的点击、输入、滚动等交互动作,将其转化为结构化的 "剧本数据",确保回放时能精准还原用户操作轨迹。

5.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

5.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;
}

5.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) // 是否为功能键
  };
}
  1. 滚动事件(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 // 是否为页面滚动
  };
}

5.3.4 技术难点与解决方案

  • 难点 1:事件顺序错乱

不同事件的触发存在时间差(如 "点击按钮→输入文本→提交表单"),若录制时事件顺序错误,回放会出现逻辑混乱(如未输入就提交)。

解决方案

  • ① 所有事件携带精确时间戳(Date.now()),回放时按时间戳升序执行;

  • ② 对存在依赖关系的事件(如 "click" 后触发的 "keydown"),记录事件间的关联 ID(如parentEventId),确保回放时顺序一致。

  • 难点 2:敏感输入泄露

若录制时未处理密码、银行卡号等敏感输入,会导致用户隐私泄露,违反合规要求(如 GDPR、个人信息保护法)。

解决方案

  • ① 基于配置自动掩码(maskInputPassword: true),密码输入框内容统一替换为 "*";
  • ② 提供自定义过滤函数(filterInputValue: (value, target) => {}),支持业务方自定义敏感数据处理逻辑(如银行卡号只保留后 4 位);
  • ③ 禁止录制input[type="password"]的value属性,仅记录输入动作不记录内容。

5.3.5 事件捕获流程示意图

5.4 回放还原技术

通俗类比:回放就像 "按剧本复现舞台剧"------DOM 快照是 "初始舞台布置",增量事件是 "舞台道具变化指令",用户交互事件是 "演员动作指令",rrweb-player 则是 "导演",按时间顺序执行所有指令,最终还原完整场景。

5.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;
  }
}
  1. 事件调度(按时间顺序执行)

采用 "定时器 + 事件队列" 模式,模拟真实时间流逝,按事件 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;
      // 其他事件类型执行逻辑...
    }
  }
}
  1. 增量事件执行(以 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;
    }
  }
}

5.4.2 技术难点与解决方案

  • 难点 1:事件执行顺序偏差

录制时事件按真实时间戳存储,但回放时若定时器精度不足(如 setInterval 存在延迟),可能导致 "先执行点击、后执行 DOM 更新" 的顺序错误,引发场景混乱。

解决方案:① 事件队列按 timestamp 严格排序,执行时通过 "当前播放时间 = 首事件时间 +(当前时间 - 回放开始时间)× 倍速" 精准计算应执行的事件;② 对依赖 DOM 状态的事件(如 click),增加 "DOM 就绪检查",确保增量更新执行完成后再触发交互事件。

  • 难点 2:回放时节点定位失败

若录制时 DOM 结构动态变化(如 Vue 列表重新渲染),回放时可能出现 "路径找不到节点" 的问题。

解决方案

  • ① 采用 "节点 ID 优先、路径降级" 的定位策略,录制时为每个节点分配__rrwebId,回放时优先通过 ID 定位;
  • ② 路径定位时支持 "模糊匹配"(如忽略动态索引,通过类名 + 标签名组合定位);
  • ③ 定位失败时触发 "降级处理"(如跳过该事件,避免整个回放崩溃)。

总结:

本文围绕前端故障 "难复现" 的痛点展开,从介绍解决工具 rrweb 入手,既讲了它相比传统录制方式的优势,也给了基础操作步骤和企业级实用技巧,还拆解了底层原理。希望这些内容能帮中高级前端工程师们,在处理项目故障时少走弯路,用 rrweb 高效解决排查难题,让前端问题复现和定位变得更简单。

相关推荐
AI前端老薛7 分钟前
CSS实现动画的几种方式
前端·css
晨米酱9 分钟前
轻量级 Git Hooks 管理工具 Husky
前端·代码规范
携欢11 分钟前
portswigger靶场之修改序列化数据类型通关秘籍
android·前端·网络·安全
前端小L12 分钟前
专题二:核心机制 —— reactive 与 effect
javascript·源码·vue3
GuMoYu12 分钟前
npm link 测试本地依赖完整指南
前端·npm
代码老祖13 分钟前
vue3 vue-pdf-embed实现pdf自定义分页+关键词高亮
前端·javascript
未等与你踏清风13 分钟前
Elpis npm 包抽离总结
前端·javascript
代码猎人14 分钟前
如何使用for...of遍历对象
前端
秋天的一阵风15 分钟前
🎥解决前端 “复现难”:rrweb 录制回放从入门到精通(下)
前端·开源·全栈
林恒smileZAZ15 分钟前
【Vue3】我用 Vue 封装了个 ECharts Hooks
前端·vue.js·echarts