最近在开发一个需要记录用户操作轨迹并支持辅助功能的应用时,发现市面上的方案要么太重,要么不支持自定义录制。自己动手实现一个轻量、灵活且能持续运行的工具,成了刚需。本文将分享一套从零构建“可自录、可辅助、一直在”能力的完整实战方案,涵盖核心原理、代码实现、性能优化与生产部署,无论是前端监控、自动化测试还是辅助工具开发,都能直接复用。
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.io或WebSocket: 用于实现录制数据的实时传输(可选,用于“一直在”的实时模式)。
npm install rrweb rrweb-player express npm install @types/express socket.io @types/socket.io --save-dev2.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采用了一种巧妙的方案:
- 初始全量快照: 录制开始时,序列化整个 DOM 树(包括样式、状态),生成一个初始快照。
- 增量变更记录: 通过
MutationObserver监听 DOM 变化,通过事件监听器捕获用户交互(点击、输入、滚动等)。只记录发生变化的部分。 - 数据序列化: 将 DOM 节点和事件转换为可序列化的普通对象(Plain Object),便于网络传输和存储。
- 时间戳对齐: 为每个事件和快照打上高精度时间戳,保证回放时的时序正确性。
3.2 辅助功能基础:事件序列的回放与解析录制下来的数据是一个带时间戳的事件流。辅助功能建立在对这个事件流的解析之上:
- 回放: 按照时间顺序,在一个“干净”的环境(如 iframe)中,先应用初始快照,再依次重放增量事件,即可还原界面变化。
- 分析: 遍历事件流,可以提取出“点击了哪个按钮”、“在哪个输入框输入了什么”、“页面跳转路径”等结构化信息。
- 提示: 基于当前回放到的节点和事件类型,可以触发相应的提示逻辑。
3.3 “一直在”的实现策略
- Web Worker: 将录制脚本运行在独立的 Web Worker 线程中,避免阻塞主线程,保证页面流畅。录制数据通过
postMessage与主线程通信。 - Service Worker: 对于 PWA 应用,可以利用 Service Worker 在后台甚至离线时进行有限的数据处理和缓存。
- 节流与采样: 不是所有事件都需要记录。对高频率事件(如
mousemove、scroll)进行节流或采样,只记录关键节点,大幅减少数据量。 - 后端常驻服务: 录制数据可以定期或实时发送到后端服务。后端服务负责存储、分析和提供辅助功能接口。
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.ts和storage.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.html和src/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. 设置 recordCanvas为false,或降低 Canvas 录制质量。3. 使用 Web Worker 进行录制,定期将数据批量发送到后端。 |
| 回放时样式错乱或布局异常 | 1. 页面依赖动态加载的样式或字体未捕获。 2. 使用了 Shadow DOM 或 iframe,rrweb 配置未覆盖。 3. 回放环境与录制环境 CSS 基准不同。 | 1. 开启collectFonts: true,并确保关键样式在录制开始时已加载。2. 检查 rrweb 的 blockSelector和inlineStylesheet配置。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 性能与资源优化
- 采样策略是关键: 务必对
mousemove、scroll、resize等高频事件进行采样或直接关闭。一个用户会话录制 10 分钟,如果全量记录mousemove,数据量可能超过 100MB。 - 数据压缩: 在将事件数据发送到后端前,进行压缩(如使用
pako库进行 gzip 压缩)。rrweb 的事件数据文本率很高,压缩比通常很可观。 - 增量上传与清理: 不要等会话结束才上传数据。建立心跳机制,每 10-30 秒或每积累 100 个事件就上传一次。前端内存中只保留最近一段时间的原始事件。
- 使用 Web Worker: 将录制、压缩、序列化等 CPU 密集型任务放到 Web Worker 中,绝对保证主线程流畅。
7.2 数据安全与隐私
- 敏感信息屏蔽: 使用
maskTextSelector配置项,为所有密码输入框、信用卡号、手机号等元素添加特定类名或属性(如>