electron多窗口状态同步 插件pinia-plugin-electron-share升级版

前言

通过indexDb存储pinia的相关数据。传输的时候不用传输整个数据

核心思想转变:
之前的模式是 “数据跟随消息” (Push Data),现在的模式是 “消息通知,数据自取” (Signal & Pull)

  1. 写入方:Store 变更 -> 写入 IndexedDB -> 发送 “数据已更新” 的信号(不带数据)。
  2. 接收方:收到信号 -> 从 IndexedDB 读取最新数据 -> 更新 Store。
  3. 初始化:新窗口启动 -> 请求初始化 -> 老窗口强制将内存数据刷入 IndexedDB -> 发送 “准备好了” -> 新窗口从 DB 读取。

这样做的好处是 BroadcastChannel 极其轻量,不再受数据大小限制,且数据持久化由 IndexedDB 承担。

下面是完整的实现方案。

1. 封装 IndexedDB 工具类 (src/utils/db.ts)

首先我们需要一个极简的 IDB 包装器,用于存取 Pinia 的 State。

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
// src/utils/db.ts

const DB_NAME = 'pinia-electron-db'
const DB_VERSION = 1
const STORE_NAME = 'pinia-state'

export class SimpleIDB {
private dbRequest: Promise<IDBDatabase>

constructor() {
this.dbRequest = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)

request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)

request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME) // keyPath 为 storeId
}
}
})
}

async setItem(key: string, value: any) {
const db = await this.dbRequest
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const req = store.put(value, key)

req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
}

async getItem(key: string) {
const db = await this.dbRequest
return new Promise<any>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const req = store.get(key)

req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
}

// 导出单例
export const idb = new SimpleIDB()

2. 改造 Pinia 插件 (src/index.ts)

关键改动点:

  1. SyncPayload 中的 data 字段被移除(或设为可选,这里直接移除)。
  2. $subscribe 不再直接发消息,而是先 idb.setItem,成功后再发 notify
  3. 接收端收到消息后,执行 idb.getItem
  4. 增加了异步锁逻辑,防止 IDB 读写期间产生冲突。
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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
// src/index.ts
import { PiniaPluginContext } from 'pinia'
import { idb } from './utils/db' // 引入上面的工具

declare module 'pinia' {
export interface DefineStoreOptionsBase<S, Store> {
share?: boolean
}
}

export interface ElectronShareOptions {
channelName?: string
}

// ✨ 载荷不再包含 data,只包含通知信号
type SyncPayload = {
type: 'NOTIFY_UPDATE' | 'REQUEST_INIT' | 'RESPONSE_READY'
storeId: string
timestamp: number
srcId: string
}

function generateId() {
return Math.random().toString(36).substr(2, 9)
}

export function createElectronSharePlugin(globalOptions: ElectronShareOptions = {}) {
const CHANNEL_NAME = globalOptions.channelName || 'pinia-electron-share'
const channel = new BroadcastChannel(CHANNEL_NAME)
const WINDOW_ID = generateId()

return ({ store, options }: PiniaPluginContext) => {
if (!options.share) return

// --- 状态标志 ---
let isExternalUpdate = false // 正在应用外部数据
let isInitialized = false
let initInterval: any = null
let replyDebounceTimer: any = null

// --- 核心:从 DB 读取并应用数据 ---
const applyStateFromDB = async () => {
try {
const data = await idb.getItem(store.$id)
if (data) {
// 标记为外部更新,防止触发 subscribe 再次写入 DB
isExternalUpdate = true
store.$patch(data)

// 给一点缓冲时间让 Vue Reactivity 完成,防止立刻触发 subscribe
setTimeout(() => { isExternalUpdate = false }, 50)
}
return !!data
} catch (err) {
console.error(`[Share] Read DB failed:`, err)
return false
}
}

// --- 消息处理 ---
const messageHandler = async (event: MessageEvent<SyncPayload>) => {
const { type, storeId, srcId } = event.data

if (storeId !== store.$id || srcId === WINDOW_ID) return

switch (type) {
case 'NOTIFY_UPDATE':
// 收到别人的更新通知 -> 去查 DB
if (!isExternalUpdate) {
await applyStateFromDB()

// 如果我是刚启动的窗口,收到这个也算初始化完成
if (!isInitialized) {
isInitialized = true
clearInterval(initInterval)
}
}
break

case 'REQUEST_INIT':
// 别人在请求初始化
if (isInitialized) {
// 竞态抑制:随机延迟回复
const delay = Math.floor(Math.random() * 90) + 10
replyDebounceTimer = setTimeout(async () => {
// ✨ 关键:先把我也许还没保存的内存状态,强制刷入 DB
// 确保新窗口读到的是最新的
await idb.setItem(storeId, JSON.parse(JSON.stringify(store.$state)))

// 发送 "数据已就绪" 信号 (不带数据)
channel.postMessage({
type: 'RESPONSE_READY',
storeId: store.$id,
timestamp: Date.now(),
srcId: WINDOW_ID
})
}, delay)
}
break

case 'RESPONSE_READY':
// 收到 "数据已就绪" 信号
if (replyDebounceTimer) {
clearTimeout(replyDebounceTimer)
replyDebounceTimer = null
}

if (!isInitialized) {
console.log(`[Share] DB Ready signal received from ${srcId}`)
// 去 DB 拉取数据
await applyStateFromDB()

isInitialized = true
clearInterval(initInterval)
}
break
}
}

channel.addEventListener('message', messageHandler)

// --- 监听状态变化 (写入方) ---
store.$subscribe(async (mutation, state) => {
if (isExternalUpdate) return
if (!isInitialized) return

try {
// 1. 先写入 IndexedDB
const rawState = JSON.parse(JSON.stringify(state))
await idb.setItem(store.$id, rawState)

// 2. 写入成功后,发送轻量级通知
channel.postMessage({
type: 'NOTIFY_UPDATE',
storeId: store.$id,
timestamp: Date.now(),
srcId: WINDOW_ID
})
} catch (e) {
console.error(`[Share] Save to DB error`, e)
}
})

// --- 初始化流程 ---

// 尝试直接从 DB 读取一次 (也许是刷新页面,DB里已经有数据了)
applyStateFromDB().then((hasData) => {
if (hasData) {
// 只要 DB 有数据,就算初始化完成,但也可能数据是旧的
// 所以我们仍然可以发 REQUEST_INIT 确认一下有没有更权威的窗口
// 但为了简单,如果 DB 有数据,通常认为是持久化的结果
isInitialized = true
// 这里看策略:
// 策略A: 信任 DB,直接完成。
// 策略B: 即使 DB 有,也问一下其他窗口有没有更新的内存数据。
// 下面代码采用策略 B,继续轮询一会
}
})

initInterval = setInterval(() => {
if (isInitialized) {
clearInterval(initInterval)
return
}
// 喊话:谁有最新数据?请刷入 DB 并告诉我
channel.postMessage({
type: 'REQUEST_INIT',
storeId: store.$id,
timestamp: Date.now(),
srcId: WINDOW_ID
})
}, 200)

// 兜底
setTimeout(() => {
if (!isInitialized) {
console.log('[Share] Init timeout, using local/DB state.')
isInitialized = true
clearInterval(initInterval)
}
}, 1000)
}
}

方案解析

  1. 数据流向

    • 旧方案Memory (Window A) -> BroadcastChannel (Huge String) -> Memory (Window B)
    • 新方案Memory (Window A) -> IndexedDB -> BroadcastChannel (Notify) -> Memory (Window B) reads IndexedDB
  2. 异步处理 (isExternalUpdate 的微妙之处)

    • 因为 idb.getItem 是异步的,从发出读取请求到数据 patch 到 store 之间有时间差。
    • 我们在 applyStateFromDB 里设置 isExternalUpdate = true,并在 patch 完成后稍微 setTimeout 延迟释放。
    • 这确保了:从 DB 读出来的数据应用到 Store 时,不会再次触发 $subscribe 导致回写 DB 和死循环。
  3. 初始化逻辑增强

    • 当新窗口发送 REQUEST_INIT 时,老窗口不能只回个“收到”。
    • 老窗口必须执行 await idb.setItem(...)强制把自己内存里的最新状态刷入磁盘
    • 因为 IndexedDB 可能比内存稍微滞后(如果你在 subscribe 里做了防抖),这一步强制刷新保证了新窗口读到的一定是内存里最新的。
  4. 性能优势

    • 传输极快:IPC 消息体只有几十字节(ID + Timestamp),无论 Store 有多大(比如存了 1MB 的文本),通信都不会卡顿。
    • 自动持久化:你的应用关掉再打开,数据还在 IndexedDB 里(只要你在代码里处理了启动读取),天然支持了数据持久化需求。

这个方案非常适合 Electron 应用,因为它规避了 IPC 传输大数据序列化的性能瓶颈。

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