彻底解决 Electron 多进程配置共享难题:从 electron-store 并发写入到零阻塞内存快照架构

2 阅读12分钟
# 彻底解决 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
// src/shared/storage/index.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
// src/renderer/pages/main/storage/index.js(renderer 进程再建一个)
import Store from 'electron-store'

const storage = new Store({
  watch: true,
  schema: { /* 和上面几乎重复的 schema 定义 */ },
  defaults: { /* 几乎重复的默认值 */ }
})

export default storage
```

```js
// src/inject/web/whatsapp/behavior/utils/dataAccess.js(webview 注入脚本)
const { ipcRenderer } = window.require('electron')

export async function getStoreData(key) {
  return await ipcRenderer.invoke('get-store-data', key)
}
// webview 通过 IPC 访问主进程,主进程再转发给自己的 Store 实例
```

而主进程 IPC handler 大概是这样的:

```js
// main 进程(伪代码还原)
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
// src/shared/storage/freeTierFeatureLimit.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.json4 个 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
  }

  /**
   * 同步读取 config.json,必须在 app.on('ready') 之前、窗口创建前调用。
   * 只调用一次。
   */
  load() {
    try {
      const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
      this._data = { ...CONFIG_DEFAULTS, ...JSON.parse(raw) }
    } catch {
      // 首次运行,文件不存在
      this._data = { ...CONFIG_DEFAULTS }
    }
  }

  /**
   * 支持点分路径读取,如 'wdTryLimit.verifyLimit'
   */
  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)]))
  }

  /**
   * 返回启动快照:只含 STARTUP_KEYS 中的 key,避免传输不必要数据
   */
  getStartupSnapshot() {
    return this.getMany(STARTUP_KEYS)
  }

  /**
   * app.on('before-quit') 中调用,确保 debounce 未触发时数据不丢失
   */
  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
// src/main/config/config-defaults.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: {},
}

/**
 * 必须在 Vuex / i18n / 请求拦截器初始化前可用的 key。
 * 这些 key 会在启动快照中传给所有进程。
 */
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
// src/main/config/config-listeners.js

import { ipcMain } from 'electron'
import { configManager } from './config-manager.js'

export function registerConfigListeners() {
  // 启动时一次性获取快照(每个进程只调用一次)
  ipcMain.handle('config:get-startup-snapshot', () => {
    return configManager.getStartupSnapshot()
  })

  // 低频按需读取单个 key(如支付回调后刷新配额)
  ipcMain.handle('config:get', (event, key) => {
    return configManager.get(key)
  })

  // fire-and-forget 写入(不返回 promise,不阻塞调用方)
  ipcMain.on('config:set', (event, key, value) => {
    configManager.set(key, value)
  })

  // 批量读取(减少 IPC 往返次数)
  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'

// 第一步:同步读取配置(窗口创建前,app.ready 前都可以)
configManager.load()

app.on('ready', () => {
  // 第二步:注册 IPC 频道(窗口创建前注册,避免竞态)
  registerConfigListeners()

  // 第三步:创建窗口
  createMainWindow()
  createLoginWindow()
})

app.on('before-quit', () => {
  // 退出前同步刷盘,防止 debounce 2s 未触发就关了
  configManager.flushSync()
})
```

### 3.5 configStore — 渲染进程本地快照

```js
// src/renderer/config/config-store.js

import { ipcRenderer } from 'electron'

const _snapshot = {}

const configStore = {
  /**
   * bootstrap 调用一次,将主进程快照注入本地
   */
  hydrate(snapshot) {
    Object.assign(_snapshot, snapshot)
  },

  /**
   * 同步读取本地快照,零 I/O,可在任意同步代码中调用
   */
  get(key, defaultVal) {
    const val = key.split('.').reduce((obj, k) => obj?.[k], _snapshot)
    return val !== undefined ? val : defaultVal
  },

  /**
   * 本地快照立即更新(UI 不等待),同时 fire-and-forget 通知主进程
   */
  set(key, value) {
    // 本地快照先更新,保证 UI 响应性
    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

    // 通知主进程异步持久化(不 await,不阻塞)
    ipcRenderer.send('config:set', key, value)
  },

  /**
   * 显式从主进程内存拉取最新值。
   * 仅在需要强一致性时使用(如支付成功后刷新配额)。
   */
  async getFresh(key) {
    return ipcRenderer.invoke('config:get', key)
  }
}

export default configStore
```

### 3.6 bootstrap — 所有渲染窗口的统一启动入口

```js
// src/renderer/config/bootstrap.js

import Vue from 'vue'
import { ipcRenderer } from 'electron'
import configStore from './config-store.js'

/**
 * 所有使用 Vuex 的渲染窗口必须通过此函数启动。
 * 禁止直接 new Vue()。
 */
export async function createHydratedVueApp({ App, store, i18n, mount = '#app' }) {
  // 从主进程获取启动快照(主进程在窗口创建前已 load(),IPC 永远可用)
  const snapshot = await ipcRenderer.invoke('config:get-startup-snapshot')

  // 注水:将快照写入本地
  configStore.hydrate(snapshot)

  // 初始化 i18n(必须在 Vue 实例创建前)
  if (i18n) {
    i18n.locale = configStore.get('setLanguage', 'zh')
  }

  // 初始化 Vuex store(从快照中读取账号等状态)
  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')

// 只有四个函数,清晰对应四个 IPC 频道

export async function getStoreData(key) {
  return ipcRenderer.invoke('config:get', key)
}

export function setStoreData(key, value) {
  // fire-and-forget,和 renderer 侧保持一致
  ipcRenderer.send('config:set', key, value)
}

export async function getBatchStoreData(keys) {
  return ipcRenderer.invoke('config:get-many', keys)
}

// 带 TTL 缓存:webview 中部分数据读取非常频繁(如每条消息都检查 LogDebug)
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
// 主进程:fork UtilityProcess,传递启动时所需的配置
const child = utilityProcess.fork(serverBundlePath, [
  '--language', configManager.get('setLanguage', 'zh'),
  '--data-dir', app.getPath('userData'),
], { stdio: 'pipe' })
```

```js
// UtilityProcess 入口:解析 CLI 参数
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
// WATCHED_KEYS 控制哪些 key 会被推送给 UtilityProcess
const WATCHED_KEYS = [  'LogDebug',  'userToken',  'userInfo',  'userId',  'accounts',  'activeAccountId',  'quotaConfig',  'quotaUsed',  'setLanguage',  // ← 语言切换需要实时同步]

// 订阅 configManager 变化,按需推送
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()  // key → Set<callback>

parentPort.on('message', (event) => {
  const msg = event.data  // 注意:UtilityProcess 收到的是 event.data,不是 event 本身

  if (msg.channel === 'main-config:update') {
    const oldValue = localCache.get(msg.key)
    localCache.set(msg.key, msg.value)
    // 触发该 key 的所有 watch 回调
    watchers.get(msg.key)?.forEach(cb => cb(msg.value, oldValue))
  }
})

// 对外暴露 watch API
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
// UtilityProcess:Promise 化的主动拉取
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 })

    // 超时兜底,防止主进程无响应时 Promise 永久挂起
    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
// UtilityProcess 启动时注册 watch
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
// renderer:接收推送,提交到 Vuex
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 的多进程乱象,希望这篇文章能给你一个清晰的参考路径。