refactor: 统一扩展消息机制并优化组件结构

1、统一消息系统:将 background、content 和 popup 的通信方式从原生的 chrome.runtime 迁移至 @webext-core/messaging,修复了消息回调返回 undefined 的问题。
2、优化组件结构:将通用组件从 entrypoints/popup/components 迁移至根目录 components,并使用 MUI 重构了 Navbar。
3、同步更新:调整了录制工具 (useRecorder) 和各页面组件(RecordeReplayPage, TestPage等),以适配新的消息协议和组件路径。
This commit is contained in:
雨霖铃
2026-02-13 20:38:15 +08:00
parent 22985f4c18
commit 50034331bc
16 changed files with 252 additions and 271 deletions
+117
View File
@@ -0,0 +1,117 @@
import { NavLink, useLocation } from 'react-router-dom';
import { useState, ReactNode, MouseEvent } from 'react';
import {
AppBar,
Box,
IconButton,
Menu,
MenuItem,
Tab,
Tabs,
Toolbar,
useMediaQuery,
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
interface RouteItem {
path: string;
label: string;
element: ReactNode;
}
interface NavbarProps {
items?: RouteItem[];
}
function Navbar({ items = [] }: NavbarProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const isMenuOpen = Boolean(anchorEl);
const location = useLocation();
// Replicating the screen size logic from original component
const isLargeScreen = useMediaQuery('(min-width:768px)');
const isMediumScreen = useMediaQuery('(min-width:480px)');
let visibleItemsCount: number;
if (isLargeScreen) {
visibleItemsCount = items.length;
} else if (isMediumScreen) {
visibleItemsCount = Math.min(3, items.length);
} else {
// Small screen
visibleItemsCount = Math.min(2, items.length);
}
const visibleNavItems = items.slice(0, visibleItemsCount);
const collapsedNavItems = items.slice(visibleItemsCount);
const handleMenuOpen = (event: MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
// Find the current active tab index for the Tabs value
// Using startsWith to handle nested routes correctly.
const activeTabIndex = visibleNavItems.findIndex((item) =>
location.pathname.startsWith(item.path),
);
return (
<AppBar
position="static"
color="default"
elevation={0}
sx={{ backgroundColor: 'transparent', borderBottom: 1, borderColor: 'divider' }}
>
<Toolbar sx={{ justifyContent: 'center', position: 'relative' }}>
<Tabs
value={activeTabIndex === -1 ? false : activeTabIndex}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
aria-label="navigation tabs"
>
{visibleNavItems.map((item) => (
<Tab key={item.path} label={item.label} component={NavLink} to={item.path} />
))}
</Tabs>
{collapsedNavItems.length > 0 && (
<Box sx={{ position: 'absolute', right: 8 }}>
<IconButton color="inherit" aria-label="open menu" edge="end" onClick={handleMenuOpen}>
<MenuIcon />
</IconButton>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
PaperProps={{
style: {
maxHeight: 48 * 4.5,
width: '20ch',
},
}}
>
{collapsedNavItems.map((item) => (
<MenuItem
key={item.path}
component={NavLink}
to={item.path}
onClick={handleMenuClose}
selected={location.pathname.startsWith(item.path)}
>
{item.label}
</MenuItem>
))}
</Menu>
</Box>
)}
</Toolbar>
</AppBar>
);
}
export default Navbar;
+54 -81
View File
@@ -1,6 +1,7 @@
import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser';
import { downloadHtmlInBackground } from '@/utils/recordUtils.tsx';
import { sendMessage, onMessage } from '@/utils/messages';
const events: unknown[] = [];
@@ -51,88 +52,65 @@ export default defineBackground(() => {
);
});
// 监听来自 popup script 的消息
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === messages.popup.checkStatus) {
console.log('[bg] checkStatus received');
sendToActiveTab({ type: messages.content.checkStatus })
.then((res) => {
sendResponse(res);
})
.catch((error: Error) => {
if (error.message.includes('restricted URL')) {
console.warn('Could not check status: on a restricted page.', error.message);
} else if (error.message.includes('No active tab found')) {
console.warn('Could not check status: no active tab found.');
} else {
console.error('Failed to check status in content script', error);
}
sendResponse({ ok: false });
});
return true;
}
onMessage('popup:check-status', async (message) => {
console.log(`[background] popup:check-status: ${message}`);
const obj = {
active: false,
startTime: -1,
};
if (msg.type === messages.popup.from.start) {
console.log('[bg] startRecording received');
sendToActiveTab({ type: messages.content.to.startRecording })
.then(async () => {
await chrome.runtime.sendMessage({ type: messages.popup.to.started });
sendResponse({ ok: true });
})
.catch((error: Error) => {
if (error.message.includes('restricted URL')) {
console.warn('Could not start recording: on a restricted page.', error.message);
} else if (error.message.includes('No active tab found')) {
console.warn('Could not start recording: no active tab found.');
} else {
console.error('Failed to start recording in content script', error);
}
sendResponse({ ok: false });
});
try {
const tabId = await getActiveTabId();
const result = await sendMessage('content:check-status', undefined, tabId);
console.log(`[background]111${result}`);
obj.active = result;
obj.startTime = new Date().getTime();
} catch (err) {
console.error(`[background-err]${err}`);
}
return obj;
});
if (msg.type === messages.popup.from.stop) {
console.log('[bg] stopRecording received');
sendToActiveTab({ type: messages.content.to.stopRecording })
.then(async () => {
await chrome.runtime.sendMessage({ type: messages.popup.to.stopped });
downloadHtmlInBackground(events);
sendResponse({ ok: true });
})
.catch((error: Error) => {
if (error.message.includes('restricted URL')) {
console.warn('Could not stop recording: on a restricted page.', error.message);
} else if (error.message.includes('No active tab found')) {
console.warn('Could not stop recording: no active tab found.');
} else {
console.error('Failed to stop recording in content script', error);
}
sendResponse({ ok: false });
});
onMessage('popup:start', async () => {
console.log('[bg] startRecording received');
try {
const tabId = await getActiveTabId();
const response = await sendMessage('content:start-recording', undefined, tabId);
if (response.ok) {
await sendMessage('popup:started', undefined);
return { ok: true };
}
return { ok: false };
} catch (error) {
console.error('Failed to start recording in content script', error);
return { ok: false };
}
});
onMessage('popup:stop', async () => {
console.log('[bg] stopRecording received');
try {
const tabId = await getActiveTabId();
const response = await sendMessage('content:stop-recording', undefined, tabId);
if (response.ok) {
await sendMessage('popup:stopped', undefined);
downloadHtmlInBackground(events);
return { ok: true };
}
return { ok: false };
} catch (error: unknown) {
console.error('Failed to stop recording in content script', error);
return { ok: false };
}
});
onMessage('content:save-tracke-events', (eventsList) => {
console.log('[bg] saveTrackeEvents received:', eventsList);
events.push(eventsList);
return true;
});
// 监听来自 content script 的消息
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === messages.content.from.saveTrackeEvents) {
console.log('[bg] saveTrackeEvents received:', msg.payload);
events.push(msg.payload);
sendResponse({ ok: true });
}
});
onMessage('getStringLength', (message) => {
return message.data.length;
});
async function sendToActiveTab(message: {
type: string;
data?: unknown;
activeTabId?: number;
[key: string]: unknown;
}) {
async function getActiveTabId() {
const activeTabs = await chrome.tabs.query({
active: true,
currentWindow: true,
@@ -159,12 +137,7 @@ export default defineBackground(() => {
}
}
const sendTo = message.activeTabId || activeTab.id;
try {
return await chrome.tabs.sendMessage(sendTo!, message);
} catch (error) {
console.error('Error sending message to active tab:', error);
throw error;
}
const sendTo = activeTab.id;
return sendTo!;
}
});
+29 -54
View File
@@ -1,11 +1,6 @@
import '../.wxt/types/imports.d.ts';
import { createRecorder } from '@/utils/useRecorder';
interface IResponse {
ok: boolean;
error?: string;
data?: unknown;
}
import { onMessage } from '@/utils/messages.tsx';
export default defineContentScript({
// matches: ['*://*.google.com/*'],
@@ -15,56 +10,36 @@ export default defineContentScript({
const recorder = createRecorder();
let isRecording = false;
onMessage('getStringLength', (message) => {
console.log(`[content] getStringLength received: ${message.data}`);
onMessage('content:check-status', () => {
console.log(`[content]${isRecording}`);
return isRecording;
});
// 监听来自 background script 的消息
chrome.runtime.onMessage.addListener(
(msg: { type: string }, _sender, sendResponse: (response: IResponse) => void) => {
const handleAsyncMessage = async () => {
try {
switch (msg.type) {
// 在这里处理异步消息类型
case messages.content.checkStatus:
return { ok: true, data: { isRecording } };
onMessage('content:start-recording', async () => {
try {
const started = await recorder.startRecord();
if (started) {
isRecording = true;
return { ok: true };
} else {
return { ok: false, error: 'Failed to start recorder' };
}
} catch (error) {
console.error('Error handling start recording:', error);
return { ok: false };
}
});
case messages.content.to.startRecording: {
const started = await recorder.startRecord(messages.content.from.saveTrackeEvents);
if (started) {
isRecording = true;
return { ok: true };
} else {
// 如果启动失败,返回错误信息
return { ok: false, error: 'Failed to start recorder' };
}
}
case messages.content.to.stopRecording:
// 停止录制的处理逻辑
console.log('[content] stopRecording received');
recorder.stopRecord();
isRecording = false;
return { ok: true };
default:
return { ok: false, error: 'Unknown message type' };
}
} catch (error) {
console.error('Error handling message:', error);
return { ok: false };
}
};
handleAsyncMessage().then((response) => {
if (sendResponse) {
sendResponse(response);
}
});
return true;
},
);
onMessage('content:stop-recording', () => {
try {
console.log('[content] stopRecording received');
recorder.stopRecord();
isRecording = false;
return { ok: true };
} catch (error) {
console.error('Error handling stop recording:', error);
return { ok: false };
}
});
},
});
+2 -2
View File
@@ -3,8 +3,8 @@ import { HashRouter as Router, Routes, Route } from 'react-router-dom';
import TimestampPage from './pages/TimestampPage';
import RecordeReplayPage from './pages/RecordeReplayPage';
import TestPage from './pages/TestPage';
import Navbar from './components/Navbar';
import RoutePersistence from './components/RoutePersistence';
import Navbar from '../../components/Navbar';
import RoutePersistence from '../../components/RoutePersistence';
import './App.css';
// 路由配置数据
-97
View File
@@ -1,97 +0,0 @@
import { NavLink } from 'react-router-dom';
import { useState, useEffect, ReactNode } from 'react';
import { IconButton } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
interface RouteItem {
path: string;
label: string;
element: ReactNode;
}
interface NavbarProps {
items?: RouteItem[];
}
function Navbar({ items = [] }: NavbarProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [visibleItems, setVisibleItems] = useState(items.length);
// 检测屏幕尺寸变化
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth;
setIsMobile(width < 768);
// 根据屏幕宽度决定显示多少个导航项
if (width >= 768) {
setVisibleItems(items.length); // 大屏幕显示所有
} else if (width >= 480) {
// 中等屏幕:如果导航项超过3个,显示3个,否则显示全部
setVisibleItems(Math.min(3, items.length));
} else {
// 小屏幕:如果导航项超过2个,显示2个,否则显示全部
setVisibleItems(Math.min(2, items.length));
}
};
handleResize(); // 初始调用
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [items.length]); // 添加依赖,如果 items 长度变化也需要重新计算
// 计算哪些导航项应该显示,哪些应该折叠
const visibleNavItems = items.slice(0, visibleItems);
const collapsedNavItems = items.slice(visibleItems);
return (
<nav className="nav">
<div className="nav-content">
<ul className={`nav-list ${isMenuOpen ? 'open' : ''}`}>
{/* 显示的导航项 */}
{visibleNavItems.map((item) => (
<li key={item?.path}>
<NavLink
to={item?.path}
className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')}
onClick={() => isMobile && setIsMenuOpen(false)}
>
{item?.label}
</NavLink>
</li>
))}
{/* 折叠区域逻辑 */}
{collapsedNavItems.length > 0 && (
<li className="nav-collapse-item">
<div className={`nav-collapse-content ${isMenuOpen ? 'show' : ''}`}>
{isMenuOpen &&
collapsedNavItems.map((item) => (
<NavLink
key={item?.path}
to={item?.path}
className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')}
onClick={() => setIsMenuOpen(false)}
>
{item?.label}
</NavLink>
))}
</div>
<IconButton
color="inherit"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label={isMenuOpen ? '收起菜单' : '展开菜单'}
>
{isMenuOpen ? <CloseIcon /> : <MenuIcon />}
</IconButton>
</li>
)}
</ul>
</div>
</nav>
);
}
export default Navbar;
+23 -21
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useMemo } from 'react';
import { AppState } from '../types';
import { messages } from '@/utils/messages';
import { sendMessage, onMessage } from '@/utils/messages';
import { Button } from '@mui/material';
const RecordeReplayPage = () => {
@@ -8,27 +8,22 @@ const RecordeReplayPage = () => {
const isRecording = useMemo(() => status === AppState.RECORDING, [status]);
useEffect(() => {
const handleMessage = (msg: { type: string }) => {
if (msg.type === messages.popup.ready) {
setStatus(AppState.READ);
}
const unlistenStarted = onMessage('popup:started', () => {
console.log('[popup] Received started message');
setStatus(AppState.RECORDING);
});
if (msg.type === messages.popup.to.started) {
console.log('[popup] Received started message');
setStatus(AppState.RECORDING);
}
const unlistenStopped = onMessage('popup:stopped', () => {
setStatus(AppState.READ);
});
if (msg.type === messages.popup.to.stopped) {
setStatus(AppState.READ);
}
};
const unlistenReady = onMessage('popup:ready', () => {
setStatus(AppState.READ);
});
browser.runtime.onMessage.addListener(handleMessage);
browser.runtime
.sendMessage({ type: messages.popup.checkStatus })
sendMessage('popup:check-status', undefined)
.then((res) => {
if (res?.data?.isRecording) {
if (res?.active) {
setStatus(AppState.RECORDING);
} else {
setStatus(AppState.READ);
@@ -38,13 +33,20 @@ const RecordeReplayPage = () => {
console.error(err);
});
return () => browser.runtime.onMessage.removeListener(handleMessage);
return () => {
unlistenStarted();
unlistenStopped();
unlistenReady();
};
}, []);
const toggleRecording = async () => {
try {
const actionType = isRecording ? messages.popup.from.stop : messages.popup.from.start;
await browser.runtime.sendMessage({ type: actionType });
if (isRecording) {
await sendMessage('popup:stop', undefined);
} else {
await sendMessage('popup:start', undefined);
}
} catch (error) {
console.error('Error toggling recording:', error);
setStatus(AppState.READ);
+2 -6
View File
@@ -3,12 +3,8 @@ import { Button } from '@mui/material';
const TestPage = () => {
const handleSeedMessage = async () => {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
tabs.forEach(async (tab) => {
console.log(tab.id);
const length = await sendMessage('getStringLength', 'hello world', tab.id);
console.log('字符串长度:', length);
});
const status = await sendMessage('popup:check-status');
console.log(`[popup]status: ${status}`);
};
return (
<div>
+3 -3
View File
@@ -1,6 +1,6 @@
import { TimestampToDatetime } from '../components/TimestampToDatetime';
import { DatetimeToTimestamp } from '../components/DatetimeToTimestamp';
import { TimestampExecution } from '../components/TimestampExecution';
import { TimestampToDatetime } from '@/components/TimestampToDatetime';
import { DatetimeToTimestamp } from '@/components/DatetimeToTimestamp';
import { TimestampExecution } from '@/components/TimestampExecution';
const TimestampPage = () => {
return (
+2 -1
View File
@@ -48,7 +48,8 @@
"assets/**/*",
"hooks/**/*",
".wxt/types/**/*.ts",
".wxt/types/*.d.ts"
".wxt/types/*.d.ts",
"components"
],
"exclude": ["node_modules", ".wxt", "eslint.config.ts"]
}
+17 -1
View File
@@ -32,7 +32,23 @@ export const messages = {
};
interface ProtocolMap {
getStringLength(data: string): number;
// --- Popup 相关 ---
'popup:start': () => { ok: boolean };
'popup:stop': () => { ok: boolean };
'popup:started': () => void;
'popup:stopped': () => void;
'popup:check-status': () => { active: boolean; startTime?: number };
'popup:ready': () => void;
// --- Content 相关 ---
'content:save-tracke-events': (event) => boolean;
'content:start-recording': () => { ok: boolean; error?: string };
'content:stop-recording': () => { ok: boolean };
'content:check-status': () => boolean;
// --- Offscreen 相关 ---
'offscreen:start-recording': (streamId: string) => void;
'offscreen:stop-recording': () => void;
}
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
+3 -5
View File
@@ -1,18 +1,16 @@
import * as rrweb from 'rrweb';
import { listenerHandler } from '@rrweb/types';
import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record';
import { sendMessage } from '@/utils/messages';
export const createRecorder = () => {
let stopFn: listenerHandler | null = null;
const startRecord = async (msg: string) => {
const startRecord = async () => {
try {
const handler = rrweb.record({
emit(event) {
chrome.runtime.sendMessage({
type: msg,
payload: event,
});
sendMessage('content:save-tracke-events', event);
},
plugins: [getRecordConsolePlugin()],
});