单例 Tab 策略” (Singleton Tab Strategy)
核心逻辑可以概括为:“先问一圈,有人有吗?有就让他弹出来,没人有我就自己造。”
我们需要解决三个技术点:
- 全局查询:利用
BroadcastChannel 询问所有窗口(包括自己)。
- 窗口激活:如果别的窗口有,那个窗口需要把自己置顶(Focus)。
- Tab 激活:那个窗口需要把对应的 Tab 设为当前活动 Tab。
下面是系统性的实现方案。
第一步:主进程支持“窗口激活” (src/main/ipc/window-ipc.ts)
前端渲染进程不能直接强制操作系统把窗口置顶,这必须由主进程(Main Process)来做。
修改 src/main/ipc/window-ipc.ts,增加一个让窗口“激活自己”的 IPC 接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import { ipcMain, BrowserWindow } from 'electron'
export function setupWindowIPC() {
ipcMain.handle('activate-current-window', (event) => { const win = BrowserWindow.fromWebContents(event.sender) if (win) { if (win.isMinimized()) win.restore() win.show() win.focus() return true } return false }) }
|
(别忘了在 src/preload/index.ts 里把这个方法暴露给前端,类似于之前的 checkCursorOutside)
第二步:封装“Tab 查询工具” (src/utils/tab-manager.ts)
我们需要一个工具函数,利用 BroadcastChannel 发出询问,并等待回复。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
|
const TAB_CHANNEL_NAME = 'dockview-tab-manager'
interface TabMessage { type: 'CHECK_TAB_EXIST' | 'TAB_FOUND' tabId: string }
export function findAndActivateTab(tabId: string): Promise<boolean> { return new Promise((resolve) => { const channel = new BroadcastChannel(TAB_CHANNEL_NAME) let isFound = false const handler = (event: MessageEvent<TabMessage>) => { if (event.data.type === 'TAB_FOUND' && event.data.tabId === tabId) { isFound = true cleanup() resolve(true) } }
const cleanup = () => { channel.removeEventListener('message', handler) channel.close() }
channel.addEventListener('message', handler)
channel.postMessage({ type: 'CHECK_TAB_EXIST', tabId })
setTimeout(() => { if (!isFound) { cleanup() resolve(false) } }, 200) }) }
export function registerTabManager(dockAPI: any) { const channel = new BroadcastChannel(TAB_CHANNEL_NAME)
channel.onmessage = async (event: MessageEvent<TabMessage>) => { if (event.data.type === 'CHECK_TAB_EXIST') { const targetId = event.data.tabId const panel = dockAPI.getPanel(targetId)
if (panel) { console.log(`🙋♂️ 我这里有 Tab [${targetId}],正在激活...`)
channel.postMessage({ type: 'TAB_FOUND', tabId: targetId })
panel.api.setActive()
if (window.electronAPI && (window.electronAPI as any).activateWindow) { await (window.electronAPI as any).activateWindow() } else { window.focus() } } } }
return () => channel.close() }
|
第三步:Preload 补充 (src/preload/index.ts)
为了让 activateWindow 生效,我们需要在 preload 里补上这个 API。
1 2 3 4 5 6 7 8 9 10
| import { contextBridge, ipcRenderer } from 'electron'
const api = { checkCursorOutside: () => ipcRenderer.invoke('check-cursor-outside-window'), activateWindow: () => ipcRenderer.invoke('activate-current-window') }
|
(记得更新 src/env.d.ts 或 window 类型定义,加上 activateWindow)
第四步:在 App.vue 注册监听 (src/App.vue)
所有窗口(主窗口和子窗口)都运行 App.vue,所以它们都需要“监听别人的查询”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <!-- src/App.vue --> <script setup lang="ts"> import { onUnmounted } from 'vue' import { registerTabManager } from './utils/tab-manager' // 引入刚才写的工具
// ... 你的其他代码 ...
let cleanupTabManager: Function | null = null
const onReady = (event: DockviewReadyEvent) => { const api = event.api // ✅ 1. 注册全局 Tab 管理器 // 一旦 dockview 准备好,我就开始监听 "有没有人找 Tab" cleanupTabManager = registerTabManager(api)
// ... 原有的逻辑 ... }
onUnmounted(() => { // 销毁时清理监听 if (cleanupTabManager) cleanupTabManager() }) </script>
|
第五步:使用功能 (在创建 Tab 的地方)
假设你有一个侧边栏菜单或者按钮用来打开 Tab,现在把 dockAPI.addPanel 替换为下面的逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| <!-- 假设这是你的 Sidebar.vue 或者某个业务组件 --> <template> <button @click="openSettings">打开设置 (唯一)</button> </template>
<script setup lang="ts"> import { findAndActivateTab } from '../utils/tab-manager'
// 假设你能获取到 dockAPI (通过 Provide/Inject 或者 Store) // 如果是在 App.vue 内部直接调用就更简单了 const props = defineProps<{ dockAPI: any }>()
const openSettings = async () => { const tabId = 'settings_panel' const tabTitle = '设置中心'
// 1. ✨ 核心步骤:先问一圈 const exists = await findAndActivateTab(tabId)
if (exists) { console.log('✅ Tab 已存在于某个窗口,已自动激活,无需创建。') return // 直接结束 }
// 2. 如果没找到,就在当前窗口创建 console.log('✨ Tab 不存在,正在当前窗口创建...') props.dockAPI.addPanel({ id: tabId, component: 'default', // 替换为你的组件名 tabComponent: 'myTab', params: { title: tabTitle }, title: tabTitle }) } </script>
|
场景演示
场景 A:同窗口切换
- 现状:主窗口有“设置”Tab,但被“首页”Tab 挡住了。
- 操作:点击“打开设置”。
- 流程:
findAndActivateTab 广播 -> 主窗口收到 -> 发现自己有 -> panel.api.setActive() 切换到设置 Tab -> 激活窗口 -> 回复 TAB_FOUND。
- 结果:主窗口直接切到了设置页。
场景 B:跨窗口激活
- 现状:你把“设置”Tab 拖拽到了一个独立的新窗口(子窗口),并且把这个子窗口最小化了。
- 操作:在主窗口点击“打开设置”。
- 流程:主窗口广播 -> 子窗口收到 -> 发现自己有 -> 调用
ipc: activate-current-window -> 主进程把子窗口还原并置顶 -> 子窗口回复 TAB_FOUND。
- 结果:原本最小化的子窗口弹到了屏幕最前面,显示着设置页。主窗口不会创建重复的 Tab。
场景 C:不存在
- 现状:所有窗口都关闭了“设置”。
- 操作:点击“打开设置”。
- 流程:广播 -> 200ms 内没人回 -> 返回
false -> 执行 addPanel。
- 结果:在当前窗口新开一个设置 Tab。
这个方案利用了你现有的 BroadcastChannel 架构,非常轻量且高效。
Prev: BroadcastChannel 握手机制的方法
Next: 监听窗口创建完成