项目文件夹

文件
2025-10-20 10:01:34 +08:00

30 KiB

问题2: 我认为还有需要讨论的场景,开启录制之后,需要考虑以下的场景

  1. 首先应该是在当前激活的tab中注入内容脚本,但是注意防重,这里的防重策略考虑一下
  2. 当前的tab在录制中的情况下,如果刷新,怎么续作,这里要考虑清楚
  3. 假设开启录制的时候,当前的tab是不支持注入脚本的,是否需要提示一下。默认场景是会注入脚本,然后工作流的开始节点是打开当前标签页的url,如果当前是不可注入的,这里是不是就不需要记录为开始节点了,只需要提示一下,然后等待用户切换到另外的可以注入的tab
  4. 因为是动态注入,而非一开始就全部注入,所以切换到新的标签页的时候,就需要注入内容脚本,并且注意防止重复注入
  5. 所有已经注入监听脚本的tab,在收到停止录制的消息后,应该移除掉监听器,假设此时又开始录制,应该不用重复注入,但是要重新开始监听了
  6. 因为录制中的页面上需要注入一个录制浮窗,这块的状态管理(展示/隐藏等)是否也考虑清楚
  7. 除了上面的,你全面思考一下还有没有要考虑的场景,尽量覆盖全面

基于我们上面的讨论和我刚刚说的场景,我觉得有必要画一个清晰的数据/状态流图来辅助分析

回复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; } });

更多需要考虑的场景

  1. 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秒保存
}

}

  1. 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();
      }
    });
  }
}

}

  1. 新打开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
  });
}

}

  1. 扩展更新/重载

// 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