知行札记
专题技术实践桌面应用实践Electron 桌面应用

本地状态与持久化

安排窗口状态、共享事实、数据库、目录和凭据。

本页整合 Electron 归档分稿中的机制、接口和接线。归档使用 Electron 44 系列作为其版本讨论背景;本文接口依据官方文档及 breaking changes 核查,实际采用时仍需固定项目版本。包版本、维护状态和原稿实测均是对应日期的历史信息。本次未启动 Electron、构建安装包、签名、公证或验证更新。代码块按各自标注区分片段与组合示例。

版本背景:截至 2026-10-04,Electron 最新稳定版为 44.x(2026-08-25 发布,Chromium 152 / Node 24),官方支持窗口内为 42/43/44 三个大版本;Electron 40(2026-01-15)起内置 Node 24。本章所有版本号与维护状态均以文末来源为准,无法核实的标注「待核实」。

3.1 原理与心智模型:四层状态

一个典型的 Electron 桌面应用,本地状态可以拆成四层,每层的读写路径、生命周期与选型逻辑完全不同:

  1. 渲染进程 UI 状态:视图状态(选中项、面板开合、表单草稿),生命周期跟随窗口,单窗口内闭合,用 React/Vue 生态的状态库管理。
  2. 主进程应用状态:窗口注册表、托盘、全局快捷键、原生服务(更新器、网络监测)、权限缓存。这是整个应用的「单一事实来源(Single Source of Truth)」,多窗口共享,通过 IPC 推送/拉取。
  3. 持久化数据:配置项、用户内容、缓存。落在 app.getPath('userData') 下的文件或数据库,跨启动存活。
  4. 敏感凭据:token、密码、私钥。永远不与普通数据混放,走 safeStorage + 系统钥匙串。

这个分层的核心动机来自 Electron 的进程模型:主进程(main)是常驻的 Node.js 进程,持有全部原生能力;每个 BrowserWindow 是独立的沙箱化 Chromium 渲染进程,默认 nodeIntegration: false、sandbox: true(自 Electron 20 起 preload 也被沙箱化)。因此渲染进程天然接触不到文件系统与原生 API,任何持久化与原生状态都必须经由 IPC——这是后面所有选型的前提。

3.2 渲染进程 UI 状态库选型

3.2.1 候选库现状(2026-10-04 查询 npm registry)

库最新版发布日期模型适配框架
Zustand5.0.152026-08-13不可变 store + hooks,无 ProviderReact(也可 vanilla)
Redux Toolkit (RTK)2.13.02026-09-29action/reducer + Immer + RTK QueryReact 为主
Valtio2.3.22026-05-01Proxy 可变快照React
Jotai3.0.12026-09-29原子化(atom)自底向上React
Pinia4.0.32026-08-12store 组合式 APIVue

社区调查(State of React / State of JS 2025 的二手转述)显示 Zustand 在「留存率/兴趣度」上居首,Redux Toolkit 在「使用量」上居首;该结论来自二手汇总,待核实(见参考来源 tech-insider 一文)。

3.2.2 桌面场景与 Web 的差异

桌面应用没有 SSR/hydration 问题,但有几个 Web 上不突出的取舍点:

  • 持久化默认落点变了。Zustand persist 中间件默认写入 localStorage,而 Electron 里 localStorage 属于 Chromium session 存储,落在 sessionData(默认即 userData)目录,且跟随 partition 隔离。若用户清理缓存或你重定向了 sessionData,UI 状态会一起丢——重要的用户数据不应只存 localStorage。
  • 单窗口单实例 store 就够了。桌面没有多标签页共享需求,但有多窗口需求:每个窗口加载各自的 bundle,store 互不共享,跨窗口同步必须回到主进程(见 3.3)。
  • DevTools 时间旅行在桌面调试中价值很高。RTK + Redux DevTools 有完整 action 时间线;Zustand 的 devtools 中间件可用但默认没有具名 action,历史记录较薄(二手结论,见 theroadtoenterprise 对比文)。
  • RTK Query 在桌面仍有意义:对接后端 API、缓存与轮询逻辑开箱即用;纯本地应用(Zustand/Valtio/Jotai)则用不上这块,选 RTK 的收益下降。

选型建议(2026):新项目、中小型桌面应用,React 技术栈首选 Zustand(轻、无 Provider、persist 中间件内置版本化迁移);大型团队、已有 Redux 经验或重度依赖 RTK Query/严格 action 审计,继续 RTK;Vue 项目用 Pinia(持久化配 pinia-plugin-persistedstate,最新 4.7.1);Valtio/Jotai 适合喜欢可变代理/原子粒度模型的团队,与桌面特性无特殊关联。

3.2.3 关键示例:Zustand persist 带版本迁移

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface UiState {
  sidebarOpen: boolean;
  lastWorkspacePath?: string;
  setSidebar: (open: boolean) => void;
}

export const useUiStore = create<UiState>()(
  persist(
    (set) => ({
      sidebarOpen: true,
      setSidebar: (sidebarOpen) => set({ sidebarOpen }),
    }),
    {
      name: 'app-ui',                    // storage key,必须唯一
      storage: createJSONStorage(() => localStorage),
      // 只持久化需要的字段,函数/DOM 引用一律排除
      partialize: (s) => ({ sidebarOpen: s.sidebarOpen, lastWorkspacePath: s.lastWorkspacePath }),
      version: 2,                        // 持久化 schema 版本号
      migrate: (persisted, version) => {
        // version 不匹配时才触发;旧版本逐级升级
        if (version === 1) {
          return { ...(persisted as object), lastWorkspacePath: undefined };
        }
        return persisted as UiState;
      },
    },
  ),
);

要点:version 记录在持久化数据里,与当前不匹配时旧数据默认不直接使用,migrate(persistedState, version) 负责迁移——这是官方文档明确的行为。注意 migrate 只在版本不匹配时执行,首次安装(无历史数据)不会调用。

3.3 主进程状态:单一事实来源与多窗口广播

3.3.1 原理

窗口管理、托盘、全局快捷键、原生功能只能活在主进程。官方 IPC 教程给出的通信模式是:

  • renderer → main:ipcRenderer.invoke + ipcMain.handle(两向、返回 Promise,官方推荐);避免 sendSync(同步阻塞渲染进程直至返回)。
  • main → renderer:webContents.send + ipcRenderer.on;没有 main→renderer 的 invoke 等价物,回执需 renderer 另行 send。
  • renderer ↔ renderer:没有直连通道,要么以主进程为消息代理,要么由主进程通过 MessageChannelMain 把一对 MessagePortMain 分别交给两个窗口,建立直连。

因此多窗口同步的惯用法是:主进程持有 canonical state + 事件广播。所有变更必须先经主进程写入,再由主进程扇出(fan-out)给所有窗口;禁止窗口 A 直接 invoke 改完自己改内存、指望窗口 B 「以后拉取」——那会立刻出现两份事实。

// main.ts —— 极简的主进程状态总线
import { BrowserWindow, ipcMain } from 'electron';

class AppState {
  private windows = new Map<string, BrowserWindow>();
  private state = { online: true, activeProject: null as string | null };

  register(id: string, win: BrowserWindow) {
    this.windows.set(id, win);
    win.on('closed', () => this.windows.delete(id));
  }

  private broadcast(event: string, payload: unknown, exceptWebContentsId?: number) {
    for (const win of BrowserWindow.getAllWindows()) {
      if (win.isDestroyed()) continue;
      if (win.webContents.id === exceptWebContentsId) continue; // 可选:跳过发起方
      win.webContents.send(event, payload);
    }
  }

  patch(patch: Partial<AppState['state']>, originWebContentsId?: number) {
    this.state = { ...this.state, ...patch };
    this.broadcast('app:state-changed', this.state, originWebContentsId);
  }

  snapshot() { return this.state; }
}

export const appState = new AppState();

ipcMain.handle('app:get-state', () => appState.snapshot());
ipcMain.on('app:patch-state', (event, patch) => appState.patch(patch, event.sender.id));
// preload.ts —— contextBridge 只暴露最小 API,不透传 ipcRenderer
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('appApi', {
  getState: () => ipcRenderer.invoke('app:get-state'),
  patchState: (patch: unknown) => ipcRenderer.send('app:patch-state', patch),
  // 官方文档明确警告:不要把 callback 原样传给 ipcRenderer.on,
  // 会通过 event.sender 泄漏 ipcRenderer;必须包装并剥离 event
  onStateChanged: (cb: (s: unknown) => void) => {
    const listener = (_e: Electron.IpcRendererEvent, s: unknown) => cb(s);
    ipcRenderer.on('app:state-changed', listener);
    return () => ipcRenderer.removeListener('app:state-changed', listener);
  },
});

渲染侧把 onStateChanged 的推送接到 Zustand/RTK 里,即形成「主进程是唯一写入点,渲染层是投影」的单向数据流。IPC 只能传结构化克隆可序列化的数据(Element、BrowserWindow 等 DOM/原生对象不可传),错误经 handle 抛出时也只有 message 到达渲染端——这两点都是官方文档明示的约束。

3.3.2 重状态:utilityProcess 隔离

大量数据(如全文索引、加密、图片处理)不建议直接放主进程——主进程被阻塞会卡住所有窗口的 IPC 与窗口管理。官方 utilityProcess 模块等价于 Node 的 child_process.fork(基于 Chromium Services API),只能在主进程 fork,但可以把一个 MessagePortMain 转移给 webContents,让渲染进程与该子进程直连而不经过主进程中转。把 SQLite 等重 I/O 放进 utility process 是 2025-2026 年逐渐流行的做法(配套 better-sqlite3 的 worker 线程支持)。

3.3.3 单实例锁:多实例并发写的第一道防线

app.requestSingleInstanceLock() 返回 boolean:拿到锁的实例继续运行,未拿到的实例应立即退出,其启动参数通过主实例的 second-instance 事件送达,签名为 (event, argv, workingDirectory, additionalData)。两个细节(均出自官方文档):argv 可能被 Chromium 追加/重排 flag(如 --original-process-start-time),精确参数应走 additionalData;macOS/Linux 上该消息上限 32MB,超限直接丢弃且不触发事件。对绝大多数桌面应用,拿到锁 + 单实例是避免「两个进程同时写同一个配置文件/数据库」的最简单解法。

3.4 本地持久化方案对比

3.4.1 选型总表

方案运行位置原生依赖适用规模多实例并发写2026 维护状态
electron-store / conf(JSON)main无KB 级配置弱(整文件覆盖写)v11.0.2(2025-10),ESM-only,要求 Electron 30+
自管 JSON 文件main无KB~几 MB取决于实现(原子写 + 单写者)VS Code 自用方案,持续演进
better-sqlite3main / utilityProcess原生模块(需 rebuild)GB 级、复杂查询WAL 模式下多连接读写尚可v13.0.3(2026-08),活跃
node:sqlite(内置)main无(Node 内置)中小规模同 SQLite,但 API 同步、实验性Node 24 中 Stability: 1.2(实验性)
sql.js(WASM)任意进程无中小、免编译场景差(需手动导出落盘)v1.14.2(2026-08)
IndexedDBrenderer无渲染侧缓存/离线数据随 session 隔离Chromium 内置,随版本更新
LevelDB(level)main / utilityProcess原生(可配 pure JS)键值型中等数据单进程独占v10.0.0(2025-04)

3.4.2 JSON 配置:electron-store 与自管文件

electron-store 仍是「小配置」的默认答案,但 2026 年使用它必须知道三件事(均出自其 README):

  1. 只在主进程用。README 明确:由于 Electron 默认禁用 renderer 的 Node 集成、自 Electron 20 起 preload 也被沙箱化,在 renderer/preload 引入会报 Can't resolve 'fs';官方建议「keep the store in the main process and expose it over IPC」(ipcMain.handle + ipcRenderer.invoke)。
  2. 它不是数据库:「This package is not a database. It simply uses a JSON file that is read/written on every change」——每次变更全量读写 userData/config.json,只适合用户设置、轻量缓存;大 blob 应存盘后在 store 里存路径。
  3. 包是 native ESM,不再提供 CommonJS 导出;v11.0.2 要求 Electron 30+。

其底层 conf(v15.1.0)的 README 承诺「Changes are written to disk atomically, so if the process crashes during a write, it will not corrupt the existing config」——即写坏的是临时文件,旧配置仍在。

自管 JSON 的标杆是 VS Code。其主进程 StateService(src/vs/platform/state/node/stateService.ts,本次调研直接读取了该源码)的 FileStorage 类做对了几件小事,值得照抄:

  • 节流写入:ThrottledDelayer 把保存合并到 100ms 窗口内,高频 setItem 不会打爆磁盘;
  • 内容比对跳过无效写:序列化结果与 lastSavedStorageContents 相同则直接返回;
  • 原子写:writeFile(..., { atomic: { postfix: '.vsctmp' } }),先写临时文件再改名;
  • 优雅关闭:close() 以 0 延迟 flush 挂起的写入,保证退出前落盘;
  • 初始化未完成前不写(避免把空 state 覆盖到磁盘上)。
// 自管 JSON 的最小可靠实现(节选自 VS Code FileStorage 的思路)
private async doSave(): Promise<void> {
  await this.initializing;                      // 等 init 完成,防止覆盖
  const serialized = JSON.stringify(this.storage, null, 4);
  if (serialized === this.lastSavedStorageContents) return;  // 内容没变不写
  await this.fileService.writeFile(this.storagePath,
    VSBuffer.fromString(serialized),
    { atomic: { postfix: '.vsctmp' } });        // 临时文件 + rename
  this.lastSavedStorageContents = serialized;
}

3.4.3 SQLite:better-sqlite3、node:sqlite、sql.js

用户内容(聊天记录、文档库、索引)超过几十 KB 就应升级到 SQLite:

  • better-sqlite3(v13.0.3,2026-08-05 活跃维护):同步 API、事务支持、官方 README 称其「much faster than node-sqlite3 in most cases」并支持 worker 线程。代价是原生模块:Electron 内置 Node 的 ABI 与系统 Node 不同,必须用 @electron/rebuild 或 electron-builder install-app-deps 为 Electron 版本重编/下载对应 prebuilt;忘记 rebuild 的典型报错是 NODE_MODULE_VERSION 115 ... requires NODE_MODULE_VERSION 118(WiseLibs issue #1111 原文)。CI 与本地每次 npm install 后都要保证 rebuild 脚本执行。
  • node:sqlite(Node 内置,DatabaseSync):无原生依赖、无需 rebuild 是它对 Electron 最大的吸引力。Electron 40+ 内置 Node 24,可直接 import { DatabaseSync } from 'node:sqlite'。但 Node 24 官方文档将其标为 Stability: 1.2(实验性),且目前只有同步 API;用 Vite 打包主进程时还需把它标记为 external,否则会报 "Module 'node:sqlite' has been externalized for browser compatibility"(vitejs/discussions/19278)。生产采用前需自评实验性风险。
  • sql.js(v1.14.2,2026-08-14):SQLite 的 WASM 编译,零原生依赖、任意进程可跑,但数据库在内存里,持久化要自己 export() 二进制再写盘——适合只读分析、嵌入式场景,不适合频繁写入的主存储。

schema 迁移:SQLite 惯用 PRAGMA user_version 记录 schema 版本,启动时逐版本执行迁移脚本并递增;配合事务保证迁移原子性。Zustand persist 侧则用 version + migrate(见 3.2.3)。electron-store 自身不带迁移器,惯例是在文件里放一个 schemaVersion 字段手写升级链。

多实例并发写:JSON 文件没有并发控制,两个进程同时写就是 last-writer-wins,原子写只能防「写坏」防不了「写丢」——所以要么单实例锁(3.3.3),要么把文件的所有权收归一个进程(主进程或 utility process),其他进程经 IPC 访问。SQLite 侧开启 PRAGMA journal_mode = WAL 可让读写并发,但跨进程多连接仍会遇到 SQLITE_BUSY,需要 busy timeout 与重试;经验上**「单写者进程 + IPC/MessagePort 服务化」比「多连接并发写」省心得多**(工程惯例总结,非官方文档结论)。

3.4.4 IndexedDB 与 LevelDB

  • IndexedDB 属于 Chromium 的 session 存储,跟随 sessionData 目录(默认即 userData)与 partition(persist: 前缀持久化,否则 in-memory,getStoragePath() 返回 null)。优点是渲染进程原生可用、异步、容量大;缺点是主进程读不到、清缓存/换 partition 即丢、Chromium 升级偶发损坏。合理定位:渲染侧缓存与离线数据,不是应用事实来源。
  • LevelDB(npm level,v10.0.0,2025-04)是经典嵌入式 KV,Chrome 自家的 IndexedDB 底层即基于 LevelDB 系。JS 侧 level 包 API 友好,但同样以独占锁方式打开数据库,多进程共享需要单写者模式;社区活跃度低于 SQLite 方案,新项目若无既有生态包袱,一般直接选 SQLite。

3.4.5 数据目录规范

官方文档 app.getPath('userData') 定义为「appData 加应用名」:macOS 是 ~/Library/Application Support/<App>,Windows 是 %APPDATA%\<App>,Linux 是 $XDG_CONFIG_HOME(或 ~/.config)/<App>。两点官方建议值得写进规范:

  • 应用自己的文件放 userData 的子目录(如 path.join(app.getPath('userData'), 'my-app-data')),避免与 Chromium 自己的子目录(Cache、Local Storage 等)命名冲突;
  • sessionData(session 生成的数据:localStorage、cookies、磁盘缓存等)默认指向 userData,若不依赖浏览器存储,可在 ready 之前用 app.setPath('sessionData', ...) 挪走,免得 Chromium 巨大的磁盘缓存污染你的数据目录、也让「清缓存不伤配置」成为可能。

3.5 敏感凭据:safeStorage 与系统钥匙串

3.5.1 机制

safeStorage 是 Electron 15(2021-09-21 发布)引入的主进程模块,用操作系统级加密体系加密字符串,磁盘上只落密文。平台后端(官方文档):

  • Windows:DPAPI——防同机其他用户,不防同用户的其他应用;
  • macOS:Keychain——密钥按应用隔离,防其他用户与其他应用;应用需正确签名,否则 Keychain 授权无法跨更新持久(未签名/ad-hoc 应用会出现 Keychain 查询失败,官方 issue #43233 记录过升级后触发密码弹窗的案例);
  • Linux:同步 API 走 kwallet/kwallet5/kwallet6 或 gnome_libsecret;异步 API 优先走 org.freedesktop.portal.Secret(Flatpak/沙箱友好)及 Secret Service。无任何密钥服务时降级为 basic_text(内置硬编码明文口令加密,getSelectedStorageBackend() 返回 basic_text 可检测),此时仅是混淆而非保护;可用 --password-store 启动参数强制指定后端。Linux 上后端探测需等 ready 事件,之前返回 unknown。

官方文档现在的推荐是优先异步 API:isAsyncEncryptionAvailable()、encryptStringAsync()、decryptStringAsync()——非阻塞、支持密钥轮换(shouldReEncrypt 为 true 时应回写重加密)、能优雅处理临时不可用;同步 API「可能在未来的 Electron 版本弃用」。文档仓库的 breaking-changes 页已列出计划:Electron 45 弃用同步三件套(isEncryptionAvailable/encryptString/decryptString),Electron 46 随 Chromium 移除同步 OSCrypt 后端一并移除;旧版 encryptString 加密的数据可直接用 decryptStringAsync 解密(同一套平台密钥库)。截至本文日期 44 为最新稳定版,45/46 尚未发布,该时间线属计划内内容、以官方最终发布为准(标注:依据 docs 仓库 main 分支,待核实)。

// main.ts —— 本分册采用的 safeStorage 用法(异步 + 密钥轮换)
import { safeStorage } from 'electron';
import { writeFile, readFile } from 'node:fs/promises';

export async function saveSecret(plain: string, file: string) {
  if (!(await safeStorage.isAsyncEncryptionAvailable())) {
    throw new Error('OS-level encryption unavailable'); // 决定降级策略,而非静默明文
  }
  const encrypted = await safeStorage.encryptStringAsync(plain);
  await writeFile(file, encrypted);
}

export async function loadSecret(file: string): Promise<string | null> {
  const buf = await readFile(file);
  const { result, shouldReEncrypt } = await safeStorage.decryptStringAsync(buf);
  if (shouldReEncrypt) {
    await writeFile(file, await safeStorage.encryptStringAsync(result)); // 密钥轮换后回写
  }
  return result;
}

3.5.2 keytar 已成历史

上一代方案 node-keytar(atom/node-keytar,直接读写系统钥匙串条目)已停止维护:仓库已归档,npm 最后版本停在 7.9.0(Obsidian 论坛等多个社区在 2023-2025 间确认其 archived 状态并寻找替代)。2026 年的替代品:优先 Electron 内置 safeStorage;确需把凭据作为钥匙串「条目」管理(可被系统凭据管理器查看/删除)的,可考虑 @napi-rs/keyring(需原生模块,Obsidian 社区讨论中提及)。

需要清醒认识的边界:safeStorage 防的是「磁盘上可直接读到明文」。Windows DPAPI 防不了同用户其他进程;有研究者分析其 Windows 实现使用 AES-128-CBC 与固定 IV(二手分析,见 chenguangliang.com 博客),同用户进程 attach 调试器即可在解密瞬间读到明文。高价值凭据仍应考虑 OS keychain 条目级存储或系统级 SSO/生物识别授权,而非仅依赖加密文件。

3.6 常见坑与反模式

  1. 在 renderer 里 import electron-store / 直接写文件:沙箱下 Can't resolve 'fs';关掉 sandbox/nodeIntegration 去迁就是安全反模式。正解:主进程持有,IPC 暴露。
  2. preload 暴露整个 ipcRenderer 或把回调直接传给 ipcRenderer.on:官方文档明确指出后者会经 event.sender 泄漏 ipcRenderer。用 contextBridge 暴露最小面。
  3. 把重要用户数据只存 localStorage/IndexedDB:清缓存即丢,主进程不可见。浏览器存储只做缓存。
  4. 多窗口各自读写同一 JSON 文件:last-writer-wins 丢数据。收敛到主进程单写者 + 广播。
  5. 忘记单实例锁:两个实例并发写配置/数据库,偶发损坏极难排查。
  6. better-sqlite3 不 rebuild 就发版:NODE_MODULE_VERSION 不匹配,装到用户机器上直接崩;把 electron-builder install-app-deps 或 @electron/rebuild 挂进 postinstall/CI。
  7. 写 JSON 不做原子写与节流:崩溃瞬间截断文件;高频状态全量落盘拖慢主进程。
  8. 忽略 shouldReEncrypt:OS 轮换密钥后旧密文虽可解,但不同步重写会一直走慢路径。
  9. 依赖同步 safeStorage:官方已预告弃用,新代码一律用 encryptStringAsync/decryptStringAsync。
  10. 把 schema 迁移写成「if 旧结构存在就删了重建」:桌面用户数据不可再生,迁移必须逐版本、可回滚(至少可备份)。

3.7 采用方式与适用条件清单

  • 分层:渲染层 UI 状态(Zustand/RTK/Pinia)→ 主进程单一事实来源 + IPC 广播 → userData 下持久化 → safeStorage 管凭据。
  • 配置:electron-store(conf)或照抄 VS Code FileStorage 的「节流 + 内容比对 + 原子写 + 关闭前 flush」四件套;文件放 userData 子目录,sessionData 按需重定向。
  • 数据:超过轻量配置即上 SQLite;better-sqlite3(要 rebuild)求稳,node:sqlite(内置、实验性)求免编译,重查询搬进 utilityProcess 单写者进程服务化,WAL + busy timeout 应对并发。
  • 同步:invoke/handle 为默认、webContents.send 推送、MessageChannelMain 建直连、requestSingleInstanceLock 保单实例。
  • 凭据:safeStorage 异步 API + isAsyncEncryptionAvailable 检查 + shouldReEncrypt 回写;Linux 检测 basic_text;放弃 keytar。
  • 每条持久化数据都带 version(JSON 字段 / PRAGMA user_version / persist version),迁移链单调向前。

参考来源

最后更新于

本页目录