30 KiB
问题2: 我认为还有需要讨论的场景,开启录制之后,需要考虑以下的场景
- 首先应该是在当前激活的tab中注入内容脚本,但是注意防重,这里的防重策略考虑一下
- 当前的tab在录制中的情况下,如果刷新,怎么续作,这里要考虑清楚
- 假设开启录制的时候,当前的tab是不支持注入脚本的,是否需要提示一下。默认场景是会注入脚本,然后工作流的开始节点是打开当前标签页的url,如果当前是不可注入的,这里是不是就不需要记录为开始节点了,只需要提示一下,然后等待用户切换到另外的可以注入的tab
- 因为是动态注入,而非一开始就全部注入,所以切换到新的标签页的时候,就需要注入内容脚本,并且注意防止重复注入
- 所有已经注入监听脚本的tab,在收到停止录制的消息后,应该移除掉监听器,假设此时又开始录制,应该不用重复注入,但是要重新开始监听了
- 因为录制中的页面上需要注入一个录制浮窗,这块的状态管理(展示/隐藏等)是否也考虑清楚
- 除了上面的,你全面思考一下还有没有要考虑的场景,尽量覆盖全面
基于我们上面的讨论和我刚刚说的场景,我觉得有必要画一个清晰的数据/状态流图来辅助分析
回复2: 详细场景分析与解决方案
场景1:防重注入策略
// recording/content-injection.ts
class ContentInjectionManager { // 维护已注入tab的状态 private injectedTabs = new Map<number, InjectionState>();
interface InjectionState {
tabId: number;
injectedAt: number;
frames: Set<number>; // 已注入的frameId
listening: boolean; // 是否正在监听
}
async injectToTab(tabId: number): Promise<InjectResult> {
// 1. 检查是否已注入
if (this.injectedTabs.has(tabId)) {
const state = this.injectedTabs.get(tabId)!;
// 如果已注入但不监听(上次录制结束),只需发送START命令
if (!state.listening) {
await this.sendCommand(tabId, 'START');
state.listening = true;
return { success: true, reused: true };
}
// 如果已注入且正在监听,直接返回
return { success: true, alreadyInjected: true };
}
// 2. 检查tab是否可注入
const canInject = await this.checkInjectability(tabId);
if (!canInject.success) {
return {
success: false,
error: 'CANNOT_INJECT',
reason: canInject.reason // 'special-page' | 'permission-denied' | ...
};
}
// 3. 获取所有frame
const frames = await chrome.webNavigation.getAllFrames({ tabId });
// 4. 使用ping机制检测哪些frame需要注入
const needInjection: number[] = [];
for (const frame of frames) {
const hasScript = await this.pingFrame(tabId, frame.frameId);
if (!hasScript) {
needInjection.push(frame.frameId);
}
}
// 5. 注入脚本
if (needInjection.length > 0) {
await chrome.scripting.executeScript({
target: { tabId, frameIds: needInjection },
files: ['inject-scripts/recorder.js'],
world: 'ISOLATED'
});
}
// 6. 记录注入状态
this.injectedTabs.set(tabId, {
tabId,
injectedAt: Date.now(),
frames: new Set(frames.map(f => f.frameId)),
listening: true
});
return { success: true, injectedFrames: needInjection.length };
}
// Ping检测脚本是否已存在
private async pingFrame(tabId: number, frameId: number): Promise<boolean> {
try {
const res = await chrome.tabs.sendMessage(
tabId,
{ action: 'RR_PING' },
{ frameId }
);
return res?.status === 'PONG';
} catch {
return false;
}
}
// 检测可注入性
private async checkInjectability(tabId: number): Promise<CheckResult> {
try {
const tab = await chrome.tabs.get(tabId);
const url = tab.url || '';
// 特殊页面不可注入
if (url.startsWith('chrome://') ||
url.startsWith('edge://') ||
url.startsWith('chrome-extension://') ||
url.startsWith('about:') ||
url === '') {
return {
success: false,
reason: 'special-page',
message: '此页面不支持录制(浏览器特殊页面)'
};
}
// Chrome Web Store
if (url.includes('chrome.google.com/webstore')) {
return {
success: false,
reason: 'webstore',
message: 'Chrome商店页面不支持录制'
};
}
return { success: true };
} catch (e) {
return {
success: false,
reason: 'error',
message: e.message
};
}
}
}
场景2:刷新续作(关键)
// recording/recorder-manager.ts
class RecorderManager { constructor() { // 监听导航事件 - 刷新续作 chrome.webNavigation.onCommitted.addListener((details) => { this.handleNavigation(details); });
// 监听tab加载完成
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.status === 'complete') {
this.handleTabLoadComplete(tabId);
}
});
}
private async handleNavigation(details: NavDetails) {
// 只处理顶级frame的导航
if (details.frameId !== 0) return;
// 只在录制中处理
if (this.state !== RecordingState.Recording) return;
const tabId = details.tabId;
// 1. 记录导航事件
if (details.transitionType === 'reload') {
this.handleRecordedEvent({
type: 'NAVIGATE',
url: details.url,
meta: { reason: 'refresh' }
});
}
// 2. 标记该tab需要重新注入(页面刷新会清空所有脚本)
this.injectionManager.markNeedsReinject(tabId);
}
private async handleTabLoadComplete(tabId: number) {
if (this.state !== RecordingState.Recording) return;
// 刷新后页面加载完成,重新注入并恢复监听
const needsReinject = this.injectionManager.needsReinject(tabId);
if (needsReinject) {
await this.injectionManager.reinjectAndResume(tabId);
}
}
}
// content-injection.ts 添加 class ContentInjectionManager { private needsReinjectSet = new Set();
markNeedsReinject(tabId: number) {
this.needsReinjectSet.add(tabId);
// 清除旧的注入状态
this.injectedTabs.delete(tabId);
}
needsReinject(tabId: number): boolean {
return this.needsReinjectSet.has(tabId);
}
async reinjectAndResume(tabId: number) {
this.needsReinjectSet.delete(tabId);
// 重新注入
const result = await this.injectToTab(tabId);
if (result.success) {
// 发送RESUME命令(不是START,避免重置状态)
await this.sendCommand(tabId, 'RESUME');
}
}
}
场景3:不可注入tab提示
class RecorderManager { async startRecording(meta?: Partial): Promise { // ... 初始化flow等
// 获取当前激活tab
const tab = await this.getActiveTab();
// 尝试注入
const injectResult = await this.injectionManager.injectToTab(tab.id);
if (!injectResult.success) {
// 不可注入的特殊页面
this.state = RecordingState.WaitingForValidTab;
await this.persistState();
// 不记录当前页面为起始URL
// 显示提示(通过popup或notification)
await this.showNotification({
type: 'warning',
message:
当前页面不支持录制(${injectResult.reason})\n请切换到普通网页继续,
duration: 0 // 持续显示直到切换
});
// 监听tab切换,等待用户切换到可注入的tab
this.waitForValidTab();
return {
success: true,
warning: 'waiting-for-valid-tab',
message: injectResult.message
};
}
// 可注入,记录初始URL
this.currentFlow.steps.push({
id: `step_${Date.now()}`,
type: 'navigate',
url: tab.url
});
return { success: true };
}
private async waitForValidTab() {
const listener = async (activeInfo: chrome.tabs.TabActiveInfo) => {
if (this.state !== RecordingState.WaitingForValidTab) {
chrome.tabs.onActivated.removeListener(listener);
return;
}
const injectResult = await
this.injectionManager.injectToTab(activeInfo.tabId);
if (injectResult.success) {
// 成功注入,切换到正式录制状态
this.state = RecordingState.Recording;
this.activeTabId = activeInfo.tabId;
await this.persistState();
// 记录第一个有效页面
const tab = await chrome.tabs.get(activeInfo.tabId);
this.currentFlow.steps.push({
id: `step_${Date.now()}`,
type: 'navigate',
url: tab.url
});
// 清除提示
await this.clearNotification();
chrome.tabs.onActivated.removeListener(listener);
}
};
chrome.tabs.onActivated.addListener(listener);
}
}
场景4:切换tab动态注入
class RecorderManager { constructor() { // 监听tab激活 chrome.tabs.onActivated.addListener((activeInfo) => { this.handleTabActivated(activeInfo); }); }
private async handleTabActivated(activeInfo: chrome.tabs.TabActiveInfo) {
if (this.state !== RecordingState.Recording) return;
const tabId = activeInfo.tabId;
// 1. 尝试注入(如果已注入会自动跳过)
const injectResult = await this.injectionManager.injectToTab(tabId);
if (!injectResult.success) {
// 不可注入的tab,显示提示但不中断录制
await this.showNotification({
type: 'info',
message: '当前tab不支持录制,请切换回其他页面继续',
duration: 3000
});
return;
}
// 2. 记录tab切换事件
const tab = await chrome.tabs.get(tabId);
this.handleRecordedEvent({
type: 'TAB_SWITCH',
url: tab.url,
tabId: tabId
});
// 3. 如果是新注入的,发送START命令
// 如果是复用的,发送RESUME命令
// (这个逻辑在injectToTab内部已处理)
}
}
场景5:停止录制的清理与重启
// inject-scripts/recorder.js
(function() { let scriptInstalled = true; // 脚本已安装(不卸载) let isListening = false; // 是否正在监听
function startListening() {
if (isListening) return;
isListening = true;
attachListeners();
showOverlay();
}
function stopListening() {
if (!isListening) return;
isListening = false;
detachListeners();
hideOverlay();
}
chrome.runtime.onMessage.addListener((msg, sender, respond) => {
switch(msg.cmd) {
case 'START':
startListening();
respond({ success: true });
break;
case 'STOP':
stopListening();
respond({ success: true });
break;
case 'RESUME':
startListening(); // 复用startListening
respond({ success: true });
break;
case 'PAUSE':
// 暂停:保持监听器但不上报事件
isPaused = true;
updateOverlay('paused');
respond({ success: true });
break;
}
});
// Ping响应(用于检测脚本是否存在)
chrome.runtime.onMessage.addListener((msg, sender, respond) => {
if (msg.action === 'RR_PING') {
respond({
status: 'PONG',
listening: isListening,
installed: scriptInstalled
});
return false;
}
});
})();
场景6:浮窗状态管理
// inject-scripts/recorder.js - 浮窗部分
class RecorderOverlay { private overlay: HTMLElement | null = null; private state: 'recording' | 'paused' | 'hidden' = 'hidden';
show(initialState: 'recording' | 'paused' = 'recording') {
// 只在main frame显示
if (window !== window.top) return;
this.state = initialState;
if (!this.overlay) {
this.create();
}
this.overlay!.style.display = 'block';
this.updateUI();
}
hide() {
this.state = 'hidden';
if (this.overlay) {
this.overlay.style.display = 'none';
}
}
updateState(newState: 'recording' | 'paused') {
this.state = newState;
this.updateUI();
}
private updateUI() {
const badge = this.overlay?.querySelector('#rr-status-badge');
const pauseBtn = this.overlay?.querySelector('#rr-pause-btn');
if (this.state === 'recording') {
badge.textContent = '● 录制中';
badge.style.color = '#ef4444';
pauseBtn.textContent = '暂停';
} else if (this.state === 'paused') {
badge.textContent = '⏸ 已暂停';
badge.style.color = '#f59e0b';
pauseBtn.textContent = '继续';
}
}
private create() {
// ... 创建DOM
// 停止按钮
stopBtn.onclick = () => {
// 发送停止消息给后台
chrome.runtime.sendMessage({ type: 'RR_USER_STOP' });
this.hide();
};
// 暂停按钮
pauseBtn.onclick = () => {
if (this.state === 'recording') {
chrome.runtime.sendMessage({ type: 'RR_USER_PAUSE' });
this.updateState('paused');
} else {
chrome.runtime.sendMessage({ type: 'RR_USER_RESUME' });
this.updateState('recording');
}
};
}
}
const overlay = new RecorderOverlay();
// 命令响应 chrome.runtime.onMessage.addListener((msg) => { switch(msg.cmd) { case 'START': overlay.show('recording'); break; case 'STOP': overlay.hide(); break; case 'RESUME': overlay.show('recording'); break; case 'PAUSE': overlay.updateState('paused'); break; } });
更多需要考虑的场景
- Service Worker休眠恢复
// recording/recorder-manager.ts
class RecorderManager { private initialized = false;
async init() {
if (this.initialized) return;
// 从storage恢复状态
const savedState = await this.stateManager.restore();
if (savedState && savedState.state === RecordingState.Recording) {
// Service Worker休眠后恢复
console.log('Recovering recording state from storage');
// 恢复核心状态
this.state = savedState.state;
this.activeTabId = savedState.activeTabId;
// 尝试恢复flow(如果有中间保存)
this.currentFlow = await this.loadPartialFlow(savedState.flowId);
// 重新附加监听器
this.attachBrowserListeners();
// 向所有已注入tab发送RESUME
await this.broadcastToInjectedTabs('RESUME');
}
this.initialized = true;
}
// 定期保存中间状态(防止丢失)
private startAutosave() {
setInterval(async () => {
if (this.state === RecordingState.Recording && this.currentFlow) {
await this.savePartialFlow(this.currentFlow);
}
}, 5000); // 每5秒保存
}
}
- Tab关闭处理
class RecorderManager { constructor() { chrome.tabs.onRemoved.addListener((tabId) => { this.handleTabClosed(tabId); }); }
private handleTabClosed(tabId: number) {
if (this.state !== RecordingState.Recording) return;
// 记录关闭tab事件
this.handleRecordedEvent({
type: 'TAB_CLOSE',
tabId: tabId
});
// 清理注入状态
this.injectionManager.removeTab(tabId);
// 如果关闭的是当前活跃tab,检查是否还有其他tab
if (tabId === this.activeTabId) {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs.length > 0) {
this.activeTabId = tabs[0].id;
} else {
// 所有tab都关了,自动停止录制
this.stopRecording();
}
});
}
}
}
- 新打开Tab
class RecorderManager { constructor() { chrome.tabs.onCreated.addListener((tab) => { this.handleTabCreated(tab); }); }
private handleTabCreated(tab: chrome.tabs.Tab) {
if (this.state !== RecordingState.Recording) return;
// 记录打开新tab(如果是用户主动打开)
this.handleRecordedEvent({
type: 'TAB_OPEN',
url: tab.url || 'about:blank',
tabId: tab.id
});
}
}
- 扩展更新/重载
// background/index.ts
chrome.runtime.onInstalled.addListener(async (details) => { if (details.reason === 'update' || details.reason === 'install') { // 扩展更新,检查是否有未完成的录制 const savedState = await RecordingStateManager.restore();
if (savedState && savedState.state === RecordingState.Recording) {
// 提示用户录制中断
await chrome.notifications.create({
type: 'basic',
iconUrl: 'icon.png',
title: '录制已中断',
message: '扩展更新导致录制中断,部分数据已保存',
buttons: [
{ title: '查看已保存内容' },
{ title: '忽略' }
]
});
// 清理状态
await RecordingStateManager.clear();
}
}
});
完整状态流转图
┌─────────────────────────────────────────────────────────────────────┐ │ 录制系统状态机 │ └─────────────────────────────────────────────────────────────────────┘
┌──────────────┐ │ Idle │ 初始状态/录制结束 │ (空闲) │ └──────┬───────┘ │ │ 用户点击"开始录制" ▼ ┌──────────────────────────────────────────────────────────┐ │ 步骤1: 初始化 │ │ - 创建Flow对象(单一数据源) │ │ - 状态 → Initializing │ │ - 持久化状态到storage │ └──────┬───────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────┐ │ 步骤2: 检查当前Tab │ │ - 获取activeTab │ │ - 检查可注入性 │ └──────┬───────────────────────────┬───────────────────────┘ │ │ │ 可注入 │ 不可注入 ▼ ▼ ┌──────────────┐ ┌────────────────────┐ │ 步骤3a: │ │ 步骤3b: │ │ 注入脚本 │ │ 等待有效Tab │ │ │ │ - WaitingForValidTab│ │ - Ping检测 │ │ - 显示提示 │ │ - 注入未安装 │ │ - 监听tab切换 │ │ 的frame │ │ - 不记录初始URL │ └──────┬───────┘ └────────┬───────────┘ │ │ │ │ tab切换到可注入页面 │ ▼ │ ┌──────────────┐ │ │ 重新尝试注入 │ │ └──────┬───────┘ │ │ │◄──────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────┐ │ 步骤4: 开始录制 │ │ - 状态 → Recording │ │ - 记录初始URL (navigate步骤) │ │ - 向content发送START命令 │ │ - content显示浮窗 │ │ - 附加浏览器监听器(tab切换/导航/关闭等) │ └──────┬───────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────┐ │ Recording State │ │ (录制进行中) │ └──────┬───────────────────────────────────────────────────┘ │ │ 运行中的各种事件 │ ├──────────────────────────────────────────────────┐ │ │ ▼ ▼ ┌──────────────────┐ ┌────────────────────┐ │ 事件A: 页面交互 │ │ 事件B: 浏览器操作 │ │ │ │ │ │ Content Script │ │ Background Monitor │ │ ┌──────────────┐ │ │ ┌────────────────┐ │ │ │ 监听事件: │ │ │ │ 监听: │ │ │ │ - click │ │ │ │ - tab切换 │ │ │ │ - fill │ │ │ │ - tab关闭 │ │ │ │ - keypress │ │ │ │ - tab新建 │ │ │ │ - scroll │ │ │ │ - 导航 │ │ │ │ - ... │ │ │ │ - 刷新 │ │ │ └──────┬───────┘ │ │ └────────┬───────┘ │ │ │ │ │ │ │ │ │ iframe? │ │ │ │ │ ├─────────┤ │ │ │ │ │ Yes No │ │ │ │ │ ▼ ▼ │ │ ▼ │ │ ┌────┐ ┌────┐│ │ ┌────────────────┐│ │ │→MF │ │→BG ││ │ │直接处理 ││ │ └──┬─┘ └──┬─┘│ │ │RecorderManager ││ │ │ │ │ │ │.handleEvent() ││ │ │ │ │ │ └────────┬───────┘│ │ └──┬───┘ │ │ │ │ └──────────┼──────┘ └───────────┼─────────┘ │ │ │ 上报事件 │ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ RecorderManager.handleRecordedEvent() │ │ (单一数据写入点) │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ 1. 检查状态 (必须是Recording) │ │ │ │ 2. 转换事件 → Step │ │ │ │ 3. currentFlow.steps.push(step) │ │ │ │ 4. 更新meta.updatedAt │ │ │ │ 5. 自动保存(每5s或每N个step) │ │ │ └────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ │ │ 继续监听... │ │ ┌───┴──────────────────────────────────────────────┐ │ │ ▼ ▼ ┌──────────────┐ ┌────────────────┐ │ 事件C: │ │ 事件D: │ │ 页面刷新 │ │ Tab切换 │ └──────┬───────┘ └────────┬───────┘ │ │ ▼ ▼ ┌──────────────────────────────────────┐ ┌────────────────────────┐ │ 刷新处理流程: │ │ Tab切换处理: │ │ │ │ │ │ 1. onCommitted监听到reload │ │ 1. onActivated监听到 │ │ 2. 记录navigate事件(reason=refresh) │ │ 2. 检查tab可注入性 │ │ 3. markNeedsReinject(tabId) │ │ 3. 动态注入(防重) │ │ 4. 等待页面加载complete │ │ 4. 发送START/RESUME │ │ 5. reinjectAndResume() │ │ 5. 记录TAB_SWITCH事件 │ │ - 重新注入所有frame │ │ │ │ - 发送RESUME命令 │ │ │ │ - content恢复监听+显示浮窗 │ │ │ └──────────────────────────────────────┘ └────────────────────────┘ │ │ └───────────────┬───────────────────────────────────┘ │ 继续录制... │ ▼ ┌──────────────────────────────────────────────────────────┐ │ 触发条件: │ │ - 用户点击浮窗"停止" │ │ - Popup点击"停止录制" │ │ - 快捷键 │ │ - 所有tab关闭 │ └──────┬───────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────┐ │ 停止录制流程: │ │ │ │ 1. 暂存currentFlow │ │ 2. 状态 → Stopping │ │ 3. 广播STOP命令到所有注入tab │ │ - content停止监听 │ │ - content隐藏浮窗 │ │ - 保持脚本已安装状态(不卸载) │ │ 4. 清理: │ │ - currentFlow = null │ │ - activeTabId = null │ │ - injectionManager.markAllStopped() │ │ 5. 保存Flow到storage │ │ 6. 状态 → Idle │ │ 7. 持久化状态 │ │ 8. 通知Popup录制完成 │ └──────┬───────────────────────────────────────────────────┘ │ ▼ ┌──────────────┐ │ Idle │ ◄─── 可以再次开始录制 └──────────────┘
┌─────────────────────────────────────────────────────────────────────┐ │ 特殊场景处理 │ └─────────────────────────────────────────────────────────────────────┘
┌──────────────────────┐ │ Service Worker休眠 │ └──────┬───────────────┘ │ ▼ ┌────────────────────────────────────────┐ │ 恢复流程: │ │ 1. background唤醒 │ │ 2. init()从storage恢复状态 │ │ 3. 如果是Recording: │ │ - 恢复currentFlow(中间保存) │ │ - 重新附加监听器 │ │ - 向所有tab广播RESUME │ │ 4. 继续录制 │ └────────────────────────────────────────┘
┌──────────────────────┐ │ Tab关闭 │ └──────┬───────────────┘ │ ▼ ┌────────────────────────────────────────┐ │ 1. 记录TAB_CLOSE事件 │ │ 2. 清理注入状态 │ │ 3. 如果是activeTab: │ │ - 切换到其他tab │ │ - 如果没有tab了 → 停止录制 │ └────────────────────────────────────────┘
┌──────────────────────┐ │ 扩展更新/重载 │ └──────┬───────────────┘ │ ▼ ┌────────────────────────────────────────┐ │ 1. onInstalled触发 │ │ 2. 检查storage中的录制状态 │ │ 3. 如果有未完成的录制: │ │ - 通知用户中断 │ │ - 保存部分结果 │ │ - 清理状态 │ └────────────────────────────────────────┘
┌──────────────────────┐ │ 权限不足页面 │ │ (chrome://, etc) │ └──────┬───────────────┘ │ ▼ ┌────────────────────────────────────────┐ │ 1. 注入失败 │ │ 2. 显示提示 │ │ 3. 继续监听tab切换 │ │ 4. 不中断整体录制 │ └────────────────────────────────────────┘
核心设计原则总结
| 原则 | 实现 |
|---|---|
| 单一数据源 | 后台 RecorderManager.currentFlow 是唯一真相 |
| 防重注入 | Ping机制 + Map维护注入状态 |
| 生命周期分离 | 脚本安装 ≠ 监听状态,停止录制不卸载脚本 |
| 刷新续作 | 监听导航 + 自动重注入 + RESUME命令 |
| 容错降级 | 不可注入tab不中断整体录制,只提示 |
| 状态持久化 | 关键状态写storage,防止SW休眠丢失 |
| 浮窗同步 | 状态变化立即通知content更新UI |
| 清晰消息 | 命令标准化(START/STOP/RESUME/PAUSE) |