进程间共享数据
本教程共 45 篇 · 第 15 篇 · 更新于 2026-08-03
15. 进程间共享数据
应用跑起来后,总有些数据要被多处用到:用户设置、登录态、当前打开的文档列表。在多进程的 Electron 里,「共享数据」不能靠全局变量,而要靠一个清晰的模式。本章讲讲怎么正确地做。
本节目标
- 理解为什么主进程应作为状态的单一数据源。
- 掌握渲染进程经 IPC 读写共享状态的写法。
- 明白为什么不能直接共享大对象或复杂实例。
- 了解简单的状态管理思路与边界。
- 知道多窗口下如何保持一致。
15-1 单一数据源:主进程
回想进程隔离:主、渲内存不共享,没有「全局对象」能同时被两边读写。那共享状态放哪?答案是主进程。
主进程只有一个,且常驻。把应用级的共享状态(配置、会话、缓存)放在主进程的一个普通对象里,它就自然成了「单一数据源」。所有渲染进程要读要写,都通过 IPC 来找主进程。
// 主进程 main.js
const { app, BrowserWindow, ipcMain } = require('electron/main')
const path = require('node:path')
// 主进程持有的应用状态(单一数据源)
const store = {
theme: 'light',
counter: 0
}
function createWindow () {
const win = new BrowserWindow({
webPreferences: { preload: path.join(__dirname, 'preload.js') }
})
win.loadFile('index.html')
}
把状态放主进程还有个好处:主进程有完整 Node 能力,状态需要持久化到磁盘时,直接在这里读写文件即可,不必绕回渲染进程。
15-2 渲染进程经 IPC 读写
渲染进程不能直接碰 store。它要读,就 invoke 一个查询通道;要写,就 invoke 一个更新通道。主进程在 handle 里改自己的 store,再返回结果。
// 主进程 main.js
ipcMain.handle('state:get', () => {
return store // 返回可克隆的普通对象副本
})
ipcMain.handle('state:set', (event, patch) => {
Object.assign(store, patch) // 合并更新
// 可选:通知其它窗口状态变了
BrowserWindow.getAllWindows().forEach(w => {
if (w.webContents !== event.sender) {
w.webContents.send('state:changed', store)
}
})
return store
})
preload 把这两个动作安全暴露出去:
// preload.js(Preload 脚本)
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
getState: () => ipcRenderer.invoke('state:get'),
setState: (patch) => ipcRenderer.invoke('state:set', patch)
})
// 渲染进程
const s = await window.electronAPI.getState()
await window.electronAPI.setState({ theme: 'dark' })
15-3 不要直接共享大对象
前面章节强调过:IPC 用结构化克隆传数据。这意味着你传过去的不是「同一个对象」,而是一份副本。所以「共享一个大对象并原地修改」在多进程下根本不成立。
更要命的是,DOM 元素、Electron 对象(BrowserWindow、WebContents)、带 C++ 支撑的 Node 对象,都无法被克隆,强行传会报错。共享数据的正确形态,是「小而干净的可序列化数据」。
// ✅ 传干净的数据
// ipcRenderer.invoke('state:set', { counter: 5 })
// ❌ 不能传窗口/DOM 等不可克隆对象
// ipcRenderer.send('bad', someBrowserWindow) // 会失败
如果确实有大量数据(比如大文件内容、图片 buffer),优先考虑:放主进程管理,渲染进程按需分段拉取;或只在主进程内处理,只把结果摘要发给界面。
15-4 多窗口如何保持一致
多个窗口都依赖同一份状态时,最容易出问题的是「窗口 A 改了,窗口 B 不知道」。解决办法是:状态变更后,主进程主动广播给所有窗口。
上一节的 state:set 里已经示范:更新完 store,遍历 getAllWindows(),用 webContents.send('state:changed', store) 通知其它窗口。各窗口在 preload 订阅该通道,收到后刷新自己的界面。
// preload.js(Preload 脚本)
contextBridge.exposeInMainWorld('electronAPI', {
onStateChanged: (callback) =>
ipcRenderer.on('state:changed', (_event, value) => callback(value))
})
// 渲染进程
window.electronAPI.onStateChanged((newState) => {
// 用新状态刷新界面
renderUI(newState)
})
这样无论哪个窗口改了状态,其余窗口都会通过主进程的广播自动同步,避免各窗口数据打架。
15-5 简单的状态管理思路
不需要一上来就引入 Redux 之类的大方案。对中小应用,主进程一个 store 对象 + 几个 IPC 通道,已经足够清晰。你可以再补两层:
一层是「持久化」:状态变更时写盘(如 app.getPath('userData') 下的 json),启动时读回。这样重启不丢设置。
另一层是「校验」:在 state:set 的 handle 里校验 patch 的字段与类型,防止渲染进程传来奇怪的数据破坏状态。主进程作为守门人,天然适合做这层校验。
// 主进程:带校验的更新
ipcMain.handle('state:set', (event, patch) => {
// 渲染进程传来的东西一律先当成不可信数据
if (typeof patch !== 'object' || patch === null) {
return store
}
if (typeof patch.theme === 'string') {
store.theme = patch.theme
}
if (typeof patch.counter === 'number') {
store.counter = patch.counter
}
return store
})
这里的关键是「只认白名单字段」。没在代码里列出来的键会被直接忽略,渲染进程也就没法往 store 里塞奇怪的东西。
15-6 哪些数据该放主进程
这套模式适合的是「应用级共享状态」。判断一份数据要不要提升为全局,可以问三个问题:多个窗口都要用吗?需要持久化吗?需要集中校验吗?
三者占其一,就放主进程。都不沾边的,比如当前输入框的内容、某次弹窗的临时选择,留在对应渲染进程自己的内存里就好。
真正值得共享的,通常是用户偏好、登录令牌、应用级开关这类跨窗口跨会话都要一致的东西。它们一旦被多处修改,就必须有唯一真相来源,否则各窗口各执一份,界面很快会对不上。
15-7 把 store 按业务分片
单个 store 对象在应用变大后会显得笨重。这时可以按业务把它拆开,比如分成 prefsStore 和 sessionStore,各自配一组独立通道(prefs:get/prefs:set、session:get/session:set)。
分片有两个好处。一是职责边界清楚,改偏好的代码不会误碰会话数据。二是广播时可以只通知关心该分片的窗口,减少无谓的界面刷新。
要提醒的是,全局状态用着方便,也最容易变成「什么都往里扔」的垃圾场。新加一个共享字段之前,回头再过一遍上一节那三个问题,能挡掉大部分不必要的全局化。
常见误区
- 误区一:以为渲染进程能直接读写主进程的
store对象。进程内存隔离,必须走 IPC。 - 误区二:想共享大对象或窗口实例,结果传不过去。只传可克隆的小数据。
- 误区三:多窗口改状态互不同步。记得在
handle里广播state:changed。
小结
进程间共享数据的正解,是让主进程持有唯一的数据源。渲染进程经 IPC 读写,改完之后由主进程广播给所有窗口。
传输时只走可序列化的小数据,大对象和窗口实例这类不可克隆的东西不要碰。需要持久化和校验的地方,主进程天然是最合适的守门人。这套轻量模式足以覆盖大多数中小应用。