electron-vite 集成 better-sqlite3 原生模块工程化实践
发布时间:2026/9/19 7:44:35来源:尧图网络
1. 项目缘起与整体设计思路1.1 为什么要在 electron-vite 里用 better-sqlite3做 Electron 桌面端超过两年的朋友大概都有体会本地数据存储这块选型其实就那么几条路。要么用localStorage或IndexedDB这类浏览器存储要么上lowdb、nedb这种纯 JS 文件数据库再要么就是直接怼一个真正的嵌入式关系型数据库。前两种方案在数据量小、查询简单的时候确实够用但只要业务稍微复杂一点——比如需要多表关联、事务保证、复杂条件查询、全文检索——它们立刻就会露怯。better-sqlite3是我个人在 Electron 项目里用得最顺手的 SQLite 封装。它跟sqlite3那个老牌库最大的区别在于它是同步 API。很多人一听“同步”就皱眉觉得会阻塞主线程。但在 Electron 的主进程里这个“同步”恰恰是优点——代码写起来线性、直观不用处理回调地狱也不用担心异步竞态。而且better-sqlite3底层做了大量性能优化官方 benchmark 里它的读写速度比sqlite3快好几倍在桌面端这种单用户、低并发的场景下同步调用带来的阻塞几乎可以忽略不计。那为什么标题里强调“electron-vite 项目”因为electron-vite这套脚手架把主进程、预加载脚本、渲染进程的构建流程拆得很清楚默认用的是 Vite 的构建管线。而better-sqlite3是一个原生模块native module它包含 C 编译产物.node文件不是纯 JS。这就带来一个核心矛盾Vite 的构建体系默认不认识原生模块打包时要么把它 externalize 掉要么就得处理 ABI 版本匹配问题。这个矛盾就是整个工程化实践要解决的主线。1.2 整体方案选型的几个关键决策在动手之前我先把几个关键决策点摆出来这些决策直接决定了后面所有配置的走向。第一个决策数据库操作放在主进程还是渲染进程我的选择是全部放主进程。原因有三其一better-sqlite3是原生模块在渲染进程里加载会牵扯到contextIsolation和nodeIntegration的安全配置容易踩坑其二主进程天然是 Node 环境加载原生模块最顺畅其三数据操作集中在一处便于做事务管理和错误处理。渲染进程通过ipcRenderer.invoke调用预加载脚本暴露的接口再由预加载脚本转发给主进程形成清晰的三层结构。第二个决策用 electron-builder 还是 electron-forge 打包我选electron-builder。它对原生模块的处理更成熟asarUnpack配置能精确控制哪些文件不打进 asar 包而且跨平台Windows/macOS/Linux的配置统一。electron-forge 也能做但它在原生模块 rebuild 环节的自动化程度我个人觉得不如 builder 顺手。第三个决策ABI 版本怎么对齐这是原生模块最核心的坑。Electron 内置的 Node 版本和系统 Node 版本往往不一致better-sqlite3编译出来的.node文件必须匹配 Electron 的 ABI。解决方案是用electron-rebuild或electron/rebuild针对 Electron 的 headers 重新编译。这一步做不好运行时会直接报NODE_MODULE_VERSION不匹配的错。第四个决策开发环境和生产环境的加载路径怎么统一开发时better-sqlite3从node_modules加载打包后它被解压到app.asar.unpacked目录下。如果不做路径处理生产环境会找不到模块。我的做法是在主进程里写一个统一的模块加载封装根据app.isPackaged判断当前环境动态调整require路径。把这四个决策定下来整个工程化的骨架就清晰了。下面我按实际操作的顺序一步步拆解。2. 环境准备与依赖安装的实操细节2.1 初始化 electron-vite 项目与版本锁定如果你还没有项目直接用官方脚手架起一个npm create quick-start/electronlatest my-app -- --template vue这里我选的是 Vue 模板React 或原生 JS 模板同理核心逻辑不受影响。创建完之后先别急着装better-sqlite3第一件事是把 Electron 的版本锁死。为什么因为 Electron 每个大版本对应的 Node ABI 不同better-sqlite3的预编译二进制文件是按 ABI 分发的。如果你用^或~让 Electron 自动升级某天npm install之后 ABI 变了原生模块就崩了。我的做法是在package.json里把 Electron 写成精确版本比如electron: 31.3.1同时在项目根目录加一个.npmrcsave-exacttrue这样后续所有依赖安装都会记录精确版本避免“昨天还好好的今天跑不起来”这种玄学问题。2.2 安装 better-sqlite3 与 rebuild 工具链依赖分两类运行时依赖和构建时依赖。运行时依赖只有一个npm install better-sqlite3构建时依赖需要这几个npm install -D electron/rebuild electron-builderelectron/rebuild是官方维护的 rebuild 工具老的electron-rebuild已经废弃别再用那个了。electron-builder负责打包。装完之后在package.json的scripts里加一条rebuild: electron-rebuild -f -w better-sqlite3-f是强制重新编译-w指定只 rebuildbetter-sqlite3这一个模块避免把其他无关模块也拖进来浪费时间。注意electron-rebuild在 Windows 上需要本机有 C 编译环境Visual Studio Build ToolsmacOS 上需要 Xcode Command Line ToolsLinux 上需要build-essential和python3。如果编译报错九成是编译工具链没装全先解决这个再往下走。2.3 验证原生模块能否正常加载在写任何业务代码之前先做一个最小验证。在主进程文件通常是src/main/index.js或index.ts里临时加一段const Database require(better-sqlite3) const db new Database(:memory:) db.exec(CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)) db.prepare(INSERT INTO test (name) VALUES (?)).run(hello) const row db.prepare(SELECT * FROM test).get() console.log(better-sqlite3 加载成功:, row) db.close()跑npm run dev如果控制台打印出{ id: 1, name: hello }说明开发环境的原生模块没问题。如果报NODE_MODULE_VERSION错误说明 rebuild 没生效回去检查electron-rebuild是否针对正确的 Electron 版本执行了。这一步看着简单但它是整个项目的“地基验收”。我见过太多人跳过这步直接写业务结果打包时才发现模块加载不了回头排查成本翻好几倍。3. 主进程数据库层的工程化封装3.1 数据库连接的单例管理与路径处理数据库连接不能每次用都 new 一个那样既浪费资源又容易造成文件锁冲突。我习惯写一个单例封装// src/main/database/connection.js const path require(path) const fs require(fs) const { app } require(electron) let dbInstance null function getDatabasePath() { const userDataPath app.getPath(userData) const dbDir path.join(userDataPath, data) if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }) } return path.join(dbDir, app.db) } function getDb() { if (dbInstance) return dbInstance const Database require(better-sqlite3) const dbPath getDatabasePath() dbInstance new Database(dbPath) dbInstance.pragma(journal_mode WAL) dbInstance.pragma(foreign_keys ON) return dbInstance } module.exports { getDb }这里有几个关键点值得展开说。为什么用app.getPath(userData)因为这是 Electron 官方推荐的用户数据目录Windows 下是%APPDATA%/应用名macOS 下是~/Library/Application Support/应用名Linux 下是~/.config/应用名。把数据库放这里既符合系统规范又不会因为应用升级被覆盖。千万别把数据库放在应用安装目录里那个目录在 macOS 和 Linux 上通常是只读的。为什么开 WAL 模式WALWrite-Ahead Logging是 SQLite 的一种日志模式开启后读写可以并发写入性能明显提升而且崩溃恢复更可靠。对于桌面应用这种“读多写少但偶尔要批量写”的场景WAL 几乎是必选项。代价是会多出-wal和-shm两个辅助文件打包和备份时要注意一起处理。为什么开foreign_keysSQLite 默认不强制外键约束这是个历史遗留设计。开启之后删除主表记录时关联的子表记录会按你定义的行为CASCADE/SET NULL/RESTRICT处理数据一致性有保障。3.2 迁移机制让数据库结构可演进桌面应用有个特点用户装的是老版本升级到新版本时数据库结构可能变了。如果没有迁移机制要么让用户删库重来数据丢失体验极差要么手动写一堆 if-else 判断版本。我的做法是实现一个轻量的迁移系统// src/main/database/migrate.js const migrations [ { version: 1, up: (db) { db.exec( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_at INTEGER DEFAULT (strftime(%s,now)) ) ) } }, { version: 2, up: (db) { db.exec(ALTER TABLE users ADD COLUMN email TEXT) } } ] function runMigrations(db) { db.exec(CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)) const current db.prepare(SELECT MAX(version) as v FROM schema_version).get() const currentVersion current.v || 0 const pending migrations.filter(m m.version currentVersion) if (pending.length 0) return const tx db.transaction(() { for (const m of pending) { m.up(db) db.prepare(INSERT INTO schema_version (version) VALUES (?)).run(m.version) } }) tx() console.log(数据库迁移完成从 v${currentVersion} 升级到 v${migrations[migrations.length-1].version}) } module.exports { runMigrations }这个迁移系统的核心思想是每个版本一个迁移函数按顺序执行用事务包起来。事务保证要么全部成功要么全部回滚不会出现“迁移到一半崩了数据库处于半新半旧状态”的尴尬局面。实操心得迁移函数一旦发布就不要再改。如果你发现 v2 的迁移写错了正确做法是加一个 v3 来修正而不是回头改 v2。因为已经升级过的用户不会重新执行 v2改了也没用反而会让新用户和老用户的数据库结构不一致。3.3 IPC 接口的分层设计渲染进程不能直接碰数据库所有操作都要通过 IPC。我习惯把 IPC 接口按业务域分组而不是一股脑全塞在一个文件里。预加载脚本里这样暴露// src/preload/index.js const { contextBridge, ipcRenderer } require(electron) contextBridge.exposeInMainWorld(dbAPI, { user: { list: () ipcRenderer.invoke(db:user:list), create: (data) ipcRenderer.invoke(db:user:create, data), update: (id, data) ipcRenderer.invoke(db:user:update, id, data), remove: (id) ipcRenderer.invoke(db:user:remove, id) } })主进程里注册对应的 handler// src/main/ipc/user.js const { ipcMain } require(electron) const { getDb } require(../database/connection) function registerUserHandlers() { ipcMain.handle(db:user:list, () { const db getDb() return db.prepare(SELECT * FROM users ORDER BY created_at DESC).all() }) ipcMain.handle(db:user:create, (_, data) { const db getDb() const stmt db.prepare(INSERT INTO users (name, email) VALUES (?, ?)) const info stmt.run(data.name, data.email) return { id: info.lastInsertRowid } }) ipcMain.handle(db:user:update, (_, id, data) { const db getDb() db.prepare(UPDATE users SET name ?, email ? WHERE id ?).run(data.name, data.email, id) return { success: true } }) ipcMain.handle(db:user:remove, (_, id) { const db getDb() db.prepare(DELETE FROM users WHERE id ?).run(id) return { success: true } }) } module.exports { registerUserHandlers }这种分层的好处是渲染进程的代码完全感知不到数据库的存在它只是调用window.dbAPI.user.list()这样的方法。将来如果要把 SQLite 换成别的存储方案只需要改主进程的实现渲染进程一行都不用动。注意ipcMain.handle注册的 handler 里如果抛异常渲染进程的invoke会 reject。所以业务代码里要做好 try-catch把数据库错误转换成友好的错误信息返回而不是让原始堆栈直接暴露给渲染进程。4. 打包配置让原生模块在生产环境正常工作4.1 electron-builder 的 asarUnpack 配置这是整个工程化实践里最关键、也最容易出错的一环。Electron 打包时默认会把所有代码打进一个app.asar归档文件。asar 是个只读的虚拟文件系统而原生模块的.node文件需要被操作系统的动态链接器加载它没法从 asar 里直接加载。所以必须把better-sqlite3排除在 asar 之外。在electron-builder.yml或package.json的build字段里配置asar: true asarUnpack: - **/node_modules/better-sqlite3/** - **/node_modules/bindings/** - **/node_modules/prebuild-install/** files: - out/** - node_modules/** - !node_modules/.cache/**asarUnpack的意思是这些文件仍然会被打进 asar 的索引但实际内容会被解压到app.asar.unpacked目录下。运行时 Electron 会自动把路径重定向到 unpacked 目录所以你的require(better-sqlite3)代码不用改。为什么还要 unpackbindings和prebuild-install因为better-sqlite3内部依赖bindings来定位.node文件而bindings会去读文件系统。如果bindings本身在 asar 里它的路径解析逻辑可能会出问题。把这两个也 unpack 掉能避免很多诡异的“模块找不到”错误。4.2 跨平台打包的注意事项如果你要同时出 Windows、macOS、Linux 三个平台的包有几个坑必须提前知道。Windows 平台better-sqlite3的.node文件在 Windows 上是.node后缀的 DLL。打包时确保asarUnpack生效另外如果你的应用要支持 32 位系统需要单独编译 32 位的原生模块不能直接用 64 位的。macOS 平台从 macOS Catalina 开始所有可执行文件都需要签名和公证notarization。原生模块的.node文件也算可执行文件所以签名时要确保它被正确签名。electron-builder 的afterSign钩子可以处理这个。另外 Apple SiliconM 系列芯片和 Intel 芯片的架构不同原生模块需要分别编译或者用 universal binary 合并。Linux 平台Linux 上打包成 AppImage 或 deb 时原生模块的依赖库比如libsqlite3需要确认目标系统上有。better-sqlite3是静态链接 SQLite 的所以通常不依赖系统的libsqlite3这点比较省心。但 glibc 版本要注意在较新的系统上编译的模块可能在老系统上跑不起来。我的建议是在哪个平台发布就在哪个平台打包。不要试图在 Windows 上交叉编译 macOS 的包原生模块这块几乎必翻车。CI 里用 GitHub Actions 的 matrix 策略三个平台各跑各的是最稳的方案。4.3 打包后的路径验证打包完成后别急着发布先本地装一遍验证。重点检查两件事第一打开应用的开发者工具如果是生产包可能需要临时开启看主进程日志里数据库是否正常初始化。第二去安装目录下找resources/app.asar.unpacked/node_modules/better-sqlite3/build/Release/目录确认better_sqlite3.node文件存在。如果运行时还是报模块加载失败八成是asarUnpack的路径匹配写错了。可以在electron-builder的配置里加--debug参数看详细的文件处理日志。5. 常见问题排查与避坑经验实录5.1 原生模块相关的典型报错下面这张表是我在实际项目中遇到过的、跟better-sqlite3集成相关的高频问题按出现频率排序报错信息根本原因解决方案NODE_MODULE_VERSION XX. This version of Node.js requires NODE_MODULE_VERSION YY原生模块编译时的 ABI 与当前 Electron 的 ABI 不匹配执行npm run rebuild确保针对当前 Electron 版本重新编译Cannot find module better-sqlite3打包时模块没被正确包含或 asarUnpack 路径不对检查files和asarUnpack配置确认 unpacked 目录下有.node文件The module was compiled against a different Node.js version开发环境用了系统 Node 编译没走 electron-rebuild删除node_modules/better-sqlite3/build目录重新执行 rebuildSQLITE_CANTOPEN数据库文件路径不存在或没有写权限检查app.getPath(userData)目录是否存在必要时手动创建database is locked多个连接同时写或 WAL 文件没清理确保用单例连接检查是否有未关闭的 db 实例5.2 开发环境与生产环境行为不一致的排查思路这是最让人头疼的一类问题npm run dev一切正常打包后各种报错。我的排查套路是固定的三步第一步确认模块加载路径。在主进程里加一行日志打印require.resolve(better-sqlite3)的结果。开发环境应该是node_modules/better-sqlite3/lib/index.js生产环境应该是app.asar.unpacked/node_modules/better-sqlite3/lib/index.js。如果生产环境打印的是 asar 内的路径说明 unpack 没生效。第二步确认 ABI 版本。打印process.versions.modules这是当前 Electron 的 ABI 版本号。再去看better-sqlite3编译产物对应的 ABI两者必须一致。第三步确认数据库文件路径。生产环境的userData路径和开发环境不同如果代码里硬编码了路径生产环境就会找不到。永远用app.getPath()动态获取。5.3 性能与稳定性的实操心得用了两年多better-sqlite3攒了几条实打实的经验都是文档里不会写的。批量插入一定要用事务。单条INSERT在 WAL 模式下每次都要写日志一万条数据可能要几十秒。包在事务里同样的数据量能压到一秒以内。写法是const insertMany db.transaction((rows) { for (const row of rows) stmt.run(row) }); insertMany(data)。预编译语句要复用。db.prepare()每次调用都会解析 SQL虽然 SQLite 有缓存但显式复用Statement对象更稳妥。我习惯在模块初始化时把所有常用语句 prepare 好存成常量。定期执行VACUUM。删除大量数据后数据库文件不会自动缩小需要手动VACUUM。但这个操作会重建整个数据库比较耗时建议放在应用空闲时或退出前执行不要频繁调用。WAL 文件要纳入备份范围。如果你的应用有数据导出/备份功能只复制.db文件是不够的-wal和-shm文件也要一起复制否则可能丢失最近的写入。更稳妥的做法是备份前先执行db.pragma(wal_checkpoint(TRUNCATE))把 WAL 内容合并回主文件。注意 Electron 的app.on(before-quit)时机。退出前要确保数据库连接被正确关闭否则 WAL 文件可能残留。但before-quit里做异步操作要小心因为 Electron 可能不等你完成就退出了。我的做法是在before-quit里同步调用db.close()better-sqlite3的 close 是同步的正好合适。6. 渲染进程的数据调用与状态同步6.1 在 Vue 组件里优雅地调用数据库接口预加载脚本暴露了window.dbAPI之后渲染进程的调用其实很直接。但直接在每个组件里写await window.dbAPI.user.list()会导致逻辑分散、错误处理重复。我的做法是再包一层 service// src/renderer/src/services/userService.js export const userService { async list() { try { return await window.dbAPI.user.list() } catch (e) { console.error(获取用户列表失败:, e) throw new Error(数据加载失败请稍后重试) } }, async create(data) { if (!data.name) throw new Error(用户名不能为空) return await window.dbAPI.user.create(data) } }这层 service 的价值在于统一错误处理、做前端侧的参数校验、将来换存储方案时只改这一层。组件里只管调userService.list()不关心底层是 SQLite 还是别的。6.2 数据变更后的界面刷新策略桌面应用和 Web 应用有个区别数据变更往往来自用户自己的操作不像 Web 那样有多个客户端并发修改。所以不需要搞复杂的实时同步简单的“操作后重新拉取”就够了。但有个细节要注意IPC 调用是异步的如果用户快速连续操作可能出现后发的请求先返回导致界面显示旧数据。解决办法是在 service 层加一个请求序号只接受最新序号的响应let listSeq 0 async list() { const seq listSeq const data await window.dbAPI.user.list() if (seq ! listSeq) return null // 丢弃过期响应 return data }这个模式在搜索框实时查询的场景下特别有用能避免输入“abc”时先返回“ab”的结果再返回“abc”的结果造成的闪烁。6.3 大数据量列表的渲染优化SQLite 查询本身很快但如果一次返回几千条记录渲染进程的列表渲染会成为瓶颈。我的经验是查询层做分页渲染层做虚拟滚动。查询层用LIMIT和OFFSETipcMain.handle(db:user:list, (_, { page 1, pageSize 50 }) { const db getDb() const offset (page - 1) * pageSize const rows db.prepare(SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?).all(pageSize, offset) const total db.prepare(SELECT COUNT(*) as c FROM users).get().c return { rows, total } })渲染层用vue-virtual-scroller或类似的虚拟滚动组件只渲染可视区域内的 DOM。两者配合即使数据库里有十万条记录界面依然流畅。实操心得OFFSET分页在数据量极大时性能会下降因为 SQLite 需要扫描并跳过前面的记录。如果分页深度经常超过几千页改用基于游标的分页WHERE id lastId ORDER BY id DESC LIMIT ?会快很多。桌面应用一般到不了这个量级但知道这个优化点没坏处。7. 构建流程的自动化与 CI 集成7.1 把 rebuild 和打包串成一条命令手动执行 rebuild 再打包容易忘我习惯在package.json里定义一条完整的构建命令scripts: { build: electron-vite build, rebuild: electron-rebuild -f -w better-sqlite3, dist: npm run build npm run rebuild electron-builder, dist:win: npm run build npm run rebuild electron-builder --win, dist:mac: npm run build npm run rebuild electron-builder --mac, dist:linux: npm run build npm run rebuild electron-builder --linux }注意顺序先electron-vite build把源码编译到out目录再rebuild原生模块最后electron-builder打包。rebuild 放在 build 之后是因为 rebuild 会修改node_modules里的.node文件如果先 rebuild 再 build理论上也没问题但放后面更符合直觉。7.2 GitHub Actions 多平台构建配置CI 配置的核心是 matrix 策略三个平台并行跑name: Build on: push: tags: [v*] jobs: build: strategy: matrix: os: [windows-latest, macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 20 - run: npm ci - run: npm run dist env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: actions/upload-artifactv4 with: name: dist-${{ matrix.os }} path: dist/npm ci而不是npm install因为 CI 环境需要可复现的安装ci会严格按package-lock.json安装。GH_TOKEN是 electron-builder 发布到 GitHub Release 需要的如果只是构建产物不上传可以去掉。注意macOS 的 runner 上编译原生模块需要 XcodeGitHub 的 macOS runner 默认带 Xcode但版本可能和你的项目要求不一致。如果遇到编译错误用xcode-select -p确认当前 Xcode 路径必要时用sudo xcode-select -s切换。7.3 构建产物的体积优化Electron 应用打包出来动辄一两百兆其中 Electron 运行时占了大头这个没法避免。但better-sqlite3相关的部分可以优化。better-sqlite3的build目录里除了.node文件还有一堆编译中间产物.o文件、.deps目录等这些在打包时应该排除掉。在electron-builder.yml里加files: - !node_modules/better-sqlite3/build/Release/obj/** - !node_modules/better-sqlite3/build/Release/obj.target/** - !node_modules/better-sqlite3/deps/** - !node_modules/better-sqlite3/src/**deps目录里是 SQLite 的源码src是 C 源码这些在运行时都不需要只保留build/Release/better_sqlite3.node就够了。这样能省下几兆到十几兆的空间虽然不多但积少成多。8. 版本升级与长期维护的注意事项8.1 Electron 升级时原生模块的连锁反应Electron 每次大版本升级Node ABI 几乎必变。升级 Electron 之后better-sqlite3必须重新 rebuild而且better-sqlite3本身可能也需要升级到支持新 ABI 的版本。我的升级流程是固定的先看better-sqlite3的 release notes确认它支持目标 Electron 版本然后升级 Electron 和better-sqlite3接着删掉node_modules和package-lock.json重新npm install最后跑npm run rebuild和完整的打包测试。整个过程在 CI 上先跑一遍确认没问题再合并到主分支。8.2 数据库文件的向后兼容应用升级时新版本可能改了数据库结构。前面讲的迁移机制能处理结构变更但有个前提迁移脚本必须能处理任意旧版本。也就是说如果用户从 v1 直接跳到 v5迁移系统要能依次执行 v2、v3、v4、v5 的迁移。我的迁移系统设计成按版本号顺序执行天然支持这种跳跃升级。但有个例外情况如果某个中间版本的迁移脚本依赖了当时的数据状态跳跃升级可能出问题。所以我在写迁移脚本时有个原则每个迁移脚本只依赖 schema不依赖具体数据。这样无论从哪个版本升上来结果都一致。8.3 数据备份与恢复的用户侧设计桌面应用的用户往往没有备份习惯一旦数据库损坏数据就没了。我习惯在应用里内置一个自动备份机制每次应用启动时把当前数据库文件复制一份到userData/backups/目录保留最近 5 个备份超过的自动删除。function backupDatabase() { const dbPath getDatabasePath() const backupDir path.join(app.getPath(userData), backups) if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true }) const timestamp new Date().toISOString().replace(/[:.]/g, -) const backupPath path.join(backupDir, app-${timestamp}.db) const db getDb() db.pragma(wal_checkpoint(TRUNCATE)) fs.copyFileSync(dbPath, backupPath) // 清理旧备份只保留最近 5 个 const backups fs.readdirSync(backupDir).filter(f f.endsWith(.db)).sort() while (backups.length 5) { fs.unlinkSync(path.join(backupDir, backups.shift())) } }这个机制实现简单但对用户数据的保护效果立竿见影。我负责过的几个项目里靠这个备份救回过好几次用户误删的数据。实操心得备份前执行wal_checkpoint(TRUNCATE)很关键。如果不做这一步WAL 文件里的最新数据不会合并到主.db文件备份出来的可能是旧数据。这个坑我踩过一次用户反馈“备份恢复后丢了几条记录”排查半天才发现是 WAL 没 checkpoint。9. 我在这套方案上踩过的几个真实坑第一个坑是electron-rebuild和electron/rebuild混用。早期项目里两个包都装了结果 rebuild 时不知道用了哪个行为不一致。后来统一只用electron/rebuild把老的卸干净。第二个坑是asarUnpack用了相对路径。我一开始写的是node_modules/better-sqlite3/**结果打包后 unpacked 目录是空的。后来发现必须用**/node_modules/better-sqlite3/**这种带通配符前缀的写法因为 electron-builder 的路径匹配是从项目根开始的不同层级的node_modules都要覆盖到。第三个坑是开发时用了系统 Node 的better-sqlite3。有次我在项目里同时跑了另一个纯 Node 脚本那个脚本npm install时把better-sqlite3编译成了系统 Node 的 ABI覆盖了 Electron 的版本。结果 Electron 启动就报 ABI 错误。解决办法是给 Electron 项目单独一个node_modules或者用nvm之类的工具隔离 Node 版本。第四个坑是macOS 公证时原生模块没签名。打包出来的 dmg 在用户机器上打开报“应用已损坏”其实是公证没通过。原因是app.asar.unpacked里的.node文件没被签名。electron-builder 的afterSign钩子里要显式对 unpacked 目录做签名或者用electron/notarize处理。这些坑的共同特点是开发环境完全正常只在打包或分发环节暴露。所以我的建议是项目一开始就把打包流程跑通别等到功能都做完了才第一次打包。越早暴露问题修复成本越低。10. 关于这套方案适用边界的个人看法better-sqlite3加electron-vite这套组合我个人认为最适合的是单用户、数据量在百万级以内、需要复杂查询的桌面应用。比如笔记软件、本地 CRM、数据采集工具、离线报表系统这类。它的优势是零配置、无服务端、查询能力强、事务可靠。但如果你的应用需要多用户并发访问同一份数据或者数据量到了千万级以上SQLite 就不合适了该上真正的客户端-服务端数据库。另外如果应用需要频繁的全文检索SQLite 的 FTS5 扩展虽然能用但性能和专业搜索引擎比还是有差距可以考虑配合flexsearch之类的纯 JS 方案做前端检索。还有一点better-sqlite3的同步 API 在主进程里用没问题但千万别在渲染进程里直接 require 它。渲染进程是浏览器环境加载原生模块会破坏contextIsolation的安全模型而且 Electron 官方也不推荐这么做。所有数据库操作走 IPC这是铁律。最后分享一个我最近才用上的小技巧better-sqlite3支持自定义函数注册可以把一些 JS 逻辑注册成 SQL 函数在查询里直接调用。比如注册一个slugify函数然后写SELECT slugify(title) FROM articles。这在做数据迁移或批量处理时特别方便省得把数据拉到 JS 里再处理一遍。用法是db.function(slugify, (str) str.toLowerCase().replace(/\s/g, -))注册一次全局可用。
网站建设高端定制企业官网