feat(app): add rrweb recording and replay functionality

- Integrate rrweb library for session recording capabilities
- Add RecordeReplayPage component with start/stop recording buttons
- Implement useRecorder hook to manage recording state and events
- Create bridge service for extension communication
- Add storage service with Chrome extension and web compatibility
- Implement downloadHtml utility for replay file generation
- Update manifest permissions for activeTab, scripting, and tabs
- Add Chrome extension type definitions as development dependencies
This commit is contained in:
雨霖铃
2026-01-25 12:36:52 +08:00
parent 49aaab6e2c
commit a53b58a007
11 changed files with 449 additions and 9 deletions
+2
View File
@@ -2,6 +2,7 @@ import {HashRouter as Router, Routes, Route, NavLink} from 'react-router-dom';
import {useState, useEffect} from 'react';
import TimestampPage from './pages/TimestampPage';
// import ElectronicWoodenFishPage from "./pages/ElectronicWoodenFishPage";
import RecordeReplayPage from './pages/RecordeReplayPage';
import './App.css';
// 导航项配置数组,便于后续添加
@@ -9,6 +10,7 @@ import './App.css';
const navItems = [
{path: '/', label: '时间戳', element: <TimestampPage/>},
// {path: '/dzmy', label: '电子木鱼', element: <ElectronicWoodenFishPage/>},
{path: '/recorde-replay', label: '录制与回放', element: <RecordeReplayPage/>},
];
function App() {
+42
View File
@@ -0,0 +1,42 @@
import {useRef, useState} from "react";
import {record} from "rrweb";
export const useRecorder = () => {
// 存储录制事件
const eventRef = useRef([]);
// 存储停止录制函数
const stopFnRef = useRef();
const [isRecording, setIsRecording] = useState(false);
const startRecord = async () => {
if (isRecording) return;
setIsRecording(true);
// 启动录制
stopFnRef.current = record({
emit(event) {
// 存储数据
eventRef.current.push(event);
}
});
};
const stopRecord = () => {
if (!isRecording) return;
setIsRecording(false);
// 停止录制
if (stopFnRef.current) {
stopFnRef.current();
}
};
return {
startRecord,
stopRecord,
isRecording,
getEvents: () => eventRef.current
}
};
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
// import {sendRecordCommand} from '../services/bridge'
// import {storage} from "../services/storage";
import {useRecorder} from "../hooks/useRecorder";
import {downloadHtml} from "../utils/recordUtils";
const RecordeReplayPage = () => {
// const [isRecording, setIsRecording] = useState(false);
const {startRecord, stopRecord, getEvents, isRecording} = useRecorder();
const handleStart = async () => {
try {
// await sendRecordCommand("START_RECORD");
if (!isRecording) {
await startRecord();
} else {
stopRecord();
}
// await storage.set({isRecording: true, recordingStartTime: Date.now()});
// console.log('events', events);
} catch (e) {
console.error("Error starting recording:", e);
}
}
const handleStop = async () => {
try {
// await sendRecordCommand("STOP_RECORD");
// const events =useRecorder().getEvents();
// console.log('events', events);
// setIsRecording(false);
// await storage.remove('isRecording');
stopRecord();
downloadHtml(getEvents());
// console.log('events', getEvents());
} catch (e) {
console.error("Error stopping recording:", e)
}
};
return (
<div style={{width: "300px", padding: "16px"}}>
<h3>RRWeb Recorder</h3>
{/* 3. 选择标签页逻辑:默认为当前页,如果需要跨页,需先列出 chrome.tabs.query */}
<div style={{display: "flex", gap: "10px", marginBottom: "20px"}}>
{!isRecording ? (
<button onClick={handleStart} className={"action-btn"}>
开始录制
</button>
) : (
<button className={"action-btn stop-btn"}
onClick={handleStop}>停止录制</button>
)}
</div>
</div>
);
};
export default RecordeReplayPage;
+56
View File
@@ -0,0 +1,56 @@
// src/services/bridge.ts
import {record} from 'rrweb';
import {isExtension} from '../utils/env';
let stopFn = null;
let events = [];
/**
* 发送指令给录制器(Content Script 或 当前页面逻辑)
* @param action 'START_RECORD' | 'STOP_RECORD'
* @returns {Promise<unknown>}
*/
export const sendRecordCommand = async (action) => {
if (isExtension()) {
// === 插件环境 ===
try {
const [tab] = await chrome.tabs.query({active: true, currentWindow: true});
if (tab?.id) {
// 使用 Promise 包装 sendMessage 以便统一 async/await 风格
return new Promise((resolve, reject) => {
chrome.tabs.sendMessage(tab.id, {action}, (response) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else {
resolve(response);
}
});
});
}
} catch (e) {
console.error("Extension communication error:", e);
}
} else {
// === 网页环境 (Web Demo / Localhost 调试) ===
console.log(`[Web Mode] Simulation: Sending ${action}`);
if (action === 'START_RECORD') {
stopFn = record({
emit(event) {
if (events.length > 1000) {
console.log('events length > 1000');
stopFn();
}
events.push(event);
}
});
} else if (action === 'STOP_RECORD') {
if (stopFn != null) {
stopFn();
}
console.log('[Web Mode] Simulation: Stopping recorder');
console.log('[Web Mode] Simulation: Events:', events);
console.log('[Web Mode] Simulation: Events:', events.length);
}
}
};
+40
View File
@@ -0,0 +1,40 @@
// src/services/storage.ts
import {isExtension} from '../utils/env';
export const storage = {
set: async (items) => {
if (isExtension()) {
return chrome.storage.local.set(items);
} else {
// Web 兼容:写入 localStorage
Object.keys(items).forEach(key => {
localStorage.setItem(key, JSON.stringify(items[key]));
});
return Promise.resolve();
}
},
get: async (keys) => {
if (isExtension()) {
return chrome.storage.local.get(keys);
} else {
// Web 兼容:读取 localStorage
const result = {};
const keyList = Array.isArray(keys) ? keys : [keys];
keyList.forEach(key => {
const val = localStorage.getItem(key);
if (val) result[key] = JSON.parse(val);
});
return Promise.resolve(result);
}
},
remove: async (key) => {
if (isExtension()) {
return chrome.storage.local.remove(key);
} else {
localStorage.removeItem(key);
return Promise.resolve();
}
}
};
+7
View File
@@ -0,0 +1,7 @@
/**
* 判断当前是否处于 Chrome 插件环境
* @returns {false|chrome.storage.LocalStorageArea}
*/
export const isExtension = () => {
return typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local;
}
+53
View File
@@ -0,0 +1,53 @@
// import rrwebPlayer from 'rrweb-player';
export const downloadHtml = (events) => {
if (events.length === 0) return;
// 1. 构建 HTML 模板字符串
// 我们将 events 数据直接注入到 <script> 标签中
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RRWeb 录像回放</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/style.css" />
</head>
<body style="margin: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background: #f0f2f5;">
<div id="player"></div>
<script src="https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/index.js"></script>
<script>
/* 注入录制的数据 */
const events = ${JSON.stringify(events)};
/* 初始化播放器 */
new rrwebPlayer({
target: document.getElementById('player'),
props: {
events: events,
width: 1024, // 可以根据需要调整
height: 576,
autoPlay: true,
showController: true,
},
});
</script>
</body>
</html>
`;
// 2. 创建 Blob 并下载
const blob = new Blob([htmlContent], {type: 'text/html'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `replay-${Date.now()}.html`; // 保存为 .html 文件
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};