Electron 缓存数据共享同步

前言

在使用 Electron 打开多窗口时, 主窗口的 sessionStoragelocalStorage 和子窗口的数据不一致无法动态响应。即主窗口修改了存储在 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

更新方案

  1. 主窗口 [更新] -[广播发送]-> 其他复用store的窗口
  2. 子窗口 [更新] -[发送]-> 主窗口 [广播发送] -> 其他复用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 插件

  1. 定义的pinia 插件, 如 pinia.use(() => ({ hello: 'world' })) ,每存在一个不重复的store 都会触发一次, 可以通过 store.$id 获取当前调用时的 store id [缓存对应的key]
  2. 在插件回调函数里, 通过订阅 store.$subscribe 在数据更新时可获取 更新后的完整数据, 通过ipc.send pinia:share 发送共享数据 (当前代码只处理 type === 'direct' 的数据, 即非 store.$patch 处理的的数据)
  3. 添加防抖处理, 防止过于频繁的更新 [leading: true 首次立即触发, maxWait 在频繁触发时最大等待时间]
  4. 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);
    }
  });
};
相关推荐
前端开发爱好者3 小时前
v5.0 版本发布!Vue3 生态最强大的 3D 开发框架!
前端·javascript·vue.js
90后的晨仔5 小时前
Vue 组件事件完全指南:子父组件通信的艺术
前端·vue.js
正义的大古5 小时前
OpenLayers地图交互 -- 章节十六:双击缩放交互详解
javascript·vue.js·openlayers
Bella_a6 小时前
请描述Vue的生命周期钩子,并在哪个阶段能访问到真实的DOM?
vue.js
_AaronWong6 小时前
Electron全局搜索框实战:快捷键调起+实时高亮+多窗口支持
前端·搜索引擎·electron
小样还想跑6 小时前
UniApp键盘监听全攻略
vue.js·uni-app·计算机外设
_一两风6 小时前
Vue3 常用指令介绍
vue.js
Z_ One Dream7 小时前
React 和 Vue 如何选择?(2026 年)
javascript·vue.js·react.js
Restart-AHTCM7 小时前
前端核心框架vue之(路由核心案例篇3/5)
前端·javascript·vue.js