Electron 文件系统与数据存储
读写文件、路径选择、localStorage / IndexedDB / SQLite / JSON 的取舍。
路径选择
const { app } = require('electron');
const path = require('node:path');
app.getPath('userData'); // 配置、数据库 → 首选
app.getPath('home');
app.getPath('documents');
app.getPath('downloads');
app.getPath('temp');
app.getPath('logs'); // 建议显式 setAppLogsPath()
path.join(app.getPath('userData'), 'settings.json');userData is a per-app directory, usually preserved on uninstall — right for user data. __dirname points at the code, which after packaging is a read-only asar archive.
读写文件
// main.js
const fs = require('node:fs/promises');
const { ipcMain, app } = require('electron');
const path = require('node:path');
const CONFIG = path.join(app.getPath('userData'), 'settings.json');
ipcMain.handle('config:load', async () => {
try {
return JSON.parse(await fs.readFile(CONFIG, 'utf8'));
} catch (e) {
if (e.code === 'ENOENT') return {}; // 首次运行,没有文件
throw e;
}
});
ipcMain.handle('config:save', async (_e, data) => {
const tmp = CONFIG + '.tmp';
await fs.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
await fs.rename(tmp, CONFIG); // 原子替换,避免写一半崩溃
return true;
});Writing .tmp then rename is the standard atomic write: within one filesystem rename is atomic, so you get either the old file or the new one, never a half-written JSON.
大文件与流
const { createReadStream, createWriteStream } = require('node:fs');
const { pipeline } = require('node:stream/promises');
await pipeline(
createReadStream(srcPath),
createWriteStream(destPath)
);fs.readFile loads the whole file into memory and blows up on hundreds of MB. Copying and transcoding must use streams, where memory stays constant.
存储方案对比
| Option | Capacity | Best for |
|---|---|---|
| localStorage | 约 5MB | 少量偏好设置 |
| IndexedDB | 几百 MB | 结构化数据、离线缓存 |
| JSON 文件 | 几 MB | 配置、简单状态 |
| SQLite | GB 级 | 大量记录、需要查询 |
| 自定义文件格式 | 不限 | 文档、媒体 |
SQLite
const Database = require('better-sqlite3');
const path = require('node:path');
const { app } = require('electron');
const db = new Database(path.join(app.getPath('userData'), 'app.db'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
const insert = db.prepare('INSERT INTO notes (title, body) VALUES (?, ?)');
insert.run('第一条', '内容');
const rows = db.prepare('SELECT * FROM notes ORDER BY id DESC LIMIT 20').all();better-sqlite3 is synchronous; in the main process a query over hundreds of thousands of rows freezes the UI. For large data use the async sqlite3 API or move queries into a worker.
better-sqlite3 is a native module and must be rebuilt with electron-rebuild against Electron's ABI, or it fails at runtime with a NODE_MODULE_VERSION mismatch — a failure that appears only when launching the app, never during npm install.安全提醒
// 危险:页面传来的路径直接读
ipcMain.handle('read', (_e, p) => fs.readFile(p, 'utf8'));
// 安全:限制在允许的目录内
ipcMain.handle('read', async (_e, p) => {
const base = path.join(app.getPath('documents'), 'myapp');
const target = path.resolve(base, p);
if (!target.startsWith(base + path.sep)) {
throw new Error('路径越界');
}
return fs.readFile(target, 'utf8');
});path.resolve collapses ../, so resolve first and then compare prefixes. Without the check, one XSS in the page reads ~/.ssh/id_rsa.
路径选择
const { app } = require('electron');
const path = require('node:path');
app.getPath('userData'); // 配置、数据库 → 首选
app.getPath('home');
app.getPath('documents');
app.getPath('downloads');
app.getPath('temp');
app.getPath('logs'); // 建议显式 setAppLogsPath()
path.join(app.getPath('userData'), 'settings.json');userData 是按应用名隔离的目录,卸载时一般不会被删,适合放用户数据。__dirname 指向代码目录,打包后是只读的 asar 包,写不进去。
读写文件
// main.js
const fs = require('node:fs/promises');
const { ipcMain, app } = require('electron');
const path = require('node:path');
const CONFIG = path.join(app.getPath('userData'), 'settings.json');
ipcMain.handle('config:load', async () => {
try {
return JSON.parse(await fs.readFile(CONFIG, 'utf8'));
} catch (e) {
if (e.code === 'ENOENT') return {}; // 首次运行,没有文件
throw e;
}
});
ipcMain.handle('config:save', async (_e, data) => {
const tmp = CONFIG + '.tmp';
await fs.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
await fs.rename(tmp, CONFIG); // 原子替换,避免写一半崩溃
return true;
});先写 .tmp 再 rename 是原子写入的标准做法:rename 在同一分区上是原子的,要么是旧文件要么是新文件,不会出现「写了一半的坏 JSON」。
大文件与流
const { createReadStream, createWriteStream } = require('node:fs');
const { pipeline } = require('node:stream/promises');
await pipeline(
createReadStream(srcPath),
createWriteStream(destPath)
);fs.readFile 会把整个文件读进内存,几百 MB 的文件直接把内存打爆。复制、转码这类操作一律用流,内存占用恒定。
存储方案对比
| 方案 | 容量 | 适用 |
|---|---|---|
| localStorage | 约 5MB | 少量偏好设置 |
| IndexedDB | 几百 MB | 结构化数据、离线缓存 |
| JSON 文件 | 几 MB | 配置、简单状态 |
| SQLite | GB 级 | 大量记录、需要查询 |
| 自定义文件 | 不限 | 文档、媒体 |
SQLite
const Database = require('better-sqlite3');
const path = require('node:path');
const { app } = require('electron');
const db = new Database(path.join(app.getPath('userData'), 'app.db'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
const insert = db.prepare('INSERT INTO notes (title, body) VALUES (?, ?)');
insert.run('第一条', '内容');
const rows = db.prepare('SELECT * FROM notes ORDER BY id DESC LIMIT 20').all();better-sqlite3 是同步 API,在主进程里用要注意别阻塞太久——一次查询几十万行会让界面卡住。数据量大时改用 sqlite3 的异步接口,或把查询放到 worker 里。
better-sqlite3 是原生模块,要用 electron-rebuild 按 Electron 的 ABI 重新编译,否则运行时报 NODE_MODULE_VERSION 不匹配。这类错误在 npm install 之后不会出现,只会在启动应用时爆发。安全提醒
// 危险:页面传来的路径直接读
ipcMain.handle('read', (_e, p) => fs.readFile(p, 'utf8'));
// 安全:限制在允许的目录内
ipcMain.handle('read', async (_e, p) => {
const base = path.join(app.getPath('documents'), 'myapp');
const target = path.resolve(base, p);
if (!target.startsWith(base + path.sep)) {
throw new Error('路径越界');
}
return fs.readFile(target, 'utf8');
});path.resolve 会把 ../ 解析掉,所以必须先解析再比较前缀。不校验的话,页面里的一个 XSS 就能读走 ~/.ssh/id_rsa。