前言
在使用 Electron 打开多窗口时, 主窗口的 sessionStorage
、 localStorage
和子窗口的数据不一致无法动态响应。即主窗口修改了存储在 sessionStorage 的用户头像, 子窗口的用户头像还是旧的头像。
版本
- Electron version 38.1.0
- vue version 3.5
- vue router version 4.5.1
- pinia version 3.0.1
- pinia-plugin-persistedstate version 4.2.0
- lodash-es version 4.17.21
更新方案
- 主窗口 [更新] -[广播发送]-> 其他复用store的窗口
- 子窗口 [更新] -[发送]-> 主窗口 [广播发送] -> 其他复用store的窗口
主进程
win.webContents.send pinia:remote-${shareData.storeId}
主进程向渲染进程发送共享消息。(shareData
是共享消息, 其中storeId
为缓存的key)
子窗口向主窗口发送共享消息时需要 传递 windowId
属性给 主窗口, 主窗口在广播消息时通过这个 windowId
排除原消息发送窗口
如果创建的窗口没有使用 pinia store 实例, 即在代码中如使用 useUserStore()
就不会订阅 pinia:remote-${shareData.storeId}
在主进程广播时就不会进行同步更新数据。
可扩展: 获取窗口信息, 判断子窗口是否需要共享数据给主窗口。 可以窗口池管理;
IPC
ts
// initPiniaStoreIpc
import { BrowserWindow, ipcMain } from 'electron';
/**
* Pinia Ipc 状态共享
*
* @param mainWindow 主窗口
*/
export const initPiniaStoreIpc = (mainWindow: BrowserWindow) => {
/**
* 子窗口处理
* 仅发送给主窗口
*
* @param shareData 数据
* @param senderWindow 发送窗口
*/
const handleChildWindow = (
shareData: ShareData,
senderWindow: BrowserWindow
) => {
// TODO 可扩展: 获取窗口信息, 判断读取子窗口是否不共享数据给主窗口
mainWindow.webContents.send(`pinia:remote-${shareData.storeId}`, shareData);
console.log(`[Pinia IPC] 向主窗口共享数据[${shareData.storeId}]发送数据`);
};
/**
* 主窗口处理
* 向所有窗口发送数据
*
* @param shareData 数据
* @param senderWindow 发送窗口
*/
const handleMainWindow = (
shareData: ShareData,
senderWindow: BrowserWindow
) => {
// 过滤有效窗口(排除devtools和已销毁的窗口)
const validWindows = BrowserWindow.getAllWindows().filter((win) => {
if (win.isDestroyed()) return false;
try {
const url = win.webContents.getURL();
return !url.startsWith('devtools://');
} catch {
return false;
}
});
// 窗口数量不足时直接返回
if (validWindows.length < 2) return;
// 提取主窗口ID和发送窗口ID,避免重复获取
const mainWindowId = mainWindow.id;
const senderWindowId = senderWindow.id;
const shareDataWindowId = shareData.windowId;
console.log(
`[Pinia IPC ${shareData.storeId}] 广播📢共享数据[${validWindows.map((win) => win.id).join(',')}]`
);
// 构建目标窗口列表(排除主窗口、发送窗口和no-sync模式窗口)
const targetWindows = validWindows.filter((win) => {
const winId = win.id;
// 排除主窗口
if (winId === mainWindowId) return false;
// 排除发送窗口
if (winId === senderWindowId || winId === shareDataWindowId) return false;
// TODO 可扩展: 获取窗口信息, 判断读取子窗口是否不共享数据给主窗口
return true;
});
// 发送共享数据到目标窗口
const eventName = `pinia:remote-${shareData.storeId}`;
targetWindows.forEach((win) => {
if (!win.isDestroyed()) {
win.webContents.send(eventName, shareData);
}
});
};
// 处理状态更新事件
ipcMain.on('pinia:share', (event, data) => {
const shareData: ShareData = data ? JSON.parse(data) : ({} as ShareData);
const senderWindow = BrowserWindow.fromWebContents(event.sender);
if (!senderWindow) {
console.warn('[Pinia IPC] 无效的窗口');
return;
}
// 是否为主窗口
const isMainWindow = senderWindow?.id === mainWindow.id;
// 主窗口处理
if (isMainWindow) {
handleMainWindow(
{
...shareData,
},
senderWindow
);
return;
}
// 子窗口处理
handleChildWindow(
{
...shareData,
windowId: senderWindow.id,
},
senderWindow
);
});
};
创建窗口时
ts
app.whenReady().then(() => {
const mainWin = createWindow();
// register ipc
initPiniaStoreIpc(mainWindow);
});
渲染层
定义 pinia 插件
- 定义的pinia 插件, 如
pinia.use(() => ({ hello: 'world' }))
,每存在一个不重复的store 都会触发一次, 可以通过store.$id
获取当前调用时的 store id [缓存对应的key] - 在插件回调函数里, 通过订阅
store.$subscribe
在数据更新时可获取 更新后的完整数据, 通过ipc.sendpinia:share
发送共享数据 (当前代码只处理 type === 'direct' 的数据, 即非store.$patch
处理的的数据) - 添加防抖处理, 防止过于频繁的更新 [leading: true 首次立即触发, maxWait 在频繁触发时最大等待时间]
- ipc.on
pinia:remote-${store.$id}
, 订阅来自主进程发送的共享数据,为每个storeId
创建独立的防抖函数
ipc | |
---|---|
ipc.on pinia:remote-{store.id} | 订阅共享消息 |
ipc.send pinia:share |
发送共享消息 |
store.$subscribe
订阅
- 需要处理从缓存中首次读取初始化数据,该次回调无需共享到主窗口上。(isFirstCallMap 处理)
ts
// electronSharedPlugin
import { PiniaPlugin, PiniaPluginContext } from 'pinia';
import { debounce, DebouncedFunc } from 'lodash-es';
/**
* Pinia 插件,用于实现 Pinia 数据的共享和订阅功能。
*
* @param store - 当前 Pinia store 实例
*/
export const electronSharePlugin: PiniaPlugin = ({
store,
}: PiniaPluginContext): void => {
const customApi = window.customApi;
const electronAPI = window.electronAPI;
if (!electronAPI || !customApi) {
return;
}
const ipc = electronAPI.ipcRenderer;
// 使用 Map 管理每个 storeId 的首次触发状态
const isFirstCallMap: Map<string, boolean> = new Map();
// 为每个 storeId 创建独立的防抖函数映射
const debouncedPatchMap: Map<
string,
DebouncedFunc<(storeId: string, state: any) => void>
> = new Map();
const remoteDebouncedFunc = debounce(
(id: string, stateData: any) => {
if (id === store.$id) {
store.$patch(stateData as any);
console.log(`接收到远程数据(${id}): `, stateData);
}
},
200,
{
leading: true,
maxWait: 500,
}
);
const debouncedSubscribe = debounce(
async (storeId: string, shareData: ShareData) => {
// 窗口数量小于 2 时不发送数据
// const windowCount = await customApi.windowCount();
// if (windowCount < 2) {
// return;
// }
ipc.send(`pinia:share`, JSON.stringify(shareData));
console.log(
`发送到远程数据[windowCount: ${windowCount}]: ${storeId}`,
'shareData: ',
shareData
);
},
150,
{
leading: true,
maxWait: 500,
}
);
/**
* 订阅数据
*/
ipc.on(`pinia:remote-${store.$id}`, (_, data = {}) => {
const { storeId, state }: ShareData = data;
// 为每个 storeId 创建独立的防抖函数
if (!debouncedPatchMap.has(storeId)) {
debouncedPatchMap.set(storeId, remoteDebouncedFunc);
}
// 使用对应 storeId 的防抖函数处理数据更新
const debouncedPatch = debouncedPatchMap.get(storeId)!;
debouncedPatch(storeId, state);
});
/**
* 订阅 store 的数据更新
* @param storeId 缓存 key
*/
store.$subscribe(({ storeId, type }, state) => {
// 仅处理非 store.$patch 更新的数据
if (type === 'direct') {
// 检查当前 storeId 是否首次调用
if (!isFirstCallMap.has(storeId)) {
// 首次调用时设置标志并返回,不执行任何操作
isFirstCallMap.set(storeId, true);
return;
}
// 更新状态,下次调用时不再视为首次
isFirstCallMap.set(storeId, false);
// 当前 store 更新数据
const shareData: ShareData = {
storeId,
state,
};
// 使用防抖函数处理订阅
debouncedSubscribe(storeId, shareData);
}
});
};