单例 Tab 策略” (Singleton Tab Strategy)

单例 Tab 策略” (Singleton Tab Strategy)

核心逻辑可以概括为:“先问一圈,有人有吗?有就让他弹出来,没人有我就自己造。”

我们需要解决三个技术点:

  1. 全局查询:利用 BroadcastChannel 询问所有窗口(包括自己)。
  2. 窗口激活:如果别的窗口有,那个窗口需要把自己置顶(Focus)。
  3. 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
// src/main/ipc/window-ipc.ts
import { ipcMain, BrowserWindow } from 'electron'

export function setupWindowIPC() {
// ... 之前的 check-cursor-outside-window 代码 ...

// ✅ 新增:激活(置顶/聚焦)发送请求的那个窗口
ipcMain.handle('activate-current-window', (event) => {
// 找到触发事件的窗口
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
// 如果最小化了,先还原
if (win.isMinimized()) win.restore()
// 聚焦窗口 (Windows/Mac/Linux 通用)
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
// src/utils/tab-manager.ts

const TAB_CHANNEL_NAME = 'dockview-tab-manager'

interface TabMessage {
type: 'CHECK_TAB_EXIST' | 'TAB_FOUND'
tabId: string
}

/**
* 尝试查找并激活已存在的 Tab
* @param tabId 要查找的 Tab ID
* @returns Promise<boolean> true 表示找到了并已激活,false 表示没找到
*/
export function findAndActivateTab(tabId: string): Promise<boolean> {
return new Promise((resolve) => {
const channel = new BroadcastChannel(TAB_CHANNEL_NAME)
let isFound = false

// 1. 监听回复
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)

// 2. 发出询问广播
// "嘿!大家注意,有人有点名要找 [tabId],有的请吱一声并跳出来!"
channel.postMessage({
type: 'CHECK_TAB_EXIST',
tabId
})

// 3. 设置超时
// 如果 200ms 内没人回复,就认为不存在
setTimeout(() => {
if (!isFound) {
cleanup()
resolve(false) // 没找到
}
}, 200)
})
}

/**
* 【在 App.vue 初始化时调用】
* 注册监听器:响应别人的查询
* @param dockAPI Dockview 的 API 实例
*/
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

// 1. 检查我这个窗口有没有这个 Tab
const panel = dockAPI.getPanel(targetId)

if (panel) {
console.log(`🙋‍♂️ 我这里有 Tab [${targetId}],正在激活...`)

// 2. 告诉发起者:我找到了!
channel.postMessage({
type: 'TAB_FOUND',
tabId: targetId
})

// 3. 激活 Dockview 里的 Tab (让它显示出来)
panel.api.setActive()

// 4. 调用主进程:把自己这个窗口置顶
// 假设你在 preload 里暴露了 activateWindow
if (window.electronAPI && (window.electronAPI as any).activateWindow) {
await (window.electronAPI as any).activateWindow()
} else {
// 兼容写法,如果 preload 没配好,用 window.focus 试试 (有时候会被浏览器拦截)
window.focus()
}
}
}
}

// 返回清理函数
return () => channel.close()
}

第三步:Preload 补充 (src/preload/index.ts)

为了让 activateWindow 生效,我们需要在 preload 里补上这个 API。

1
2
3
4
5
6
7
8
9
10
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'

const api = {
checkCursorOutside: () => ipcRenderer.invoke('check-cursor-outside-window'),
// ✅ 新增:请求主进程激活当前窗口
activateWindow: () => ipcRenderer.invoke('activate-current-window')
}

// ... exposeInMainWorld 逻辑同前 ...

(记得更新 src/env.d.tswindow 类型定义,加上 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>

场景演示

  1. 场景 A:同窗口切换

    • 现状:主窗口有“设置”Tab,但被“首页”Tab 挡住了。
    • 操作:点击“打开设置”。
    • 流程findAndActivateTab 广播 -> 主窗口收到 -> 发现自己有 -> panel.api.setActive() 切换到设置 Tab -> 激活窗口 -> 回复 TAB_FOUND
    • 结果:主窗口直接切到了设置页。
  2. 场景 B:跨窗口激活

    • 现状:你把“设置”Tab 拖拽到了一个独立的新窗口(子窗口),并且把这个子窗口最小化了。
    • 操作:在主窗口点击“打开设置”。
    • 流程:主窗口广播 -> 子窗口收到 -> 发现自己有 -> 调用 ipc: activate-current-window -> 主进程把子窗口还原并置顶 -> 子窗口回复 TAB_FOUND
    • 结果:原本最小化的子窗口弹到了屏幕最前面,显示着设置页。主窗口不会创建重复的 Tab。
  3. 场景 C:不存在

    • 现状:所有窗口都关闭了“设置”。
    • 操作:点击“打开设置”。
    • 流程:广播 -> 200ms 内没人回 -> 返回 false -> 执行 addPanel
    • 结果:在当前窗口新开一个设置 Tab。

这个方案利用了你现有的 BroadcastChannel 架构,非常轻量且高效。

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器