背景与目标
桌面端的验配或业务软件,经常需要在本地保存结构化数据,比如客户信息、业务记录之类的。与此同时,有几个诉求几乎是刚需:
- 文件打开后不能直接阅读——至少得挡住随手改 JSON 的那种操作。
- 扩展名别用默认的
.json,降低用户误开、误传、误改的概率。 - 读写只发生在主进程,渲染进程通过 IPC 访问,不暴露文件系统能力。
- API 用起来像操作内存对象:
get/set就行,不用手写一堆fs.readFile和JSON.parse。
默认情况下的 electron-store,说白了就是把明文 JSON 往 userData 里一扔,扩展名还是 .json,这显然不符合前面说的 1 和 2 两条要求。不过好在它提供了一些关键的配置项:
fileExtension:自定义后缀serialize/deserialize:自定义序列化管线cwd:自定义落盘目录
这两条钩子,就是整套方案真正的核心所在。
最终效果示意
复制代码安装目录/
├── YourApp.exe
└── data/
├── app-records.dat ← 加密后的 hex 文本,扩展名自定(这里假设为dat)
└── app-entities.dat
用记事本打开 .dat 文件,你能看到的只是一长串十六进制字符,绝不是明文 JSON。
总体架构
复制代码┌─────────────────┐ IPC ┌──────────────────┐
│ Renderer (Vue) │ ──────────► │ Main Process │
│ 只调业务 API │ │ entity-store.ts │
└─────────────────┘ │ │ │
│ ▼ │
│ createEncryptedStore()
│ serialize → AES → hex
│ deserialize ← 解密
│ │ │
│ ▼ │
│ data/*.dat 文件 │
└──────────────────┘
再来看看分层建议:
| 层 | 职责 |
|---|---|
encrypted-store 工具 | 加解密 + 创建加密 Store |
xxx-store 业务模块 | Schema、CRUD、软删除等 |
xxx-ipc | 暴露给渲染进程的通道 |
| (可选)backup / import | 复用同一套加解密做备份包 |
第一步:安装依赖
记住,这一步是在 Electron 主进程侧进行的,别把它塞进渲染进程的打包依赖里。
复制代码npm install electron-store crypto-js
npm install -D @types/crypto-js # 若使用 TypeScript
简单说明一下:
electron-store:负责键值存取、原子写入和默认值。crypto-js:在主进程里做 AES 加密解密。当然,你也可以换成 Node 自带的crypto模块,思路是一样的。
第二步:确定数据目录
业务数据如果希望“随安装目录走,方便拷贝或备份”,那就别用默认的 app.getPath('userData')。可以根据打包状态做个分支:
复制代码// electron/utils/app-paths.ts(示意)
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import { app } from 'electron'/** 打包后:安装目录;开发时:项目根 */
export function resolveAppInstallDir(): string {
return app.isPackaged
? path.dirname(app.getPath('exe'))
: path.join(process.env.APP_ROOT ?? process.cwd())
}/** 确保 /data 存在并返回 */
export function resolveAppDataDir(): string {
const dir = path.join(resolveAppInstallDir(), 'data')
mkdirSync(dir, { recursive: true })
return dir
}
这样一来,开发环境的数据就在仓库旁的 data/ 目录里,发布后则在 .exe 同级的 data/ 目录下,运维人员定位文件会直观很多。
第三步:实现加解密管线
设计的核心目标很明确:
- 在内存中,数据始终是普通的 JS 对象。
- 落盘时,流程是
JSON.stringify→ AES 加密 → Hex 文本 写入文件。 - 读取时,顺序反过来:Hex → AES 解密 →
JSON.parse。
为什么要用 Hex?因为文件全程都是可打印的 ASCII 字符,复制、日志截断、跨平台换行都更稳定。代价是文件体积会翻倍,但对于本机业务库来说,通常可以接受。
复制代码// electron/utils/encrypted-store.ts(示意,密钥请勿写死在仓库)
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import path from 'node:path'import CryptoJS from 'crypto-js'
import Store, { type Options as StoreOptions } from 'electron-store'import { resolveAppDataDir } from './app-paths'/**
* 密钥读取示意:生产环境应来自安全配置 / 构建注入 / 系统凭据,
* 切勿把真实密钥提交到 Git。
*/
function getStoreSecret(): string {
const secret = process.env.APP_STORE_SECRET
if (!secret) {
throw new Error('缺少 APP_STORE_SECRET')
}
return secret
}/** 明文 → AES → Base64 密文 → 再编码为 Hex 字符串落盘 */
function encryptToHex(plainText: string): string {
const encrypted = CryptoJS.AES.encrypt(plainText, getStoreSecret()).toString()
return CryptoJS.enc.Hex.stringify(CryptoJS.enc.Utf8.parse(encrypted))
}/** Hex 文件内容 → 解密得到明文 JSON 字符串 */
function decryptFromHex(hex: string): string {
const encrypted = CryptoJS.enc.Hex.parse(hex).toString(CryptoJS.enc.Utf8)
const decrypted = CryptoJS.AES.decrypt(encrypted, getStoreSecret())
const result = decrypted.toString(CryptoJS.enc.Utf8)
if (!result) {
throw new Error('解密 store 数据失败(密钥错误或文件损坏)')
}
return result
}/** 供备份导出等「不经过 Store」场景复用同一算法 */
export function encryptStoreContent(plainText: string): string {
return encryptToHex(plainText)
}export function decryptStoreContent(hex: string): string {
return decryptFromHex(hex)
}
第四步:封装 createEncryptedStore
把自定义扩展名、目录、序列化这些配置一次性绑死,业务侧只需要传 name 和 defaults 就行了:
复制代码const CUSTOM_EXT = 'dat' // 示例后缀;本项目可使用自有扩展名/** 旧版明文 .json → 新版加密文件(一次性迁移) */
function migrateLegacyJsonStore(dataDir: string, name: string): void {
const encryptedPath = path.join(dataDir, `${name}.${CUSTOM_EXT}`)
const jsonPath = path.join(dataDir, `${name}.json`)
if (existsSync(encryptedPath) || !existsSync(jsonPath)) return const raw = readFileSync(jsonPath, 'utf8')
writeFileSync(encryptedPath, encryptToHex(raw), 'utf8')
unlinkSync(jsonPath)
}/** 创建主进程专用加密 Store */
export function createEncryptedStoreextends Record<string, unknown>>(
options: Omit<StoreOptions, 'serialize' | 'deserialize' | 'fileExtension' | 'cwd'>
): Store {
const dataDir = resolveAppDataDir()
const name = options.name ?? 'config'
migrateLegacyJsonStore(dataDir, name) return new Store({
...options,
cwd: dataDir,
fileExtension: CUSTOM_EXT,
serialize: value => encryptToHex(JSON.stringify(value)),
deserialize: text => JSON.parse(decryptFromHex(text)) as T,
})
}
关键配置项说明:
| 选项 | 作用 |
|---|---|
cwd | 落到 data/,而不是默认的 userData |
fileExtension | 例如 dat 或项目自定义后缀 |
serialize | 写盘前加密 |
deserialize | 读盘后解密 |
| 迁移函数 | 老用户仍有明文 .json 时,能平滑升级 |
需要特别注意的是,业务代码从此禁止绕过这层直接读文件,否则加解密格式很容易分裂。
第五步:业务 Store 怎么写
以“实体列表”为例,字段已经抽象过:
复制代码// electron/hooks/entity-store.ts(示意)
import { randomUUID } from 'node:crypto'
import { createEncryptedStore } from '../utils/encrypted-store'interface EntityRecord {
id: string
title: string
updatedAt: string
deletedAt?: string | null
}interface EntityStoreSchema {
entities: EntityRecord[]
}const store = createEncryptedStore<EntityStoreSchema>({
name: 'app-entities',
defaults: {
entities: [],
},
})export function listEntities(): EntityRecord[] {
return store
.get('entities')
.filter(item => !item.deletedAt)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
}export function addEntity(title: string): EntityRecord {
const now = new Date().toISOString()
const record: EntityRecord = {
id: randomUUID(),
title,
updatedAt: now,
}
const entities = store.get('entities')
store.set('entities', [record, ...entities])
return record
}/** 软删除:保留痕迹便于审计 / 导入合并 */
export function softDeleteEntity(id: string): void {
const entities = store.get('entities')
const index = entities.findIndex(item => item.id === id)
if (index < 0) throw new Error('记录不存在') const next = [...entities]
next[index] = {
...entities[index],
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
store.set('entities', next)
}
这里有几个实践中的建议:
- Schema 显式声明:用
Store,避免any到处蔓延。 - 列表整体读写:对于小规模的本机库,这种做法够用了。更新字段时,拷贝一份再
set,不要原地 mutate 后指望它自动落盘。 - 软删除:在导入合并、审计等场景下,软删除往往比物理删除更合适。
最终的落盘结果类似这样:
复制代码data/app-entities.dat # 一整文件都是 hex
第六步:只通过 IPC 暴露给渲染进程
加密文件只应该由主进程接触。渲染层只需要拿到“业务结果”:
复制代码// electron/ipc/entity-ipc.ts(示意)
import { ipcMain } from 'electron'
import { addEntity, listEntities, softDeleteEntity } from '../hooks/entity-store'export function registerEntityIpc() {
ipcMain.handle('entity:list', () => listEntities())
ipcMain.handle('entity:add', (_e, title: string) => addEntity(title))
ipcMain.handle('entity:delete', (_e, id: string) => softDeleteEntity(id))
}
渲染进程的调用方式:
复制代码// 渲染侧示意
const list = await window.api.invoke('entity:list')
配合 contextIsolation 和 preload 白名单通道,就能有效避免渲染进程直接 require('fs') 或者拿到 Store 实例。
第七步:备份 / 导入复用同一套算法
既然有了统一的 encryptStoreContent 和 decryptStoreContent,备份包就可以做成“外层再包一层加密”的结构:
复制代码// 备份包结构示意(明文逻辑;落盘前整体再加密)
interface DataBackupBundle {
version: 1
exportedAt: string
files: Record<string, string> // key: 文件名,value: 单库已加密的 hex
}function writeBackup(targetDir: string, bundle: DataBackupBundle): string {
const fileName = `backup_${Date.now()}_all.dat`
const outputPath = path.join(targetDir, fileName)
// 注意:bundle.files 里已经是各 store 的密文;整包 JSON 再加密一层
writeFileSync(outputPath, encryptStoreContent(JSON.stringify(bundle)), 'utf8')
return outputPath
}function readBackup(filePath: string): DataBackupBundle {
const raw = readFileSync(filePath, 'utf8')
const bundle = JSON.parse(decryptStoreContent(raw)) as DataBackupBundle
if (!bundle?.files || typeof bundle.files !== 'object') {
throw new Error('备份文件格式无效')
}
return bundle
}
导入时的流程:
- 先解密外层备份包。
- 再解密各子 store 的 hex,得到 JSON。
- 按业务规则进行合并(处理冲突决议、id 映射等)。
- 写回本地 Store(仍然走
createEncryptedStore的序列化)。
这样一来,运行时库、导出包、导入包三套文件的格式完全一致,维护成本降到了最低。
从 0 到 1 的落地清单
可以按下面的顺序,一次性做完:
- 安装
electron-store、crypto-js - 实现
resolveAppDataDir(),确认打包后目录正确 - 实现
encryptToHex/decryptFromHex,密钥走环境变量或安全配置 - 封装
createEncryptedStore(cwd+ 自定义扩展名 + serialize/deserialize) - 迁移逻辑:旧
.json→ 加密文件,只跑一次 - 业务
xxx-store:defaults+ CRUD + 类型 Schema - IPC 注册;preload 白名单;渲染层只调通道
- (可选)备份/导入复用
encryptStoreContent - 手工验证:改几个字节文件应解密失败;
get/set往返数据一致
一个验证片段:
复制代码const store = createEncryptedStore<{ count: number }>({
name: 'smoke-test',
defaults: { count: 0 },
})
store.set('count', 42)
console.log(store.get('count')) // 42
// 打开 data/smoke-test.dat,应看不到明文 42
常见坑
-
在渲染进程使用 electron-store
安全隔离和打包路径都会变得很乱。老老实实放主进程。 -
忘记
JSON.stringify包在加密外层
serialize收到的是对象,必须先序列化再加密。 -
Hex / Base64 搞混
加密后 CryptoJS 默认输出的是 Base64 字符串,我们这个方案又转了一次 Hex 再落盘。解密时必须严格逆序操作。 -
密钥更换
一旦更换密钥,旧文件就全部不可读了。需要提前规划版本字段或迁移工具。 -
大对象频繁整文件
set
electron-store适合中小规模的本机库。如果数据量很大,还是考虑 SQLite 之类的方案。 -
把真实密钥写进示例博客或仓库
生产环境的密钥必须独立管理。文档和示例里一律用占位符。
小结
electron-store 本身并不是一个加密方案,但它把“改序列化管线”这个接口开放了出来,这才是关键所在。通过:
- 自定义
fileExtension→ 变成业务自有格式文件 serialize/deserialize+ AES → 磁盘上不再是明文 JSONcwd指向安装目录data/→ 文件位置可控- 主进程 Store + IPC → 最小暴露面
- 备份导入共用算法 → 端到端格式统一
这样就能在不明显牺牲开发体验的前提下,搭建出一套适合桌面业务软件的本地加密存储方案。

