Electron 桌面应用同时拥有 Node.js 主进程和 Chromium 渲染进程,主进程能够直接使用 Node 原生模块,因此 SQLite 这类嵌入式数据库可以完全在本地完成读写,不需要额外启动数据库服务。相比 LocalStorage 或 IndexedDB,SQLite 支持完整的 SQL、事务、索引和复杂查询,适合笔记管理、进销存、日志分析等桌面工具。但集成 SQLite 并不是执行一条 npm install 命令就能完成,因为原生模块需要匹配 Electron 内置的 Node.js ABI,数据库文件也必须放在可写目录中。

下面以 better-sqlite3 为核心依赖,从选型、编译、封装、IPC 通信到打包发布依次说明,构建一套可维护的本地持久化结构。
一、为什么 better-sqlite3 更适合 Electron
Electron 主进程是单线程 Node.js 环境,SQLite 操作通常在本地文件中完成,延迟很低,因此同步 API 并不会明显阻塞主线程,反而能避免回调嵌套和 Promise 包装带来的复杂度。better-sqlite3 全部接口采用同步调用,查询结果直接返回,特别适合在 ipcMain.handle 中做短平快的数据读写。相比之下,sqlite3 包以异步回调为主,需要手动包装 Promise,代码量更多;sql.js 是 WebAssembly 实现,虽然无需编译原生模块,但数据库完全加载到内存,写入时需要手动导出文件,不适合持续写入的桌面场景。
另一个关键点是原生模块兼容性。Electron 内置的 Node.js 版本与系统 Node.js 并不完全一致,直接安装 better-sqlite3 时,npm 会下载或编译针对系统 Node 的二进制文件,可能无法被 Electron 加载。这也是很多集成问题出现在启动阶段的原因,而不是业务代码写错。
从性能角度看,better-sqlite3 支持预编译语句和批量事务,写入速度优于逐条执行 SQL 的异步方案。对于需要导入大量数据或频繁更新本地记录的应用,可以明显减少磁盘 I/O 和 CPU 开销。因此,Electron 项目通常优先选择 better-sqlite3,而不是传统 sqlite3。
二、安装与重新编译 better-sqlite3
安装依赖时,除了 better-sqlite3,还需要安装 electron-rebuild 或直接使用 electron-builder 提供的 install-app-deps 命令。electron-rebuild 会针对当前 Electron 版本重新编译原生模块,确保二进制文件可以正常加载。
npm install better-sqlite3 npm install --save-dev electron-rebuild npx electron-rebuild -f -w better-sqlite3
如果项目使用 electron-builder 作为打包工具,也可以在 package.json 的 scripts 中增加一条命令,在安装依赖后自动完成原生模块重建:
{
"scripts": {
"postinstall": "electron-builder install-app-deps"
}
}
编译失败时,可以先确认系统中是否安装了 Python 和 C++ 构建工具。Windows 环境通常需要 Visual Studio Build Tools,macOS 需要 Xcode Command Line Tools,Linux 需要 build-essential 和 python3。也可以检查 npm 镜像是否完整,避免二进制包下载不完整导致 rebuild 失败。解决编译问题后,可以先写一段最小化测试代码,确认主进程能够正常打开数据库。
三、主进程数据库服务的封装设计
数据库文件不能放在项目源码目录中,因为打包后的 asar 只读,应用无法在运行期间写入数据。正确的做法是使用 app.getPath('userData') 获取用户数据目录,再拼接数据库文件名。Windows 下该目录通常位于 C:\Users\当前用户\AppData\Roaming\应用名,macOS 位于用户目录下的 Library/Application Support 中。开发阶段这个路径同样存在,不需要额外处理。
打开数据库后建议开启 WAL 模式。WAL 模式允许读写并发,更适合桌面应用中前端渲染和后台写入交替发生的场景。同时开启外键约束,避免代码层遗漏数据完整性检查。建表语句可以通过 db.exec 执行,但业务读写尽量使用 prepare 预编译语句,以防止 SQL 注入并增加执行效率。
const Database = require('better-sqlite3');
const path = require('path');
const { app } = require('electron');
const userDataPath = app.getPath('userData');
const dbPath = path.join(userDataPath, 'app.db');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
`);
function getNoteById(id) {
const stmt = db.prepare('SELECT * FROM notes WHERE id = ?');
return stmt.get(id);
}
function createNote(title, content) {
const stmt = db.prepare('INSERT INTO notes (title, content) VALUES (?, ?)');
const info = stmt.run(title, content);
return info.lastInsertRowid;
}
module.exports = { getNoteById, createNote };
如果需要一次插入多条数据,应该使用 db.transaction 把多个语句包在同一个事务中。事务函数会自动提交或回滚,性能远高于循环调用。下面是一个批量创建笔记的示例:
const insertMany = db.transaction((items) => {
const stmt = db.prepare('INSERT INTO notes (title, content) VALUES (?, ?)');
for (const item of items) {
stmt.run(item.title, item.content);
}
});
insertMany([
{ title: '第一条', content: '内容 A' },
{ title: '第二条', content: '内容 B' }
]);
数据库服务模块应该保持职责单一,只负责连接管理、表结构初始化和具体数据操作。IPC 层再调用这些函数,不要把 ipcMain 逻辑混入数据库模块。这样便于后续编写单元测试,也可以在需要迁移数据库时替换底层实现。
四、IPC 安全通信与渲染进程调用
渲染进程不能直接访问 Node.js 模块,因此需要通过 preload 脚本暴露有限接口。最安全的方式是使用 contextBridge 暴露一个普通对象,对象内的方法调用 ipcRenderer.invoke,再由主进程 ipcMain.handle 处理。不要直接在 preload 中把 ipcRenderer 整体暴露给 window,否则渲染进程可以发送任意通道,增加安全风险。
主进程侧添加 IPC 处理函数,参数从前端传入前要做基本校验,例如 id 必须转成数字,title 不能为空。错误可以抛回渲染进程,渲染端通过 try/catch 捕获。
const { ipcMain } = require('electron');
const dbService = require('./db');
ipcMain.handle('notes:get', (event, id) => {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error('无效的笔记 ID');
}
return dbService.getNoteById(numericId);
});
ipcMain.handle('notes:create', (event, payload) => {
const { title, content } = payload || {};
if (typeof title !== 'string' || title.trim() === '') {
throw new Error('标题不能为空');
}
return dbService.createNote(title.trim(), content || '');
});
preload 脚本可以这样写:
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('notesAPI', {
get: (id) => ipcRenderer.invoke('notes:get', id),
create: (title, content) => ipcRenderer.invoke('notes:create', { title, content })
});
渲染进程中的调用方式非常简洁:
async function saveNote() {
try {
const id = await window.notesAPI.create('新标题', '新内容');
console.log('创建成功,ID:', id);
} catch (error) {
console.error(error.message);
}
}
在复杂应用中,IPC 通道会逐渐增多。建议按业务模块命名通道,例如 notes:get、notes:list、notes:delete,并在一个单独的 handlers 文件中统一注册。这样既能保持主进程入口干净,也能避免通道名拼写错误。
五、打包发布中的路径和 asar 配置
Electron 打包时默认把源码放进 app.asar 文件,这是一种只读归档格式。如果数据库文件或原生模块放在 asar 内部,运行时可能无法写入或加载。数据库文件已经通过 userData 路径解决了写入问题,但 better-sqlite3 的原生 .node 文件如果被塞进 asar,也可能无法被 Node 正常加载。需要在 electron-builder 配置中把 better-sqlite3 解包出来。
{
"build": {
"appId": "com.example.mynotes",
"asar": true,
"asarUnpack": [
"node_modules/better-sqlite3/**"
]
}
}
配置 asarUnpack 之后,打包工具会把匹配的文件复制到 app.asar.unpacked 目录,资源路径仍然由 Electron 内部解析,业务代码无需修改。如果使用 electron-packager,也可以通过 asar 选项和 afterCopy 钩子处理原生模块。发布前最好在正式打包目录中运行一次安装包,确认数据库能够创建、读写,重启后数据仍然存在。
另外,在开发环境和打包环境中,app.getPath('userData') 的返回值不同。开发时它通常指向 Electron 默认目录,打包后会变成应用名对应的用户目录。不要硬编码路径,更不要把数据库写在项目相对目录。只有在主进程 ready 事件之后调用 app.getPath 才能拿到正确值,因此数据库初始化代码应放在 app.whenReady() 回调中。
ElectronSQLitebetter-sqlite3修改时间:2026-10-07 04:52:30