1. 为什么我们需要像“录像机”一样记录用户操作想象一下你是一个在线购物网站的产品经理。最近你发现商品详情页的“加入购物车”按钮点击率异常低但后台数据显示这个页面流量并不少。问题出在哪里是按钮颜色不显眼是页面加载太慢卡住了用户还是用户根本找不到这个按钮传统的埋点数据只能告诉你“点击率低”这个结果却无法告诉你“为什么”。这就是rrweb这类前端录制技术大显身手的地方。它就像给你的网站装上了一台高清录像机能够完整、精准地记录下用户在页面上的每一次点击、滚动、输入甚至是鼠标移动的轨迹。你不再需要凭空猜测用户的行为而是可以直接“坐上时光机”以第一人称视角回放用户的操作过程亲眼看到他们是如何与你的产品交互的。我在多个项目中引入 rrweb 后最深刻的体会是它把“黑盒”变成了“白盒”。过去我们排查一个偶现的 Bug需要反复询问用户“你当时点了哪里”用户往往也记不清沟通成本极高。现在我们只需要拿到用户操作时段的录制数据就能像看电影一样一帧一帧地复现问题现场。有一次我们通过回放发现一个表单提交失败是因为用户在某个第三方富文本编辑器里进行特殊粘贴操作时触发了一个极其隐蔽的脚本错误这种问题靠传统日志几乎不可能定位。除了排查问题rrweb 录制的数据更是产品优化的金矿。你可以看到用户在一个复杂的多步骤表单中在哪一步犹豫了最久是不是说明这里的文案不够清晰你可以看到用户反复点击一个没有链接的图片是不是说明这里的设计有误导性这些基于真实用户行为的洞察远比任何调研问卷都来得直接和可靠。简单来说rrweb 让你从“数据驱动”进化到“行为驱动”真正理解用户而不仅仅是了解数据。2. 5分钟快速上手在你的Vue/React项目中接入rrweb理论说得再多不如亲手试试。别被“录制”这个词吓到其实 rrweb 的接入比想象中简单得多。下面我就以最流行的 Vue 3 Vite 技术栈为例带你一步步跑通整个流程。React 或其他框架的开发者也别走开核心逻辑是完全相通的只是组件写法略有不同。2.1 第一步安装与项目准备首先在你的项目根目录下打开终端安装两个核心包。rrweb 是录制引擎rrweb-player 是官方提供的播放器组件用于回放。# 使用 npm npm install rrweb rrweb-player --save # 或者使用你喜欢的包管理器比如 pnpm 或 yarn pnpm add rrweb rrweb-player安装完成后我们需要一个地方来存放录制下来的数据。在真实的项目中这些数据最终要发送到你的后端服务器进行存储和分析。但为了演示我们先在前端用一个状态管理工具比如 Pinia来临时存储。在你的 store 目录下创建一个专门的文件来管理录制事件。2.2 第二步创建录制事件存储器我习惯在src/store目录下新建一个rrwebStore.ts文件。这里我们用 Pinia 来管理状态。// src/store/rrwebStore.ts import { defineStore } from pinia; import type { eventWithTime } from rrweb/types; // 定义存储的事件数组类型 interface RrwebState { events: eventWithTime[]; } export const useRrwebStore defineStore(rrweb, { state: (): RrwebState ({ events: [], // 初始化为空数组 }), actions: { // 设置事件数组通常用于加载已录制数据 setEvents(events: eventWithTime[]) { this.events events; }, // 追加新事件在录制过程中实时添加 addEvent(event: eventWithTime) { this.events.push(event); }, // 清空事件开始新的录制时 clearEvents() { this.events []; }, // 获取当前所有事件 getEvents() { return this.events; }, }, });记得在你的主入口文件如main.ts中安装并使用这个 store。这样我们就在前端准备好了一个“内存仓库”可以暂存用户的操作录像了。2.3 第三步构建录制页面与核心逻辑接下来我们创建一个录制页面。这个页面会包含一个表单或其他交互元素方便我们测试录制效果。核心在于两个按钮“开始录制”和“结束录制”。!-- src/views/RrwebRecorder.vue -- template div classrecorder-container h2用户行为录制测试/h2 div classcontrol-buttons button clickstartRecording :disabledisRecording开始录制/button button clickstopRecording :disabled!isRecording结束录制/button button clickclearRecording清空记录/button /div !-- 一个用于测试的模拟表单 -- div classtest-form h3请随意填写以下表单这将被录制/h3 input v-modelformData.username placeholder请输入用户名 / select v-modelformData.city option value选择城市/option option valueshanghai上海/option option valuebeijing北京/option /select textarea v-modelformData.feedback placeholder请输入反馈意见/textarea label input typecheckbox v-modelformData.agree / 我同意协议 /label button clicksimulateSubmit模拟提交/button /div p v-ifisRecording classrecording-status● 正在录制中.../p p已录制事件数量{{ eventCount }} 个/p /div /template script setup langts import { ref, reactive, computed, onUnmounted } from vue; import * as rrweb from rrweb; import { useRrwebStore } from /store/rrwebStore; import type { eventWithTime } from rrweb/types; const rrwebStore useRrwebStore(); const isRecording ref(false); let stopRecordFn: (() void) | null null; // 测试用的表单数据 const formData reactive({ username: , city: , feedback: , agree: false, }); // 计算属性显示当前存储的事件数量 const eventCount computed(() rrwebStore.events.length); const startRecording () { // 开始录制前先清空之前的记录确保每次录制都是独立的 rrwebStore.clearEvents(); console.log(开始录制用户行为...); stopRecordFn rrweb.record({ // 每当有事件触发这个 emit 回调就会被调用 emit(event: eventWithTime) { // 将事件实时存入我们的 store rrwebStore.addEvent(event); // 你也可以在这里实时打印或发送到后端 // console.log(捕获事件:, event.type, event.timestamp); }, // 重要配置是否录制 Canvas 内容如图表、游戏 recordCanvas: true, // 采样率配置可以平衡性能与精度 sampling: { mousemove: 150, // 每150ms采样一次鼠标移动避免数据量过大 scroll: 300, // 每300ms采样一次滚动 }, // 录制 DOM 变化的深度-1 表示全量录制 blockSelector: [data-no-record], // 给不想被录制的元素加上这个属性 }); isRecording.value true; }; const stopRecording () { if (stopRecordFn) { stopRecordFn(); stopRecordFn null; isRecording.value false; console.log(录制已停止共录制事件:, rrwebStore.events.length); // 在实际项目中这里通常会调用一个函数将 rrwebStore.events 发送到后端服务器 // uploadEventsToServer(rrwebStore.events); } }; const clearRecording () { rrwebStore.clearEvents(); console.log(已清空录制记录); }; const simulateSubmit () { alert(表单已提交模拟:\n用户名: ${formData.username}\n反馈: ${formData.feedback}); }; // 组件卸载时如果还在录制自动停止 onUnmounted(() { if (isRecording.value stopRecordFn) { stopRecording(); } }); /script style scoped .recorder-container { padding: 20px; } .control-buttons button { margin-right: 10px; padding: 8px 16px; } .recording-status { color: red; font-weight: bold; } .test-form { margin-top: 20px; border: 1px dashed #ccc; padding: 15px; } .test-form input, .test-form select, .test-form textarea { display: block; margin-bottom: 10px; width: 300px; padding: 5px; } /style这段代码就是录制的核心。rrweb.record()方法启动录制引擎它接受一个配置对象其中emit回调函数是关键每次用户操作产生的事件都会流经这里。我们把事件推送到 Pinia Store 中暂存。recordCanvas: true这个配置非常实用如果你的页面有 ECharts 图表、游戏或任何 Canvas 绘制的内容开启它才能正确录制。sampling配置则是性能优化的关键避免高频的鼠标移动事件产生海量数据。2.4 第四步实现回放功能让操作“重播”数据录好了怎么看呢我们需要一个播放器页面。rrweb-player 这个包帮我们做好了大部分工作。!-- src/views/RrwebPlayer.vue -- template div classplayer-container h2用户行为回放/h2 div v-ifhasEvents div classplayer-controls button clickstartReplay开始回放/button button clickpauseReplay :disabled!replayInstance暂停/button button clickresetReplay重置/button span速度/span select v-modelplaybackSpeed changechangeSpeed option value0.50.5x/option option value11x (正常)/option option value22x/option option value44x/option /select /div div idreplay-container/div p classhint回放区域如上。你可以看到用户所有的操作包括鼠标移动轨迹如果录制时开启了。/p /div div v-else classno-data p暂无录制数据可供回放。/p p请先前往router-link to/record录制页面/router-link生成一些数据。/p /div /div /template script setup langts import { ref, computed, onMounted, onUnmounted } from vue; import rrwebPlayer from rrweb-player; import rrweb-player/dist/style.css; // 引入播放器样式 import { useRrwebStore } from /store/rrwebStore; const rrwebStore useRrwebStore(); const replayInstance refrrwebPlayer | null(null); const playbackSpeed ref(1); // 计算属性判断是否有数据 const hasEvents computed(() rrwebStore.events.length 0); const startReplay () { // 如果已有实例先销毁 if (replayInstance.value) { replayInstance.value.$destroy(); } const events rrwebStore.getEvents(); if (events.length 0) return; const container document.getElementById(replay-container); if (!container) return; // 清空容器 container.innerHTML ; // 创建新的播放器实例 replayInstance.value new rrwebPlayer({ target: container, props: { events, width: container.clientWidth, height: 600, showController: true, // 显示控制条播放/暂停/进度条 autoPlay: true, // 自动开始播放 speed: parseFloat(playbackSpeed.value), // 播放速度 mouseTail: true, // 显示鼠标轨迹 // 更多配置项可以参考官方文档 }, }); }; const pauseReplay () { if (replayInstance.value) { // 播放器实例提供了 pause 和 play 方法 const player (replayInstance.value as any).getReplayer(); if (player) { player.pause(); } } }; const resetReplay () { if (replayInstance.value) { replayInstance.value.$destroy(); replayInstance.value null; const container document.getElementById(replay-container); if (container) container.innerHTML ; } }; const changeSpeed () { if (replayInstance.value) { const player (replayInstance.value as any).getReplayer(); if (player) { player.setConfig({ speed: parseFloat(playbackSpeed.value) }); } } }; // 组件卸载时清理播放器实例防止内存泄漏 onUnmounted(() { resetReplay(); }); /script style scoped .player-container { padding: 20px; } #replay-container { border: 1px solid #333; margin-top: 15px; background: #f9f9f9; } .player-controls { margin-bottom: 15px; } .player-controls button, .player-controls select { margin-right: 10px; padding: 5px 10px; } .no-data { text-align: center; padding: 40px; color: #888; } .hint { font-size: 0.9em; color: #666; margin-top: 10px; } /style现在你可以运行项目在录制页面操作一番然后切换到回放页面点击“开始回放”就能神奇地看到刚才的所有操作被原封不动地复现出来包括鼠标移动的路径。这种体验对于排查问题来说简直是“降维打击”。3. 从Demo到生产必须考虑的实战配置与优化上面的代码能跑起来但离真正用到生产环境还有距离。直接这么用你可能会遇到数据量爆炸、隐私泄露、性能卡顿等问题。下面我结合踩过的坑分享几个关键的实战配置点。3.1 性能优化别让录制拖垮你的页面rrweb 录制的是 DOM 的增量快照数据量可能非常大。一个用户活跃10分钟产生几十 MB 的数据很正常。如果不加处理前端内存会爆网络传输也吃不消。第一招善用采样策略。上面代码中提到的sampling配置是你的第一道防线。对于mousemove鼠标移动和scroll滚动这类高频但信息密度可能较低的事件进行抽样采集。比如设置mousemove: 150意味着每150毫秒最多采集一次鼠标位置这能大幅减少数据量同时基本不影响对用户鼠标路径的分析。第二招设置录制时长与事件数量上限。你不能无限制地录下去。我通常会在录制逻辑里加一个“自动停止”机制。const MAX_RECORDING_DURATION 10 * 60 * 1000; // 最长录制10分钟 const MAX_EVENTS_COUNT 5000; // 最多录制5000个事件 let startTime 0; let eventCount 0; stopRecordFn rrweb.record({ emit(event) { eventCount; // 检查是否超过最大事件数 if (eventCount MAX_EVENTS_COUNT) { console.warn(达到最大事件数自动停止录制); if (stopRecordFn) stopRecordFn(); return; } rrwebStore.addEvent(event); }, // ... 其他配置 }); startTime Date.now(); // 设置一个定时器到达最大时长后自动停止 const durationTimer setTimeout(() { if (stopRecordFn) { console.warn(达到最大录制时长自动停止); stopRecordFn(); } }, MAX_RECORDING_DURATION);第三招数据压缩与分片上传。在emit回调里我们不应该每产生一个事件就立刻上传那会发起海量请求。更佳实践是在内存中缓冲定期或当缓冲数据达到一定大小时压缩后一次性上传。可以使用pako库进行 gzip 压缩能减少70%以上的体积。import pako from pako; let eventBuffer: eventWithTime[] []; const BUFFER_FLUSH_SIZE 100; // 每100个事件刷新一次 const BUFFER_FLUSH_INTERVAL 5000; // 或每5秒刷新一次 stopRecordFn rrweb.record({ emit(event) { eventBuffer.push(event); if (eventBuffer.length BUFFER_FLUSH_SIZE) { flushBuffer(); } }, }); // 定期刷新缓冲区的函数 setInterval(flushBuffer, BUFFER_FLUSH_INTERVAL); function flushBuffer() { if (eventBuffer.length 0) return; const eventsToSend [...eventBuffer]; eventBuffer []; // 清空缓冲区 // 压缩数据 const jsonStr JSON.stringify(eventsToSend); const compressed pako.gzip(jsonStr); // 将压缩后的二进制数据发送到后端 uploadCompressedEvents(compressed); }3.2 隐私与安全敏感信息必须脱敏录制功能非常强大但也意味着它能“看到”用户输入的一切包括密码、身份证号、聊天内容等。这是巨大的隐私风险必须在设计之初就解决。核心方法是 DOM 屏蔽与替换。rrweb 提供了强大的mask和block配置。stopRecordFn rrweb.record({ emit(event) { /* ... */ }, // 1. 屏蔽文本输入将输入框、密码框内的文字替换为 * maskTextSelector: input[typepassword], input[typetel], .sensitive-input, // 2. 屏蔽整个元素某些区域完全不被录制如聊天窗口、个人中心 blockSelector: .private-chat, .user-profile, [data-rrweb-block], // 3. 屏蔽类名动态类名可能包含敏感信息 maskAllInputs: false, // 为true时会屏蔽所有输入框可能过于粗暴 // 4. 自定义掩码函数最灵活的方式 maskInputOptions: { password: true, tel: true, email: true, // 邮箱也建议屏蔽 text: (element: HTMLElement) { // 如果元素有特定属性则屏蔽其内容 return element.hasAttribute(data-sensitive); }, }, // 5. 在每次快照时进行自定义处理最彻底 // 这个钩子函数允许你修改序列化前的DOM节点 // serialize: (node) { ... } });在实际项目中我通常会推动团队建立一份《可录制元素白名单》规范。默认屏蔽所有输入框和带有data-sensitive属性的区域只有明确申明为“可录制”的、不涉及隐私的交互区域才开放录制。同时在用户协议中明确告知录制行为及数据用途做到合法合规。3.3 错误处理与兼容性确保录制稳定可靠rrweb 依赖现代浏览器 API但用户环境千奇百怪。必须做好降级处理。const startRecording async () { // 检查浏览器兼容性 if (!isBrowserSupported()) { alert(您的浏览器版本较低无法使用行为录制功能。建议升级至最新版Chrome或Firefox。); return; } try { stopRecordFn rrweb.record({ emit(event) { // 添加try-catch防止单个事件处理出错导致整个录制中断 try { handleEvent(event); } catch (err) { console.error(处理录制事件时出错:, err); // 可以上报错误日志但不要抛出避免影响主流程 } }, // 录制过程中的错误捕获 errorHandler: (err) { console.error(rrweb录制过程出错:, err); // 上报错误到监控平台 reportErrorToServer(err); // 根据错误严重程度决定是否停止录制 if (isFatalError(err)) { if (stopRecordFn) stopRecordFn(); } }, }); isRecording.value true; } catch (initErr) { console.error(初始化rrweb录制失败:, initErr); alert(录制功能初始化失败请刷新页面重试。); } }; // 简单的浏览器支持判断 function isBrowserSupported(): boolean { return typeof window ! undefined MutationObserver in window requestIdleCallback in window; // rrweb依赖的一些API }此外一些浏览器扩展如广告拦截器、脚本管理器可能会干扰 rrweb 的 DOM 序列化过程。如果发现录制空白或异常可以提示用户“尝试在无痕模式或禁用某些扩展后重试”。这不是 rrweb 的 Bug而是浏览器安全策略使然。4. 数据链路闭环录制、存储、分析与报警录制和回放只是第一步真正产生价值的是将采集到的行为数据融入现有的监控和分析体系。这里我分享一下我们项目中的完整数据链路设计。4.1 后端存储与数据模型设计前端压缩上传的数据后端需要解压、解析并存储。数据表的设计很关键我们的一张核心表结构大致如下CREATE TABLE user_session_recordings ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id VARCHAR(64) NOT NULL COMMENT 唯一会话ID, user_id VARCHAR(64) COMMENT 用户ID如果已登录, page_url VARCHAR(2048) NOT NULL COMMENT 录制起始页面URL, start_time DATETIME(3) NOT NULL COMMENT 录制开始时间毫秒精度, duration INT NOT NULL COMMENT 录制时长毫秒, events_count INT NOT NULL COMMENT 事件总数, events_data LONGTEXT NOT NULL COMMENT 经过压缩的rrweb事件JSON数据, metadata JSON COMMENT 扩展元数据如设备信息、浏览器版本等, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_session (session_id), INDEX idx_user_time (user_id, start_time), INDEX idx_url_time (page_url(255), start_time) -- 前缀索引 ) COMMENT用户会话录制数据表;这里有几个设计要点会话ID (session_id): 在前端录制开始时生成一个唯一ID贯穿整个录制过程用于关联同一会话内的多次上传分片数据。分开存储元数据和事件体:events_data字段很大用LONGTEXT存储压缩后的 JSON 字符串。元数据如时间、用户、页面单独成列便于快速查询筛选。索引策略: 对最常用的查询条件如按用户、按时间、按页面建立索引但注意page_url可能很长我们使用了前缀索引。后端接口负责接收前端分片上传的压缩数据解压后按session_id合并最终存入数据库。为了应对高并发我们引入了消息队列如 RabbitMQ/Kafka上传请求只需快速写入队列由消费者异步处理存储避免阻塞用户操作。4.2 与现有监控系统联动rrweb 的数据不应该孤立存在。我们将其与错误监控如 Sentry、性能监控如 Lighthouse 数据和业务埋点打通。联动场景一错误复现。当监控平台捕获到一个前端 JavaScript 错误时会自动关联上当前时刻前后一段时间比如前后30秒的 rrweb 会话录制 ID。工程师在查看错误详情时旁边直接有一个“回放用户操作”的按钮一点就能看到错误发生前用户做了什么极大提升了定位效率。联动场景二性能分析。当性能监控发现某个页面“首次内容绘制FCP”或“交互延迟INP”时间过长时可以自动筛选出该时间段内在该页面有录制数据的会话。通过回放可以直观地看到在加载卡顿期间用户界面是如何响应的或如何不响应的结合性能时间线能更准确地判断是网络问题、资源加载问题还是脚本执行问题。联动场景三转化漏斗分析。将 rrweb 的会话与业务埋点的“关键事件”如“加入购物车”、“支付成功”关联。对于在转化漏斗中流失的用户可以抽样调取他们的操作录像进行分析是在哪一步犹豫了是遇到了错误提示还是界面设计有误导这种定性分析是定量数据漏斗的完美补充。4.3 构建内部回放平台当数据积累起来后一个专门的内部回放平台就非常有必要了。这个平台不需要很复杂核心功能包括会话列表与搜索: 支持按时间、用户ID、页面URL、设备类型等条件筛选会话。会话详情与回放: 点击一个会话直接嵌入 rrweb-player 进行回放并同时展示该会话关联的错误日志、性能指标和业务埋点。标注与协作: 工程师在回放过程中可以在时间轴上打点标注“此处发生XX错误”并添加注释方便团队协作分析。关键会话标记: 可以将典型的用户路径如“完美下单路径”、典型的 Bug 复现路径标记为“关键会话”用于新人培训或案例复盘。我们最初用了一个简单的管理后台后来逐渐演进成一个独立的服务。前端用 React Ant Design后端用 Node.js Egg.js播放器直接引入 rrweb-player。开发成本不高但给整个研发团队带来的效率提升是巨大的。5. 避坑指南我踩过的那些“坑”与解决方案最后分享几个我在实践中遇到的真实问题和解决方案希望能帮你少走弯路。坑一录制数据在部分 SPA 页面切换时“丢失”或“错乱”。现象: 在 Vue Router 或 React Router 进行路由跳转后回放时页面内容显示不正确或事件与页面不匹配。原因: rrweb 录制的是 DOM而 SPA 的路由跳转通常不会导致页面完全刷新。如果录制实例在页面组件内创建和销毁路由跳转时旧实例停止新实例开启两段录制数据在时间线上是割裂的。解决方案:将 rrweb 录制实例提升到应用最顶层如 App.vue 或根组件在整个应用生命周期内只初始化一次。通过全局状态如 Vuex/Pinia或事件总线来控制录制的开始和结束。这样就能录制到完整的、包含多个路由页面的用户会话。坑二录制 iframe 内的内容一片空白。现象: 页面中嵌入了第三方 iframe如地图、客服聊天窗口回放时 iframe 区域是空白的。原因: 由于浏览器的同源策略限制父页面无法直接访问和序列化跨域 iframe 内部的 DOM。解决方案: 这是一个硬性限制。目前最可行的办法是如果 iframe 内容对你分析用户行为至关重要尝试与 iframe 的提供方沟通看他们是否支持通过 postMessage 等方式向你传递关键的用户交互事件。或者退而求其次在 iframe 周围录制用户的点击、聚焦等行为至少知道用户与 iframe 有过交互。坑三数据量过大上传失败或影响主线程。现象: 页面在录制一段时间后变得卡顿或者上传请求失败。原因:emit回调在主线程执行如果处理逻辑如压缩、序列化太重会阻塞渲染。另外一次性上传数据太大可能被服务器拒绝或超时。解决方案综合运用前面提到的策略:缓冲与异步: 确保emit回调只做最简单的 push 到内存数组操作。Web Worker: 将数据压缩、序列化等 CPU 密集型任务丢到 Web Worker 中执行不阻塞主线程。分片上传: 将大文件拆分成多个小块上传并在服务端合并。同时实现断点续传和失败重试机制。采样与过滤: 合理配置sampling并考虑在emit中过滤掉一些不必要的高频低价值事件如某些持续的requestAnimationFrame回调。坑四隐私合规的挑战。现象: 法务或安全团队质疑录制功能的风险。解决方案: 这不仅是技术问题。我们采取了“默认屏蔽按需开放”的原则。技术上通过maskTextSelector和blockSelector进行严格过滤。流程上我们在用户协议中明确增加了关于“匿名行为数据收集用于改进产品”的条款。在应用内提供了显眼的“管理隐私设置”入口用户可以一键关闭行为录制。对所有存储的录制数据设置严格的访问权限控制只有解决特定问题如排查该用户反馈的Bug的工程师经过审批后才能临时访问并且访问日志被完整记录。对数据设置自动过期策略如30天后自动删除减少长期存储的风险。把这些坑填平后rrweb 才能真正成为一个稳定、可靠、安全的生产力工具。它不再是一个炫技的 demo而是贯穿于我们研发、测试、产品分析全流程的“基础设施”。每次通过回放快速定位到一个刁钻的 Bug或者通过分析用户录像发现一个产品优化点都会觉得前期的这些投入是值得的。