# 彻底解决 Electron 多进程配置共享难题:从 electron-store 并发写入到零阻塞内存快照架构
> 本文基于真实生产项目(Electron 29 + Vue 2 桌面端应用)的架构重构经历,完整还原问题发现、方案演进、最终设计三个阶段,并附完整可运行代码。
---
## 一、问题现场:三个进程同时抢一把锁
### 1.1 Electron 的进程模型
Electron 应用天生是多进程架构,一个中等规模的桌面应用通常包含:
```
主进程(main)
├─ 渲染进程 renderer(多窗口)
│ ├─ main 窗口(Vue.js SPA)
│ ├─ login-register 窗口
│ └─ pre-loader 窗口
└─ Webview(内嵌网页,独立进程)
└─ WhatsApp Web(注入脚本)
```
这些进程**互相隔离**,共享内存不可能,只能通过 IPC 通信或文件系统交换数据。
### 1.2 旧代码:人均一个 electron-store 实例
electron-store 是 Electron 生态中最流行的持久化方案,它把数据存在用户目录的 `config.json` 里。问题在于,我们的旧代码**在每个进程里各自创建了一个 Store 实例**:
```js
import Store from 'electron-store'
const storage = new Store({
watch: true, // 开启文件监听
defaults: {
waAccounts: [],
activeAccountId: '',
userInfo: {},
rateLimitConfig: { dailyCheckLimit: 100, batchSendLimit: 10 /* ... */ },
LogDebug: false,
// ...共计 20+ 个 key
}
})
export default storage
```
```js
import Store from 'electron-store'
const storage = new Store({
watch: true,
schema: { /* 和上面几乎重复的 schema 定义 */ },
defaults: { /* 几乎重复的默认值 */ }
})
export default storage
```
```js
const { ipcRenderer } = window.require('electron')
export async function getStoreData(key) {
return await ipcRenderer.invoke('get-store-data', key)
}
```
而主进程 IPC handler 大概是这样的:
```js
ipcMain.handle('get-store-data', (event, key) => storage.get(key))
ipcMain.handle('set-store-data', (event, key, value) => storage.set(key, value))
ipcMain.handle('get-feature-limit-data', (event, key) => featureStorage.get(key))
ipcMain.handle('set-feature-limit-data', (event, key, value) => featureStorage.set(key, value))
ipcMain.handle('get-batch-store-data', (event, keys) => {
return keys.reduce((acc, key) => { acc[key] = storage.get(key); return acc }, {})
})
```
**甚至还有一个独立的 featureLimit.json:**
```js
const freeTierFeatureLimitStorage = new Store({
name: 'freeTierFeatureLimit', // 单独文件
// ...
})
```
以及按用户 ID 动态创建的 Store:
```js
export const createWaBackupStore = () => {
const userInfo = storage.get('userInfo')
const store = new Store({ name: `backup_${userInfo.userId}` })
return store
}
export const createChatLangStore = () => {
const userInfo = storage.get('userInfo')
const store = new Store({ name: `chatlang_${userInfo.userId}`, accessPropertiesByDotNotation: false })
return store
}
```
### 1.3 具体症状
| 问题 | 表现 |
|------|------|
| **首屏卡顿** | 每个 webview 启动时同步解析 config.json,4 个 webview = 4 次同步 I/O |
| **竞态写入** | renderer 和 main 可能同时 `set()` 同一个 key,最后写入者获胜,中间状态随机丢失 |
| **`watch:true` 的代价** | 每个 Store 实例都在监听文件变化,一次写入触发多个实例的回调,造成写入风暴 |
| **Schema 重复维护** | 同一份数据结构在 shared/storage、renderer/storage 分别定义,极易出现不一致 |
| **散乱的 IPC 频道** | `get-store-data`、`set-store-data`、`get-feature-limit-data`、`set-feature-limit-data`、`get-batch-store-data`... 难以追踪 |
---
## 二、架构决策:一个核心原则
> **主进程是配置数据的唯一持有者,其他进程只读本地快照,写入走异步 IPC。**
这个原则解决了一切:
- 文件 I/O 只在主进程发生 → 竞态消失
- 其他进程持有内存快照 → 读取零 I/O
- 写入 fire-and-forget → 不阻塞 UI
### 2.1 整体数据流
```
主进程启动
│
├─ fs.readFileSync(config.json) ← 同步一次,窗口创建前完成
│ → ConfigManager 内存对象 ← 全局单例,整个主进程共享
│
├─ registerConfigListeners() ← 注册 4 个 IPC 频道
│
└─ 创建窗口
各窗口 / Webview(启动时)
│
└─ await ipcRenderer.invoke('config:get-startup-snapshot')
→ configStore.hydrate(snapshot) ← 一次性注水
→ createHydratedVueApp() ← Vue 应用启动
运行期
├─ configStore.get(key) → 读本地快照,同步,零 I/O
├─ configStore.set(key,val) → 本地快照立即更新 + fire-and-forget IPC
└─ configStore.getFresh(key)→ 显式 invoke,低频按需(如支付回调)
主进程写入流程:
IPC handler 收到 config:set
→ configManager.set(key, value) ← 更新内存
→ scheduleFlush() ← debounce 2s
→ fs.writeFile(tmp) + rename ← 原子写入,不损坏文件
退出:
app.on('before-quit')
→ configManager.flushSync() ← 同步写,防止 debounce 未触发时丢数据
```
---
## 三、核心实现代码
### 3.1 ConfigManager — 主进程唯一数据持有者
```js
// src/main/config/config-manager.js
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
import { STARTUP_KEYS, CONFIG_DEFAULTS } from './config-defaults.js'
const CONFIG_PATH = path.join(app.getPath('userData'), 'config.json')
const TMP_PATH = CONFIG_PATH + '.tmp'
const FLUSH_DEBOUNCE = 2000
class ConfigManager {
constructor() {
this._data = {}
this._dirty = false
this._flushTimer = null
}
load() {
try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
this._data = { ...CONFIG_DEFAULTS, ...JSON.parse(raw) }
} catch {
this._data = { ...CONFIG_DEFAULTS }
}
}
get(key, defaultVal) {
const val = key.split('.').reduce((obj, k) => obj?.[k], this._data)
return val !== undefined ? val : defaultVal
}
set(key, value) {
const keys = key.split('.')
let obj = this._data
for (let i = 0; i < keys.length - 1; i++) {
if (obj[keys[i]] === undefined) obj[keys[i]] = {}
obj = obj[keys[i]]
}
obj[keys[keys.length - 1]] = value
this._scheduleFlush()
}
delete(key) {
const keys = key.split('.')
let obj = this._data
for (let i = 0; i < keys.length - 1; i++) {
obj = obj?.[keys[i]]
if (!obj) return
}
delete obj[keys[keys.length - 1]]
this._scheduleFlush()
}
getMany(keys) {
return Object.fromEntries(keys.map(k => [k, this.get(k)]))
}
getStartupSnapshot() {
return this.getMany(STARTUP_KEYS)
}
flushSync() {
if (this._flushTimer) {
clearTimeout(this._flushTimer)
this._flushTimer = null
}
this._writeSync()
}
_scheduleFlush() {
this._dirty = true
if (this._flushTimer) clearTimeout(this._flushTimer)
this._flushTimer = setTimeout(() => this._writeAsync(), FLUSH_DEBOUNCE)
}
_writeSync() {
const json = JSON.stringify(this._data, null, 2)
fs.writeFileSync(TMP_PATH, json, 'utf-8')
fs.renameSync(TMP_PATH, CONFIG_PATH)
this._dirty = false
}
async _writeAsync() {
const json = JSON.stringify(this._data, null, 2)
await fs.promises.writeFile(TMP_PATH, json, 'utf-8')
await fs.promises.rename(TMP_PATH, CONFIG_PATH)
this._dirty = false
this._flushTimer = null
}
}
export const configManager = new ConfigManager()
```
**关键设计点:**
1. `tmp + rename` 原子替换:即使写入中途崩溃,原文件不损坏
2. debounce 2s:频繁 set 只触发一次磁盘写入
3. `flushSync()`:退出时的最后保险
### 3.2 config-defaults.js — 单一真相来源
```js
export const CONFIG_DEFAULTS = {
app: { showWebviewDevTools: false, showRendererDevTool: false },
accounts: [],
linkedAccounts: [],
activeAccountId: '',
activeAccountIdMap: {},
accountCustomOrder: [],
autoSorting: true,
userToken: '',
userId: '',
userInfo: {},
rememberMe: false,
accountHistory: [],
lastAccount: '',
setLanguage: 'zh',
setPreferredSystemLanguage: false,
LogDebug: false,
safeMode: true,
darkMode: false,
webviewPoolSize: 5,
userDataPath: '',
downloadPath: '',
rateLimitConfig: {
recordTime: 0,
targetCheckMap: {},
taskMap: {},
dailyCheckLimit: 100,
batchSendLimit: 10,
sendInterval: { min: 300, max: 600 },
checkInterval: { min: 500, max: 1000 }
},
messageRemind: true,
translation: {},
actionPanelVisible: true,
quotaConfig: {},
quotaUsed: {},
}
export const STARTUP_KEYS = [ 'userToken', 'userId', 'userInfo', 'lastAccount', 'rememberMe', 'accountHistory', 'accounts', 'linkedAccounts', 'activeAccountId', 'activeAccountIdMap', 'accountCustomOrder', 'autoSorting', 'setLanguage', 'LogDebug', 'app', 'safeMode', 'webviewPoolSize', 'userDataPath', 'downloadPath', 'rateLimitConfig', 'messageRemind', 'translation', 'actionPanelVisible', 'quotaConfig', 'quotaUsed']
```
### 3.3 config-listeners.js — 精简到 4 个 IPC 频道
```js
import { ipcMain } from 'electron'
import { configManager } from './config-manager.js'
export function registerConfigListeners() {
ipcMain.handle('config:get-startup-snapshot', () => {
return configManager.getStartupSnapshot()
})
ipcMain.handle('config:get', (event, key) => {
return configManager.get(key)
})
ipcMain.on('config:set', (event, key, value) => {
configManager.set(key, value)
})
ipcMain.handle('config:get-many', (event, keys) => {
return configManager.getMany(keys)
})
}
```
注意 `config:set` 用的是 `ipcMain.on`(单向),不是 `ipcMain.handle`(双向)。这是 fire-and-forget 的关键:调用方不等待响应,完全不阻塞。
**删除的旧频道(共 5 个):**
```
get-store-data ← 已删除
set-store-data ← 已删除
get-feature-limit-data ← 已删除(featureLimit 并入主配置)
set-feature-limit-data ← 已删除
get-batch-store-data ← 已删除(替换为 config:get-many)
```
### 3.4 主进程入口:启动顺序很重要
```js
// src/main/index.js
import { app } from 'electron'
import { configManager } from './config/config-manager.js'
import { registerConfigListeners } from './config/config-listeners.js'
configManager.load()
app.on('ready', () => {
registerConfigListeners()
createMainWindow()
createLoginWindow()
})
app.on('before-quit', () => {
configManager.flushSync()
})
```
### 3.5 configStore — 渲染进程本地快照
```js
import { ipcRenderer } from 'electron'
const _snapshot = {}
const configStore = {
hydrate(snapshot) {
Object.assign(_snapshot, snapshot)
},
get(key, defaultVal) {
const val = key.split('.').reduce((obj, k) => obj?.[k], _snapshot)
return val !== undefined ? val : defaultVal
},
set(key, value) {
const keys = key.split('.')
let obj = _snapshot
for (let i = 0; i < keys.length - 1; i++) {
if (obj[keys[i]] === undefined) obj[keys[i]] = {}
obj = obj[keys[i]]
}
obj[keys[keys.length - 1]] = value
ipcRenderer.send('config:set', key, value)
},
async getFresh(key) {
return ipcRenderer.invoke('config:get', key)
}
}
export default configStore
```
### 3.6 bootstrap — 所有渲染窗口的统一启动入口
```js
import Vue from 'vue'
import { ipcRenderer } from 'electron'
import configStore from './config-store.js'
export async function createHydratedVueApp({ App, store, i18n, mount = '#app' }) {
const snapshot = await ipcRenderer.invoke('config:get-startup-snapshot')
configStore.hydrate(snapshot)
if (i18n) {
i18n.locale = configStore.get('setLanguage', 'zh')
}
if (store) {
store.commit('accounts/SET_LIST', configStore.get('accounts', []))
store.commit('accounts/SET_ACTIVE', configStore.get('activeAccountId', ''))
}
return new Vue({
i18n,
store,
render: h => h(App)
}).$mount(mount)
}
```
**为什么要统一启动函数?**
Vue 实例创建时,computed 属性、watch 监听会立即执行,它们可能依赖 `configStore.get('setLanguage')` 或 Vuex 中的账号数据。如果快照还没到就创建 Vue 实例,初始值就是错的,而且第一次渲染后不会自动重渲染(Vue 2 没有 Suspense)。`createHydratedVueApp` 确保所有依赖数据就绪后才创建 Vue 实例。
### 3.7 Webview 注入脚本 — dataAccess.js
Webview 注入脚本(Node 集成已开启)直接通过 `ipcRenderer` 访问主进程,使用统一的 `config:*` 频道:
```js
// src/inject/web/whatsapp/behavior/utils/dataAccess.js
const { ipcRenderer } = window.require('electron')
export async function getStoreData(key) {
return ipcRenderer.invoke('config:get', key)
}
export function setStoreData(key, value) {
ipcRenderer.send('config:set', key, value)
}
export async function getBatchStoreData(keys) {
return ipcRenderer.invoke('config:get-many', keys)
}
const _cache = new Map()
const _expiry = new Map()
const DEFAULT_TTL = 5000
export async function getCachedStoreData(key, ttl = DEFAULT_TTL) {
const now = Date.now()
if (_cache.has(key) && now < _expiry.get(key)) {
return _cache.get(key)
}
const val = await getStoreData(key)
_cache.set(key, val)
_expiry.set(key, now + ttl)
return val
}
```
### 3.8 UtilityProcess 的配置通信:parentPort + Config Bridge
#### 3.8.1 UtilityProcess 与渲染进程的本质区别
Electron 22+ 提供的 `utilityProcess` 是一个独立的 Node.js 子进程,与渲染进程有一个关键区别:
| 进程 | IPC 接口 | 特点 |
|------|---------|------|
| 渲染进程 | `ipcRenderer` | 可直接 `invoke` / `send` |
| UtilityProcess | `parentPort`(MessagePort 接口) | 只有 `postMessage` / `on('message')` |
这意味着渲染进程中的 `configStore.get()` / `configStore.set()` 在 UtilityProcess 里完全不可用,需要一套独立的通信机制。
#### 3.8.2 启动时快照:CLI 参数传递
UtilityProcess 启动时,主进程通过命令行参数传递初始配置:
```js
const child = utilityProcess.fork(serverBundlePath, [
'--language', configManager.get('setLanguage', 'zh'),
'--data-dir', app.getPath('userData'),
], { stdio: 'pipe' })
```
```js
function parseArgv() {
const opts = { language: 'zh' }
const args = process.argv.slice(2)
for (let i = 0; i < args.length; i++) {
if ((args[i] === '-l' || args[i] === '--language') && args[i + 1]) {
opts.language = args[++i]
}
}
return opts
}
```
CLI 参数适合**低频、结构稳定**的配置(版本号、数据目录)。但用户随时可以切换语言,CLI 参数只传一次,无法覆盖运行时变化——这是问题所在。
#### 3.8.3 Config Bridge:运行时推送机制
对于运行时可能变化的 key,需要一套"配置桥":主进程订阅 `configManager` 的变化,过滤出关心的 key,再通过 `process.postMessage()` 推送给 UtilityProcess。
**主进程端**(http-service.js 中 UtilityProcess 的管理类):
```js
const WATCHED_KEYS = [ 'LogDebug', 'userToken', 'userInfo', 'userId', 'accounts', 'activeAccountId', 'quotaConfig', 'quotaUsed', 'setLanguage', // ← 语言切换需要实时同步]
configManager.subscribe((key, value, oldValue) => {
if (!WATCHED_KEYS.includes(key)) return
this.process.postMessage({
channel: 'main-config:update',
key,
value,
oldValue
})
})
```
**UtilityProcess 端**(使用 `parentPort`):
```js
const { parentPort } = require('electron')
const localCache = new Map()
const watchers = new Map()
parentPort.on('message', (event) => {
const msg = event.data
if (msg.channel === 'main-config:update') {
const oldValue = localCache.get(msg.key)
localCache.set(msg.key, msg.value)
watchers.get(msg.key)?.forEach(cb => cb(msg.value, oldValue))
}
})
function watchConfig(key, callback) {
if (!watchers.has(key)) watchers.set(key, new Set())
watchers.get(key).add(callback)
return () => watchers.get(key).delete(callback)
}
```
#### 3.8.4 双向请求:UtilityProcess 主动拉取配置
除被动接收推送外,UtilityProcess 也可以主动向主进程请求任意配置。由于 `parentPort.postMessage` 是单向的,需要用 `msgId` 手动实现 Promise 化的请求-响应:
```js
const pending = new Map()
let _seq = 0
function getConfig(key) {
return new Promise((resolve, reject) => {
const msgId = ++_seq
pending.set(msgId, { resolve, reject })
parentPort.postMessage({ channel: 'main-config', msgId, action: 'get', key })
setTimeout(() => {
if (pending.has(msgId)) {
pending.delete(msgId)
reject(new Error(`config IPC timeout: ${key}`))
}
}, 5000)
})
}
parentPort.on('message', (event) => {
const msg = event.data
if (msg.channel === 'main-config:response') {
const p = pending.get(msg.msgId)
if (p) {
pending.delete(msg.msgId)
p.resolve(msg.data)
}
}
})
```
**主进程**响应 UtilityProcess 的请求:
```js
this.process.on('message', (msg) => {
if (msg.channel === 'main-config' && msg.action === 'get') {
this.process.postMessage({
channel: 'main-config:response',
msgId: msg.msgId,
data: configManager.get(msg.key)
})
}
})
```
#### 3.8.5 实际案例:为 setLanguage 添加实时同步
**现象**:用户在界面切换语言后,HTTP 服务内的 i18n 不更新,API 返回的错误文案仍是旧语言。
**根因**:`WATCHED_KEYS` 列表中没有 `setLanguage`,Config Bridge 过滤掉了该 key 的变化,UtilityProcess 从未收到推送。
**修复**:两步操作。
第一步,将 `setLanguage` 加入 `WATCHED_KEYS`:
```js
const WATCHED_KEYS = [ // ...原有 key 'setLanguage', // ← 新增]
```
第二步,在 UtilityProcess 中 watch 该 key,触发 i18n 重载:
```js
watchConfig('setLanguage', (newLang, oldLang) => {
i18n.setLocale(newLang)
logger.info(`language switched: ${oldLang} → ${newLang}`)
})
```
#### 3.8.6 完整消息流(语言切换全链路)
```
用户点击"切换语言"按钮(renderer)
→ configStore.set('setLanguage', 'en')
→ 本地快照立即更新(UI 语言瞬间切换)
→ ipcRenderer.send('config:set', 'setLanguage', 'en') [fire-and-forget]
主进程 config:set handler
→ configManager.set('setLanguage', 'en')
→ 内存更新
→ 通知所有订阅者(包括 Config Bridge)
→ scheduleFlush() [2s debounce → 写入 config.json]
Config Bridge(主进程内)
→ 检查 'setLanguage' 是否在 WATCHED_KEYS → 是
→ utilityProcess.postMessage({ channel: 'main-config:update', key: 'setLanguage', value: 'en' })
UtilityProcess(full.server.js)
→ parentPort.on('message') 收到 main-config:update
→ localCache.set('setLanguage', 'en')
→ 触发 watchers['setLanguage']
→ i18n.setLocale('en') ← HTTP 服务端 i18n 更新完成
```
整个链路用户无感知延迟:renderer 侧语言切换是同步的,UtilityProcess 侧在毫秒级完成同步。
#### 3.8.7 三类进程的配置访问模式对比
| 进程类型 | IPC 接口 | 读取配置 | 写入配置 | 实时订阅 |
|---------|---------|---------|---------|---------|
| 主进程 | 无(直接内存) | `configManager.get()` | `configManager.set()` | `configManager.subscribe()` |
| 渲染进程 | `ipcRenderer` | `configStore.get()`(本地快照,零 I/O) | `configStore.set()`(fire-and-forget IPC) | `webContents.send()` 推送 → Vuex mutation |
| UtilityProcess | `parentPort` | `getConfig(key)`(Promise 化请求)或本地缓存 | `parentPort.postMessage()`(请求主进程) | Config Bridge 推送 → `watchConfig()` |
---
## 四、响应式状态:替代 onDidChange
旧方案中,各进程通过 `electron-store` 的 `watch: true` 监听文件变化来同步状态。新方案彻底删除文件监听,改用**显式业务事件**:
```js
// 主进程:账号发生变化时,主动推送给 renderer
function notifyAccountsChanged(accounts) {
BrowserWindow.getAllWindows().forEach(win => {
win.webContents.send('app:accounts-changed', accounts)
})
}
function notifyActiveAccountChanged(id) {
BrowserWindow.getAllWindows().forEach(win => {
win.webContents.send('app:active-account-changed', id)
})
}
```
```js
ipcRenderer.on('app:accounts-changed', (event, accounts) => {
store.commit('accounts/SET_LIST', accounts)
})
ipcRenderer.on('app:active-account-changed', (event, id) => {
store.commit('accounts/SET_ACTIVE', id)
})
```
**为什么不用 onDidChange?**
`watch: true` 意味着每次写入 config.json 都会触发所有实例的回调,造成不必要的 IPC 风暴。显式事件只在业务确实需要时发出,语义更清晰,性能更好。
**Vuex 与 config.json 的职责划分:**
```
Vuex → renderer 运行期响应式状态(内存,窗口关闭即失)
config.json → 持久化结果(只存结构固定、跨会话需要的数据)
两者不耦合,Vuex mutation 可能同时触发 configStore.set,也可能不触发
```
---
## 五、config.json 数据范围治理
一个常见的反模式是把所有业务数据都堆进 config.json,导致文件越来越大,读写越来越慢。
| 数据类型 | 存入 config.json | 说明 |
|---------|-----------------|------|
| 应用偏好 | ✅ | darkMode, safeMode, LogDebug... |
| 语言设置 | ✅ | setLanguage, setPreferredSystemLanguage |
| 登录态 | ✅ | userToken, userId, userInfo, rememberMe |
| 账号状态 | ✅ | accounts, activeAccountId(结构固定) |
| 速率限制配置 | ✅ | rateLimitConfig(结构固定,不随账号数增长) |
| 功能配额 | ✅ | quotaConfig / quotaUsed(并入主配置) |
| 任务结果缓存 | ❌ 迁出 | taskResult::{userId} → SQLite |
| 数据备份 | ❌ 迁出 | backup_{userId} → SQLite |
| 用户语言偏好 | ❌ 迁出 | userLang_{userId} → SQLite |
**判断标准:** 如果一个 key 会随着用户数据增长而无限膨胀(如每个联系人一条记录),它不属于 config.json,应该进 SQLite。
---
## 六、风险与应对
| 风险 | 处理方式 |
|------|---------|
| fire-and-forget 失败,本地快照与主进程不一致 | 本地快照先更新保证 UI 不阻塞;IPC 失败静默记日志;下次 set 覆盖 |
| debounce 期间进程崩溃,最近 2s 写入丢失 | 关键操作后可额外发送 `config:flush` 频道触发立即写入 |
| 启动快照过大拖慢首屏 | STARTUP_KEYS 严格控制,非关键数据排除在外 |
| 多窗口同时写同一 key | 主进程按到达顺序取最后一次写入,单用户场景无冲突 |
---
## 七、迁移步骤总结
如果你的项目也在用 electron-store 并且遇到了类似问题,以下是迁移路线:
**第一步:收拢数据所有权**
```
删除所有非主进程的 new Store() 实例
主进程保留唯一的 ConfigManager 单例
```
**第二步:统一 IPC 频道**
```
删除所有旧的 get/set IPC 频道
只保留 config:get-startup-snapshot / config:get / config:set / config:get-many
```
**第三步:引入启动快照**
```
主进程:load() → registerConfigListeners() → 创建窗口
渲染进程:invoke('config:get-startup-snapshot') → hydrate() → new Vue()
```
**第四步:替换响应式**
```
删除 watch: true 和所有 onDidChange
改用显式业务事件推送账号变化等状态
```
**第五步:清理配置边界**
```
识别并迁出增长型业务数据到 SQLite
config.json 只保留结构固定的配置项
```
---
## 八、效果对比
| 指标 | 迁移前 | 迁移后 |
|------|--------|--------|
| 首屏磁盘 I/O 次数 | 1(主进程)+ N(webview 各一次)| 1(主进程 load) |
| 渲染进程读配置 | 同步 IPC(阻塞渲染) | 同步内存读取 |
| 渲染进程写配置 | 同步 IPC(阻塞渲染) | fire-and-forget,立即返回 |
| IPC 频道数量 | 8+ 个散乱频道 | 4 个语义清晰频道 |
| Schema 定义位置 | shared/storage + renderer/storage(重复) | config-defaults.js(唯一) |
| 文件监听实例数 | N 个(每 Store 实例一个) | 0 |
---
## 九、完整目录结构
```
src/
├── main/
│ └── config/
│ ├── config-defaults.js # 默认值 + STARTUP_KEYS(唯一 schema 定义)
│ ├── config-manager.js # ConfigManager 单例(文件 I/O 唯一入口)
│ └── config-listeners.js # IPC 注册(仅 4 个频道)
│
├── renderer/
│ └── config/
│ ├── config-store.js # 本地快照:hydrate / get / set / getFresh
│ └── bootstrap.js # createHydratedVueApp()(统一启动入口)
│
└── inject/
└── web/whatsapp/behavior/utils/
└── dataAccess.js # getStoreData / setStoreData / getBatchStoreData / getCachedStoreData
```
---
## 十、结语
这个方案没有引入任何新的依赖,也没有用到 SharedArrayBuffer 或 Worker Threads 这类复杂机制。它只是把一个被滥用的库(electron-store)的职责收拢到正确的地方——主进程——然后用最简单的 IPC + 内存快照模式把数据分发出去。
核心思路可以迁移到任何 Electron 项目:
> **谁拥有文件系统,谁就是配置的真相来源。其他进程只是它的镜像。**
如果你的项目也在经历 electron-store 的多进程乱象,希望这篇文章能给你一个清晰的参考路径。