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-store 或 electron-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.fileAPI、window.windowAPI,避免单个对象过于臃肿。
Web 端降级实现要做到什么程度才算合格?
至少要做到两点:第一,所有 preload 暴露的方法在 Web 端都有对应的空实现或 localStorage 降级,确保 store 代码不会因为 window.xxxAPI 为 undefined 而报错;第二,降级实现的接口签名要和 Electron 端完全一致(参数类型、返回值类型),否则 TypeScript 的类型推导会失效。建议把 API 接口类型单独抽成一个 shared/api-types.ts 文件,两端共用。