Electron 和 Web 端共用 store,主进程与渲染进程的同步方案我对比了四种

共享 store 的核心难点不在于“能不能通”,而在于同步边界划在哪里。四种方案里,只有一种能同时兼顾 Web 端兼容、性能开销和代码可维护性,其余三种都在不同维度上吃了亏。

先给结论:如果你的 Electron 应用需要和 Web 端共用一套 store 代码,最稳妥的做法是“渲染进程单例 store + preload 桥接主进程能力”,把主进程降级为一个纯服务层,而不是让它也跑一份 store 实例。

下面我把四种方案逐个拆开,说说每种方案的实际表现、踩过的坑,以及什么场景下才值得选它。

方案一:主进程跑唯一的 store,渲染进程通过 IPC 读写

这个方案的核心思路是:把 store 实例放在主进程,渲染进程不直接持有 store,而是通过 contextBridge 暴露的 API 来读值和写值,所有状态变更都在主进程完成。

结论:Web 端完全没法复用,而且渲染进程每次读状态都是一次 IPC 往返,高频场景性能直接崩。

具体说一下。假设你用 Pinia 或 Vuex 的 store,在主进程里创建一个实例:

// main.js (Node.js 侧)
import { createPinia } from 'pinia'
import { useAppStore } from './stores/app'

const pinia = createPinia()
const appStore = useAppStore(pinia)

ipcMain.handle('store:get', (_, key) => appStore[key])
ipcMain.handle('store:set', (_, key, value) => { appStore[key] = value })

渲染进程这边只能通过 window.electronAPI.getStore('theme') 这样的方式去拿一个值。这带来了三个问题:

第一,Web 端根本用不了这一套。你没法在浏览器里跑一个 Node.js 主进程,所以 store 初始化和读写逻辑必须全部重写,相当于维护两套状态管理代码。

第二,性能损耗肉眼可见。每次读值都是一个异步 IPC,如果某个组件依赖 5 个响应式状态,初始化时就是 5 次 ipcRenderer.invoke。我实测过,在 macOS 上单次 invoke 往返大概 0.3-0.5ms,看起来不多,但如果一个页面有几十个组件同时挂载,这延迟就累积起来了。

第三,响应式断裂。渲染进程拿到的是一个静态值,主进程那边的状态变更不会自动推送到渲染进程。你必须自己实现一套事件通知机制,让主进程在状态变化时 webContents.send('store:changed', ...),然后渲染进程手动更新本地状态。这等于在 IPC 层手写了一个简陋的响应式系统,维护成本高得离谱。

唯一适合用这个方案的场景是:你的 Electron 应用根本不需要兼容 Web 端,而且 store 里的数据绝大多数是启动时一次性加载的配置项,运行时很少频繁读写。即便如此,我也更推荐方案三。

方案二:渲染进程跑 store,通过 preload 把 Node 能力注入 store actions

这个方案的思路反了过来:store 实例放在渲染进程,跟 Web 端保持一致,但那些需要访问文件系统、系统级 API 的操作,通过 preload 暴露的方法在 actions 里调用。

结论:这是四种方案里最平衡的选择,Web 端兼容性最好,架构也最干净。

具体实现上,store 的定义跟纯 Web 应用一模一样:

// stores/app.js (渲染进程侧,也可在 Web 端使用)
import { defineStore } from 'pinia'

export const useAppStore = defineStore('app', {
  state: () => ({
    theme: 'light',
    recentFiles: []
  }),
  actions: {
    async loadRecentFiles() {
      // 这里的 fsAPI 在 Web 端有降级实现
      this.recentFiles = await window.fsAPI?.getRecentFiles() ?? []
    },
    setTheme(theme) {
      this.theme = theme
      window.fsAPI?.saveConfig('theme', theme)
    }
  }
})

preload 脚本只负责暴露具体的系统能力,不参与状态管理:

// preload.js
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('fsAPI', {
  getRecentFiles: () => ipcRenderer.invoke('fs:getRecentFiles'),
  saveConfig: (key, value) => ipcRenderer.invoke('fs:saveConfig', key, value)
})

Web 端只需要提供一个同名的 window.fsAPI 降级实现,或者干脆在调用时做可选链判断。这样一来,整份 store 代码可以在 Electron 和浏览器之间零修改复用。我目前维护的一个项目就是这套架构,Web 端用 localStorage 降级 fsAPI,两端的 store 文件完全一致,CI 跑同一套单元测试。

这个方案的边界也很清晰:主进程只负责 I/O 操作(读写文件、调用系统对话框、监听系统事件),不持有任何业务状态。状态的所有权完全在渲染进程,主进程收到系统事件(比如文件变更)时,通过 webContents.send 推给渲染进程,由渲染进程的 store 决定如何更新。这种单向数据流比方案一的双向同步好维护得多。

缺点是,如果你有多个窗口同时跑同一个 store,它们之间不会自动同步状态。但说实话,绝大多数 Electron 应用只有一个主窗口,多窗口场景完全可以走方案四的变体。

方案三:主进程和渲染进程各跑一份 store,通过 IPC 双向同步

这个方案试图两头都占:主进程和渲染进程各自维护一个 store 实例,通过 IPC 保持它们之间的状态同步。

结论:看起来很美,实际工程中几乎不可维护,同步逻辑的复杂度会随状态字段数量线性增长。

原理上,主进程 store 的任何变更都会 send 给所有渲染进程,渲染进程 store 的变更也会通知主进程,两边互相更新。伪代码大概长这样:

// 主进程侧
appStore.$subscribe((mutation, state) => {
  mainWindow.webContents.send('store:sync', JSON.parse(JSON.stringify(state)))
})

// 渲染进程侧
ipcRenderer.on('store:sync', (_, state) => {
  appStore.$patch(state)
})
appStore.$subscribe((mutation, state) => {
  ipcRenderer.send('store:update', JSON.parse(JSON.stringify(state)))
})

这个方案有三个致命问题。

第一,死循环风险。A 端变更触发同步到 B 端,B 端应用变更后又触发同步回 A 端。你必须在两端都加上来源标记(比如 _fromMain_fromRenderer),跳过对方发来的同步触发的本地 $subscribe。一旦某个 action 里同时修改了多个字段,或者在异步回调里触发了新的变更,这个标记逻辑就很容易出 bug。

第二,序列化开销。每次同步都要把整个 state 对象做一次 JSON.parse(JSON.stringify())。如果你的 store 里存了几百 KB 的数据(比如一个大型项目的文件树),每次变更都全量序列化,主进程和渲染进程之间的 IPC 带宽会被迅速占满。我见过一个项目因为这个问题,在 store 更新频繁时渲染进程的帧率从 60fps 掉到 20fps。

第三,冲突处理。如果用户在渲染进程 A 和渲染进程 B 几乎同时修改了同一个字段,谁说了算?你需要实现一套冲突解决策略(last-write-wins 或者 CRDT),这已经不是状态管理的问题了,而是分布式系统的问题。

只有一个场景值得考虑这个方案:你的应用确实有多个窗口,且每个窗口需要实时看到其他窗口的状态变更,同时你不介意引入复杂的同步逻辑。 但即使在这种情况下,我也更建议方案四。

方案四:用 electron-storeelectron-shared-state 这类库做底层同步

这类库的卖点是“自动同步主进程和渲染进程之间的数据”,比如 electron-store 在主进程读写,渲染进程通过 IPC 访问,或者 electron-shared-state 通过 SharedArrayBuffer 或 IPC 做状态共享。

结论:上手快,但灵活性差,跟 Web 端不兼容,适合原型阶段快速验证,不适合长期维护的产品。

electron-store 的用法很简单:

// 主进程
const Store = require('electron-store')
const store = new Store()
store.set('theme', 'dark')

// 渲染进程(通过 preload 暴露)
const theme = await window.storeAPI.get('theme')

问题在哪呢?electron-store 本质上是一个基于 JSON 文件的键值存储,它不知道你的 store 里有哪些字段,也没有类型推导。你必须在代码里到处写字符串 key,TypeScript 的类型安全完全失效。而且它不支持 computed 状态、不支持和 Pinia/Vuex 的生态集成,你等于是维护了两套状态系统:一套是 UI 层的响应式 store,一套是持久化层的 electron-store

electron-shared-state 稍微好一点,它试图让主进程和渲染进程共享同一个对象引用。但它的底层依赖 Node.js 的 worker_threads 或者 IPC 序列化,在渲染进程里拿到的依然是一个副本,响应式需要手动处理。而且这些库在设计时就没考虑过 Web 端兼容,一旦你要做 Web 版,所有持久化逻辑都得重写。

适合的场景很窄:你正在做一个纯 Electron 的原型,需要快速把一些配置持久化到磁盘,且不打算做 Web 端。一旦项目进入正式开发阶段,我建议尽早迁移到方案二。

四种方案对比总结

方案 Web 端兼容 性能 维护成本 适合场景
一、主进程单例 store ❌ 完全不可用 差(每次读写走 IPC) 纯 Electron,低频读写
二、渲染进程 store + preload 桥接 ✅ 零修改复用 好(本地响应式) 推荐方案,适用绝大多数项目
三、双端各跑 store + IPC 同步 ❌ 需重写 中(全量序列化) 多窗口且要求实时同步
四、专用同步库 ❌ 需重写 原型验证,纯 Electron

如果你现在正在做技术选型,我的建议非常明确:直接用方案二起步。先把 store 定义在渲染进程,跟 Web 端保持一致,preload 只暴露最小化的系统 API。如果将来真的遇到了多窗口同步需求,再考虑在方案二的基础上引入一个轻量的主进程事件总线,而不是把整个 store 搬到主进程去。架构上,让状态靠近 UI 层永远比让状态靠近系统层更容易维护。


常见问题

多窗口场景下,方案二怎么让窗口 A 的 store 变更同步到窗口 B?

方案二默认不处理多窗口同步。如果你确实有这个需求,最简单的做法是让主进程充当消息中转站:窗口 A 的 store 变更时通过 preload 发 IPC 给主进程,主进程再广播给所有其他窗口。注意不要直接同步整个 state 对象,而是发送具体的 action 名称和参数,让各窗口自行执行 action,避免全量序列化和冲突问题。

方案二里,preload 暴露的 API 太多怎么办?会不会导致 preload 脚本膨胀?

如果 API 数量超过 20 个,说明你把太多业务逻辑塞进了 preload。preload 应该只暴露原子化的系统能力(读文件、写文件、打开对话框等),业务逻辑全部放在 store actions 里。你还可以按功能模块拆分成多个 contextBridge.exposeInMainWorld 调用,比如 window.fileAPIwindow.windowAPI,避免单个对象过于臃肿。

Web 端降级实现要做到什么程度才算合格?

至少要做到两点:第一,所有 preload 暴露的方法在 Web 端都有对应的空实现或 localStorage 降级,确保 store 代码不会因为 window.xxxAPIundefined 而报错;第二,降级实现的接口签名要和 Electron 端完全一致(参数类型、返回值类型),否则 TypeScript 的类型推导会失效。建议把 API 接口类型单独抽成一个 shared/api-types.ts 文件,两端共用。