electron多窗口状态同步 插件开发pinia-plugin-electron-share

问题

如何开发一个electron多窗口状态同步的插件,让Dockview弹出窗之间的数据同步?

设计方案

  1. 零主进程依赖:基于 BroadcastChannel,不需要修改 Electron 主进程代码,纯前端实现。
  2. 自动水合(Hydration):新窗口(Dockview 弹出窗)打开时,会自动向老窗口“要”数据,解决初始化空白问题。
  3. 防死循环:内置互斥锁,防止 A -> B -> A 的无限更新。
  4. 按需同步:不是所有 Store 都会同步,你可以在定义 Store 时通过 share: true 开启。

1. 核心插件代码

请在项目中新建文件 src/plugins/pinia-plugin-electron-share.ts

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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import { PiniaPluginContext } from 'pinia';

// 扩展 Pinia 的定义,以便在使用 defineStore 时有代码提示
declare module 'pinia' {
export interface DefineStoreOptionsBase<S, Store> {
/**
* 是否在 Electron 多窗口之间同步该 Store 的状态
* @default false
*/
share?: boolean;
}
}

// 消息类型定义
type SyncPayload = {
type: 'UPDATE' | 'REQUEST_INIT' | 'RESPONSE_INIT';
storeId: string;
data?: any; // State object
timestamp: number;
};

// 频道名称
const CHANNEL_NAME = 'electron-pinia-share-channel';

export function piniaPluginElectronShare({ store, options }: PiniaPluginContext) {
// 1. 检查当前 Store 是否开启了共享
if (!options.share) {
return;
}

// 2. 创建广播频道 (所有窗口共用同一个频道名)
const channel = new BroadcastChannel(CHANNEL_NAME);

// 内部状态锁:防止死循环 (收到消息更新时,不再广播出去)
let isExternalUpdate = false;

/**
* 核心逻辑:处理接收到的消息
*/
channel.onmessage = (event: MessageEvent<SyncPayload>) => {
const { type, storeId, data, timestamp } = event.data;

// 只处理当前 Store 的消息
if (storeId !== store.$id) return;

switch (type) {
// 场景 A: 收到其他窗口的状态更新
case 'UPDATE':
if (!isExternalUpdate) {
isExternalUpdate = true;
store.$patch(data);
// 这里稍微延迟一点释放锁,确保 Vue 响应式系统处理完毕
setTimeout(() => { isExternalUpdate = false; }, 0);
}
break;

// 场景 B: 新窗口刚打开,请求初始化数据 (我是老窗口,我有数据)
case 'REQUEST_INIT':
// 为了避免多个老窗口同时回复,这里可以做一个简单的随机延迟,或者由于是替换操作,多次回复也无所谓
// 直接发送当前状态给新窗口
channel.postMessage({
type: 'RESPONSE_INIT',
storeId: store.$id,
data: JSON.parse(JSON.stringify(store.$state)),
timestamp: Date.now(),
});
break;

// 场景 C: 我是新窗口,收到了初始化数据
case 'RESPONSE_INIT':
isExternalUpdate = true;
store.$patch(data);
setTimeout(() => { isExternalUpdate = false; }, 0);
break;
}
};

/**
* 核心逻辑:监听当前 Store 变化并广播
*/
store.$subscribe((mutation, state) => {
// 如果是由外部消息触发的更新,则不广播,断绝死循环
if (isExternalUpdate) return;

// 序列化 State (去除 Vue 的响应式代理,转为纯 JSON)
// 注意:如果有 Date/Map/Set 需要特殊处理,这里假设是基础 JSON
const rawState = JSON.parse(JSON.stringify(state));

channel.postMessage({
type: 'UPDATE',
storeId: store.$id,
data: rawState,
timestamp: Date.now(),
});
});

/**
* 初始化逻辑:
* 插件加载(Store 创建)时,如果是新窗口,可能数据是空的。
* 发送一个 "REQUEST_INIT" 广播,问问有没有其他窗口存活着并有数据。
*/
// 延时极短的时间,确保 BroadcastChannel 已经准备好,且不会阻塞主线程
setTimeout(() => {
channel.postMessage({
type: 'REQUEST_INIT',
storeId: store.$id,
timestamp: Date.now(),
});
}, 50);

// 组件销毁时关闭频道 (可选,Pinia Store 通常生命周期伴随整个应用,不关也没事)
// return () => { channel.close(); }
}

2. 在 Pinia 中注册插件

修改你的 src/store/index.ts (或者你创建 pinia 的地方)。

1
2
3
4
5
6
7
8
9
import { createPinia } from 'pinia';
import { piniaPluginElectronShare } from '../plugins/pinia-plugin-electron-share'; // 引入上面写的文件

const pinia = createPinia();

// 注册插件
pinia.use(piniaPluginElectronShare);

export default pinia;

3. 如何在 Store 中使用

这步最关键。你需要在那些“需要在 Dockview 窗口间同步”的 Store 里,加上 share: true

例如 src/store/modules/user.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
state: () => ({
token: '',
userInfo: {
name: 'Guest',
age: 0
},
// 假设这是 Dockview 的布局配置,也需要同步
layoutConfig: {}
}),

actions: {
updateName(name: string) {
this.userInfo.name = name;
}
},

// 开启多窗口同步
share: true
});

如果是 Setup Store 写法:

1
2
3
4
5
6
7
8
9
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
function increment() { count.value++ }

return { count, increment };
}, {
// Setup Store 的配置项在这里
share: true
});

4. 验证与调试方法

我建议你按照以下步骤验证方案是否生效:

  1. 启动应用:打开主窗口。
  2. 修改数据:在主窗口修改 useUserStore 中的数据(比如登录,或者改个名字)。
  3. 弹出窗口 (Dockview):拖拽 Tab 形成一个新的弹出窗口。
    • 预期:弹出窗口应该立即显示刚才修改过的数据(这是 REQUEST_INIT 逻辑在起作用)。
  4. 双向同步测试
    • 弹出窗口里修改数据。
    • 观察主窗口(以及其他弹出窗口)的数据是否跟着变了。
    • 预期:应该毫秒级同步。

5. 注意事项 (Senior Tips)

  1. 同源策略
    BroadcastChannel 依赖浏览器的同源策略。确保你的 electron-vite 在开发模式下所有窗口都是 http://localhost:xxxx,或者在生产模式下加载的都是 file:// 或者自定义协议 app://。只要它们属于同一个 Origin,这个方案就 100% 有效。
  2. 性能
    虽然 BroadcastChannel 很快,但不要用它传输非常大的二进制数据(如图片 Base64、超长文本)。如果 State 特别大,建议将大数据放在 IndexedDB,Store 里只存 ID。
  3. 非序列化数据
    JSON.stringify 会丢失 FunctionMapSet 等类型。请确保你的 Pinia State 是纯数据对象(Plain Object)。这是 Vue 和 Pinia 的最佳实践,也是多进程通信的限制。

这套代码虽然看起来简单,但处理了多进程架构中最棘手的“竞争”和“初始化”问题,完全可以直接合入项目。

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