从零构建用户操作录制与回放系统:基于rrweb的实战指南
2026/9/11 0:23:29 网站建设 项目流程

最近在开发一个需要记录用户操作轨迹并支持辅助功能的应用时,发现市面上的方案要么太重,要么不支持自定义录制。自己动手实现一个轻量、灵活且能持续运行的工具,成了刚需。本文将分享一套从零构建“可自录、可辅助、一直在”能力的完整实战方案,涵盖核心原理、代码实现、性能优化与生产部署,无论是前端监控、自动化测试还是辅助工具开发,都能直接复用。

1. 背景与核心概念

“可自录、可辅助、一直在”描述的是一种技术能力组合,它并非指某个单一的库或框架,而是一种架构模式。其核心在于构建一个能够持续运行自动记录用户或系统行为,并能基于这些记录提供智能辅助(如操作回放、行为分析、异常提示)的系统。

1.1 核心概念拆解

  • 可自录 (Self-Recording): 系统能够自动、无侵入地捕获用户在界面上的交互事件(如点击、输入、滚动)、网络请求、控制台日志、甚至性能指标。这不同于手动录屏,它记录的是结构化的、可编程的“行为数据”。
  • 可辅助 (Assistive Capabilities): 基于录制下来的行为数据,系统可以提供多种辅助功能。例如:
    • 操作回放 (Replay): 精确复现用户的操作序列,用于问题复现、用户行为分析或自动化测试。
    • 行为分析 (Behavior Analysis): 统计高频操作路径、发现异常操作模式(如频繁错误点击)。
    • 智能提示 (Smart Hint): 根据当前上下文和历史操作,预测并提示用户下一步可能需要的操作。
    • 自动化脚本生成 (Script Generation): 将录制的事件序列转换为可执行的自动化测试脚本(如 Puppeteer、Playwright 脚本)。
  • 一直在 (Always-On): 指该录制与辅助服务需要以低开销、高可用的方式在后台持续运行,不影响主应用的性能和用户体验。它可能是一个常驻的 Web Worker、一个后台 Service,或是一个独立的微服务。

1.2 常见应用场景

  • 前端错误监控与用户行为追踪: 结合 Sentry 等工具,不仅记录错误堆栈,还能记录错误发生前用户的操作路径,极大提升排查效率。
  • 自动化测试: 录制真实用户操作,转化为稳定的端到端(E2E)测试用例,实现“测试即录制”。
  • 产品优化与用户体验分析: 分析用户的实际操作流程,发现功能使用瓶颈或设计不合理之处。
  • 内部工具与辅助系统: 为复杂的企业级后台系统提供操作指引、新手教程或自动化任务流。
  • 无障碍辅助功能: 为视障用户提供操作导航和语音提示(需结合其他技术)。

2. 环境准备与版本说明

本文将基于现代 Web 技术栈(TypeScript + Node.js)实现一个基础但完整的示例。你可以根据项目实际情况调整框架和库的版本。

2.1 开发环境

  • 操作系统: macOS / Windows / Linux (本文命令以 macOS/Linux 为例)
  • Node.js: >= 16.x (推荐 LTS 版本)
  • 包管理器: npm 或 yarn (本文使用 npm)
  • 浏览器: 现代浏览器(Chrome 90+, Firefox 88+),支持MutationObserver,PerformanceObserver等 API。

2.2 项目初始化与依赖首先,创建一个新的项目目录并初始化。

mkdir self-recording-assistant && cd self-recording-assistant npm init -y

安装 TypeScript 及相关类型定义。

npm install typescript ts-node @types/node --save-dev npx tsc --init

安装核心依赖库:

  • rrweb: 用于录制和回放 web 页面 DOM 变化与用户交互的库,是“自录”功能的核心。
  • rrweb-player: 用于播放 rrweb 录制序列的 UI 组件。
  • express: 用于构建一个简单的后端服务,存储和提供录制数据。
  • socket.ioWebSocket: 用于实现录制数据的实时传输(可选,用于“一直在”的实时模式)。
npm install rrweb rrweb-player express npm install @types/express socket.io @types/socket.io --save-dev

2.3 项目结构创建以下目录和文件,形成清晰的项目结构。

self-recording-assistant/ ├── package.json ├── tsconfig.json ├── public/ # 静态资源 │ └── index.html ├── src/ │ ├── client/ # 前端(录制/播放)代码 │ │ ├── recorder.ts │ │ ├── player.ts │ │ └── assistant.ts # 辅助逻辑 │ ├── server/ # 后端服务代码 │ │ ├── index.ts │ │ └── storage.ts # 数据存储逻辑 │ └── shared/ # 共享类型定义 │ └── types.ts └── dist/ # 编译输出目录(由 tsconfig 指定)

3. 核心原理与关键技术拆解

实现“可自录、可辅助、一直在”的系统,主要依赖于以下几项关键技术。

3.1 录制原理:DOM 序列化与增量快照纯手动监听所有事件是不现实的。rrweb采用了一种巧妙的方案:

  1. 初始全量快照: 录制开始时,序列化整个 DOM 树(包括样式、状态),生成一个初始快照。
  2. 增量变更记录: 通过MutationObserver监听 DOM 变化,通过事件监听器捕获用户交互(点击、输入、滚动等)。只记录发生变化的部分。
  3. 数据序列化: 将 DOM 节点和事件转换为可序列化的普通对象(Plain Object),便于网络传输和存储。
  4. 时间戳对齐: 为每个事件和快照打上高精度时间戳,保证回放时的时序正确性。

3.2 辅助功能基础:事件序列的回放与解析录制下来的数据是一个带时间戳的事件流。辅助功能建立在对这个事件流的解析之上:

  • 回放: 按照时间顺序,在一个“干净”的环境(如 iframe)中,先应用初始快照,再依次重放增量事件,即可还原界面变化。
  • 分析: 遍历事件流,可以提取出“点击了哪个按钮”、“在哪个输入框输入了什么”、“页面跳转路径”等结构化信息。
  • 提示: 基于当前回放到的节点和事件类型,可以触发相应的提示逻辑。

3.3 “一直在”的实现策略

  1. Web Worker: 将录制脚本运行在独立的 Web Worker 线程中,避免阻塞主线程,保证页面流畅。录制数据通过postMessage与主线程通信。
  2. Service Worker: 对于 PWA 应用,可以利用 Service Worker 在后台甚至离线时进行有限的数据处理和缓存。
  3. 节流与采样: 不是所有事件都需要记录。对高频率事件(如mousemovescroll)进行节流或采样,只记录关键节点,大幅减少数据量。
  4. 后端常驻服务: 录制数据可以定期或实时发送到后端服务。后端服务负责存储、分析和提供辅助功能接口。

4. 完整实战案例:构建一个用户操作录制与回放系统

下面我们一步步实现一个最小可行系统。

4.1 前端录制器 (src/client/recorder.ts)这是核心的录制模块,负责初始化 rrweb 并控制录制过程。

// src/client/recorder.ts import * as rrweb from 'rrweb'; import { eventWithTime } from '@rrweb/types'; export class Recorder { private events: eventWithTime[] = []; private stopFn: (() => void) | null = null; private isRecording: boolean = false; // 开始录制 public start(): void { if (this.isRecording) { console.warn('Recording is already in progress.'); return; } this.events = []; // 清空旧数据 this.stopFn = rrweb.record({ emit: (event) => { // 将事件存入内存数组 this.events.push(event); // 可选:实时发送到后端 (例如使用 WebSocket) // this.sendEventToBackend(event); }, // 录制配置项 recordCanvas: true, // 是否录制 canvas collectFonts: true, // 是否收集字体 // 对频繁事件进行采样,优化性能 sampling: { mousemove: false, // 不录制鼠标移动,数据量太大 scroll: 150, // 每150ms最多记录一次滚动 }, // 屏蔽敏感元素(如密码输入框) maskTextSelector: '[data-mask]', }); this.isRecording = true; console.log('Recording started.'); } // 停止录制 public stop(): eventWithTime[] { if (!this.isRecording || !this.stopFn) { console.warn('No active recording to stop.'); return []; } this.stopFn(); this.isRecording = false; console.log('Recording stopped. Total events:', this.events.length); return this.events; // 返回录制的事件数组 } // 获取录制数据 public getEvents(): eventWithTime[] { return [...this.events]; // 返回副本 } // 重置录制数据 public reset(): void { this.events = []; if (this.isRecording) { this.stop(); } this.isRecording = false; this.stopFn = null; } // 私有方法:将事件发送到后端(示例) private sendEventToBackend(event: eventWithTime): void { // 这里可以使用 fetch 或 WebSocket // fetch('/api/events', { method: 'POST', body: JSON.stringify(event) }); } }

4.2 后端服务与存储 (src/server/index.tsstorage.ts)创建一个简单的 Express 服务,提供 API 来接收和获取录制数据。

// src/server/index.ts import express from 'express'; import path from 'path'; import { Storage } from './storage'; const app = express(); const port = 3000; const storage = new Storage(); // 中间件:解析 JSON 请求体 app.use(express.json()); // 静态文件服务,托管前端页面 app.use(express.static(path.join(__dirname, '../../public'))); // API: 保存录制会话 app.post('/api/sessions', (req, res) => { try { const { sessionId, events } = req.body; if (!sessionId || !Array.isArray(events)) { return res.status(400).json({ error: 'Invalid request body' }); } storage.saveSession(sessionId, events); res.status(201).json({ sessionId, message: 'Session saved successfully.' }); } catch (error) { console.error('Failed to save session:', error); res.status(500).json({ error: 'Internal server error' }); } }); // API: 获取录制会话 app.get('/api/sessions/:sessionId', (req, res) => { try { const { sessionId } = req.params; const events = storage.getSession(sessionId); if (!events) { return res.status(404).json({ error: 'Session not found' }); } res.json({ sessionId, events }); } catch (error) { console.error('Failed to get session:', error); res.status(500).json({ error: 'Internal server error' }); } }); // API: 获取会话列表 app.get('/api/sessions', (req, res) => { try { const sessions = storage.listSessions(); res.json({ sessions }); } catch (error) { console.error('Failed to list sessions:', error); res.status(500).json({ error: 'Internal server error' }); } }); app.listen(port, () => { console.log(`Server is running at http://localhost:${port}`); });
// src/server/storage.ts // 简易的内存存储,生产环境需替换为数据库(如 Redis, PostgreSQL) import { eventWithTime } from '@rrweb/types'; export class Storage { private sessions: Map<string, eventWithTime[]> = new Map(); saveSession(sessionId: string, events: eventWithTime[]): void { this.sessions.set(sessionId, events); console.log(`Session saved: ${sessionId}, events count: ${events.length}`); } getSession(sessionId: string): eventWithTime[] | undefined { return this.sessions.get(sessionId); } listSessions(): Array<{ id: string; eventCount: number }> { const list: Array<{ id: string; eventCount: number }> = []; this.sessions.forEach((events, id) => { list.push({ id, eventCount: events.length }); }); return list; } }

4.3 前端播放器与界面 (public/index.htmlsrc/client/player.ts)创建一个 HTML 页面,集成录制控制和回放功能。

<!-- public/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>操作录制与回放系统</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/style.css"/> <style> body { font-family: sans-serif; margin: 20px; } .control-panel { margin-bottom: 20px; } button { margin-right: 10px; padding: 8px 16px; cursor: pointer; } #player-container { width: 100%; height: 600px; border: 1px solid #ccc; margin-top: 20px;} </style> </head> <body> <h1>用户操作录制与回放演示</h1> <div class="control-panel"> <button id="startBtn">开始录制</button> <button id="stopBtn" disabled>停止录制</button> <button id="saveBtn" disabled>保存会话</button> <button id="loadBtn">加载会话列表</button> <div> <label for="sessionSelect">选择会话回放: </label> <select id="sessionSelect" disabled></select> <button id="playBtn" disabled>回放</button> </div> </div> <p>状态: <span id="status">未开始录制</span></p> <div id="player-container"></div> <script src="https://cdn.jsdelivr.net/npm/rrweb@latest/dist/rrweb.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/index.js"></script> <!-- 编译后的客户端JS --> <script src="/dist/client/recorder.js"></script> <script src="/dist/client/player.js"></script> <script> // 页面加载后初始化 document.addEventListener('DOMContentLoaded', () => { window.app = new PlayerApp(); }); </script> </body> </html>
// src/client/player.ts import { Recorder } from './recorder'; import rrwebPlayer from 'rrweb-player'; import 'rrweb-player/dist/style.css'; export class PlayerApp { private recorder: Recorder; private currentSessionId: string | null = null; constructor() { this.recorder = new Recorder(); this.bindEvents(); this.updateStatus('就绪'); } private bindEvents(): void { document.getElementById('startBtn')!.addEventListener('click', () => this.startRecording()); document.getElementById('stopBtn')!.addEventListener('click', () => this.stopRecording()); document.getElementById('saveBtn')!.addEventListener('click', () => this.saveSession()); document.getElementById('loadBtn')!.addEventListener('click', () => this.loadSessions()); document.getElementById('playBtn')!.addEventListener('click', () => this.playSession()); } private startRecording(): void { this.recorder.start(); this.updateStatus('录制中...'); this.toggleButtons(true); } private stopRecording(): void { const events = this.recorder.stop(); this.updateStatus(`录制停止,共 ${events.length} 个事件`); this.toggleButtons(false); // 生成一个临时会话ID用于保存 this.currentSessionId = `session_${Date.now()}`; (document.getElementById('saveBtn') as HTMLButtonElement).disabled = false; } private async saveSession(): Promise<void> { if (!this.currentSessionId) return; const events = this.recorder.getEvents(); try { const response = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: this.currentSessionId, events }), }); if (response.ok) { alert('会话保存成功!'); this.loadSessions(); // 刷新列表 } else { alert('保存失败'); } } catch (error) { console.error('Save failed:', error); alert('网络错误,保存失败'); } } private async loadSessions(): Promise<void> { try { const response = await fetch('/api/sessions'); const data = await response.json(); const select = document.getElementById('sessionSelect') as HTMLSelectElement; select.innerHTML = '<option value="">--请选择--</option>'; data.sessions.forEach((s: any) => { const option = document.createElement('option'); option.value = s.id; option.textContent = `${s.id} (${s.eventCount} events)`; select.appendChild(option); }); select.disabled = false; (document.getElementById('playBtn') as HTMLButtonElement).disabled = false; } catch (error) { console.error('Load sessions failed:', error); } } private async playSession(): Promise<void> { const select = document.getElementById('sessionSelect') as HTMLSelectElement; const sessionId = select.value; if (!sessionId) return; try { const response = await fetch(`/api/sessions/${sessionId}`); const data = await response.json(); const container = document.getElementById('player-container')!; container.innerHTML = ''; // 清空旧播放器 new rrwebPlayer({ target: container, props: { events: data.events, width: container.clientWidth, height: 600, showController: true, }, }); this.updateStatus(`正在回放会话: ${sessionId}`); } catch (error) { console.error('Playback failed:', error); alert('回放失败,请检查会话数据'); } } private updateStatus(msg: string): void { document.getElementById('status')!.textContent = msg; } private toggleButtons(isRecording: boolean): void { (document.getElementById('startBtn') as HTMLButtonElement).disabled = isRecording; (document.getElementById('stopBtn') as HTMLButtonElement).disabled = !isRecording; } }

4.4 编译与运行更新tsconfig.json,确保输出目录正确。

{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules"] }

package.json中添加启动脚本。

{ "scripts": { "build": "tsc", "start:server": "node dist/server/index.js", "dev": "concurrently \"npx tsc --watch\" \"nodemon dist/server/index.js\"" } }

编译并启动服务:

npm run build npm run start:server

打开浏览器,访问http://localhost:3000,即可看到操作界面。点击“开始录制”,在页面上进行一些操作,然后停止录制并保存。之后可以从列表中选择会话进行回放,完美复现刚才的操作。

5. 进阶:实现“辅助”功能

基于录制的事件流,我们可以构建简单的辅助功能。例如,一个“操作热点图”分析。

5.1 分析模块 (src/client/assistant.ts)

// src/client/assistant.ts import { eventWithTime, IncrementalSource } from '@rrweb/types'; export interface AnalysisResult { totalClicks: number; clickHeatmap: Map<string, number>; // selector -> count inputFields: Set<string>; domMutations: number; } export class BehaviorAnalyzer { public analyze(events: eventWithTime[]): AnalysisResult { const result: AnalysisResult = { totalClicks: 0, clickHeatmap: new Map(), inputFields: new Set(), domMutations: 0, }; for (const event of events) { // 分析点击事件 if (event.type === 3 && event.data?.source === IncrementalSource.MouseInteraction) { const { type, selector } = event.data; if (type === 0) { // 0 代表 click result.totalClicks++; const count = result.clickHeatmap.get(selector) || 0; result.clickHeatmap.set(selector, count + 1); } } // 分析输入事件 if (event.type === 3 && event.data?.source === IncrementalSource.Input) { result.inputFields.add(event.data.selector || 'unknown'); } // 统计DOM变更 if (event.type === 2) { // 2 代表增量快照 result.domMutations++; } } return result; } public generateReport(result: AnalysisResult): string { let report = `=== 行为分析报告 ===\n`; report += `总点击次数: ${result.totalClicks}\n`; report += `DOM 变更次数: ${result.domMutations}\n`; report += `涉及输入框: ${Array.from(result.inputFields).join(', ') || '无'}\n`; report += `\n点击热点 (前5):\n`; const sortedClicks = Array.from(result.clickHeatmap.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 5); sortedClicks.forEach(([selector, count]) => { report += ` ${selector}: ${count} 次\n`; }); return report; } }

5.2 集成到主应用player.ts中,可以在回放结束后调用分析器并展示报告。

// 在 player.ts 的 PlayerApp 类中添加方法 private async analyzeAndShowReport(sessionId: string, events: eventWithTime[]): Promise<void> { const analyzer = new BehaviorAnalyzer(); const result = analyzer.analyze(events); const report = analyzer.generateReport(result); // 可以弹窗或显示在页面某个区域 const reportDiv = document.createElement('div'); reportDiv.style.cssText = 'background: #f5f5f5; padding: 15px; margin-top: 20px; white-space: pre-wrap; font-family: monospace;'; reportDiv.textContent = report; const container = document.getElementById('player-container'); if (container) { // 避免重复添加 const oldReport = container.nextElementSibling; if (oldReport && oldReport.id === 'behavior-report') { oldReport.remove(); } reportDiv.id = 'behavior-report'; container.insertAdjacentElement('afterend', reportDiv); } } // 在 playSession 方法获取数据后调用 // this.analyzeAndShowReport(sessionId, data.events);

6. 常见问题与排查思路

在实际开发和部署中,你可能会遇到以下问题。

问题现象可能原因排查与解决思路
录制数据量巨大,页面卡顿1. 未对高频率事件(mousemove)进行采样。
2. 录制了全尺寸图片或 Canvas。
3. 事件未及时清理或发送。
1. 配置sampling选项,关闭mousemove或设置采样频率。
2. 设置recordCanvasfalse,或降低 Canvas 录制质量。
3. 使用 Web Worker 进行录制,定期将数据批量发送到后端。
回放时样式错乱或布局异常1. 页面依赖动态加载的样式或字体未捕获。
2. 使用了 Shadow DOM 或 iframe,rrweb 配置未覆盖。
3. 回放环境与录制环境 CSS 基准不同。
1. 开启collectFonts: true,并确保关键样式在录制开始时已加载。
2. 检查 rrweb 的blockSelectorinlineStylesheet配置。
3. 尝试在回放时注入一个基础 CSS Reset。
无法录制输入框的内容1. 输入框可能被框架(如 React、Vue)代理了事件。
2. 输入类型为password,被默认屏蔽。
1. 确保 rrweb 在框架初始化之后启动录制。
2. 检查maskTextSelector配置,确保没有意外屏蔽目标输入框。对于密码框,录制其占位符即可。
后端接收事件失败或存储慢1. 网络问题或 CORS 限制。
2. 事件发送频率过高,后端处理不过来。
3. 存储层(如数据库)写入慢。
1. 确保后端配置了正确的 CORS 头。前端使用fetch时检查响应状态。
2. 前端进行事件缓冲,每 N 个事件或每 M 毫秒批量发送一次。
3. 考虑使用消息队列(如 RabbitMQ, Kafka)削峰,存储改用高性能数据库或时序数据库。
录制在 SPA 页面跳转后中断1. rrweb 实例在页面跳转(Hash 或 History)时被销毁。
2. 单页应用路由切换未触发页面重载。
1. 在路由变化事件中(如hashchange,popstate)重新初始化录制器,并关联之前的会话 ID。
2. 使用 rrweb 的record返回的addCustomEvent手动记录路由变更事件。

7. 最佳实践与工程建议

将这套系统用于生产环境,需要考虑更多工程化因素。

7.1 性能与资源优化

  • 采样策略是关键: 务必对mousemovescrollresize等高频事件进行采样或直接关闭。一个用户会话录制 10 分钟,如果全量记录mousemove,数据量可能超过 100MB。
  • 数据压缩: 在将事件数据发送到后端前,进行压缩(如使用pako库进行 gzip 压缩)。rrweb 的事件数据文本率很高,压缩比通常很可观。
  • 增量上传与清理: 不要等会话结束才上传数据。建立心跳机制,每 10-30 秒或每积累 100 个事件就上传一次。前端内存中只保留最近一段时间的原始事件。
  • 使用 Web Worker: 将录制、压缩、序列化等 CPU 密集型任务放到 Web Worker 中,绝对保证主线程流畅。

7.2 数据安全与隐私

  • 敏感信息屏蔽: 使用maskTextSelector配置项,为所有密码输入框、信用卡号、手机号等元素添加特定类名或属性(如>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询