Electron桌面应用开发:SQLite数据库完整CRUD操作指南(附M1芯片适配方案)

最近在重构一个跨平台的桌面工具时,我再次把目光投向了本地数据存储方案。项目需要处理大量结构化的配置信息和用户生成的内容,这些数据不仅需要持久化,还得支持复杂的查询和关联。虽然Electron内置了localStorage和IndexedDB,但面对多表关联、事务处理和复杂SQL查询时,它们就显得有些力不从心了。这时候,一个老而弥坚的选择浮出水面——SQLite。

SQLite的魅力在于它的“零配置”和“无服务器”特性。你不需要像传统数据库那样搭建一个服务进程,它就是一个嵌入式的、基于文件的数据库引擎。对于桌面应用来说,这意味着你可以把整个数据库打包成一个.db文件,随应用分发,用户完全无感。从记账软件到代码编辑器,从聊天工具到项目管理应用,SQLite的身影无处不在,它几乎成了桌面端本地结构化数据存储的事实标准。

但现实总是比理想骨感。当我在搭载M1芯片的MacBook上兴致勃勃地开始新项目时,一个经典的兼容性问题迎面而来:预编译的sqlite3原生模块在ARM64架构上罢工了。这不仅仅是Mac用户的问题,随着ARM架构在桌面端的普及,这已经成为一个必须跨越的坎。本文将带你从零开始,在Electron中集成SQLite,实现完整的增删改查(CRUD)操作,并重点攻克M1/M2芯片的编译适配难题,让你无论使用什么硬件,都能顺畅地构建数据驱动的桌面应用。

1. 项目初始化与环境搭建

开始之前,我们需要一个干净的Electron项目作为舞台。如果你已经有一个现成的项目,可以跳过这一步,但我建议即使是老手也快速过一遍,确保依赖和结构是最新的。

打开终端,创建一个新的项目目录并初始化:

mkdir electron-sqlite-demo
cd electron-sqlite-demo
npm init -y

接下来,安装Electron的核心依赖。这里我倾向于安装为开发依赖,因为最终打包时Electron会自带运行时。

npm install electron --save-dev

现在,编辑生成的package.json文件,添加启动脚本并确认入口点。你的package.json应该看起来类似这样:

{
  "name": "electron-sqlite-demo",
  "version": "1.0.0",
  "description": "A demo for SQLite in Electron",
  "main": "main.js",
  "scripts": {
    "start": "electron .",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "devDependencies": {
    "electron": "^28.0.0"
  }
}

提示:Electron版本迭代较快,建议在安装时指定一个稳定的主版本(如electron@28),以避免因版本差异导致的API不兼容问题。

基础骨架有了,我们创建三个核心文件:main.js(主进程)、preload.js(预加载脚本)和index.html(渲染进程页面)。这是Electron应用最基础的“三件套”。

首先创建main.js,这是应用的心脏——主进程。

const { app, BrowserWindow } = require('electron');
const path = require('path');

function createWindow() {
  const win = new BrowserWindow({
    width: 1000,
    height: 700,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      // 注意:在生产环境中,应谨慎考虑是否启用nodeIntegration
      nodeIntegration: false,
      contextIsolation: true // 强烈建议保持启用上下文隔离
    }
  });

  // 加载本地文件
  win.loadFile('index.html');
  // 开发阶段打开开发者工具会很方便
  // win.webContents.openDevTools();
}

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

这里有几个关键点值得注意。webPreferences中的contextIsolation(上下文隔离)和nodeIntegration(Node.js集成)是安全性的基石。现代Electron应用的最佳实践是启用上下文隔离并禁用Node.js集成,然后通过预加载脚本(preload.js)有控制地暴露有限的API给渲染进程。这能有效防止渲染进程中的潜在恶意代码直接访问Node.js环境,造成安全风险。

接下来是preload.js,它充当主进程和渲染进程之间的安全桥梁。

const { contextBridge } = require('electron');

// 将所有需要暴露给渲染进程的API集中在这里
contextBridge.exposeInMainWorld('electronAPI', {
  // 我们后续会将数据库操作方法挂载到这里
  platform: process.platform,
  arch: process.arch
});

最后,创建一个简单的index.html作为应用界面。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Electron SQLite 数据管理</title>
    <style>
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; }
        .container { max-width: 800px; margin: 0 auto; }
        .input-group { margin-bottom: 15px; }
        input, button { padding: 10px; margin-right: 10px; font-size: 16px; }
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
        th { background-color: #f4f4f4; }
    </style>
</head>
<body>
    <div class="container">
        <h1>用户数据管理 (CRUD)</h1>
        <div class="input-group">
            <input type="text" id="username" placeholder="请输入用户名">
            <input type="number" id="age" placeholder="年龄">
            <button id="createBtn">新增用户</button>
        </div>
        <div>
            <button id="loadBtn">加载所有用户</button>
        </div>
        <table id="userTable">
            <thead>
                <tr>
                    <th>ID</th>
                    <th>用户名</th>
                    <th>年龄</th>
                    <th>操作</th>
                </tr>
            </thead>
            <tbody>
                <!-- 数据行将通过JavaScript动态插入 -->
            </tbody>
        </table>
    </div>
    <script src="./renderer.js"></script>
</body>
</html>

我们还需要一个renderer.js来处理页面逻辑,先留空,后续填充。现在,运行npm start,你应该能看到一个简单的Electron窗口弹出。至此,舞台已经搭好,主角SQLite即将登场。

2. SQLite集成与M1/M2芯片编译适配

这是整个过程中最具挑战性的一环,尤其是在Apple Silicon(M1/M2)的Mac上。sqlite3这个npm包包含需要编译的原生C++扩展,默认提供的预编译二进制文件是针对x86_64架构的,在ARM64架构上直接安装会导致运行时错误。

2.1 理解问题根源

当你执行npm install sqlite3时,npm会尝试下载与你的平台和架构匹配的预编译二进制文件。如果找不到,它会回退到从源代码编译。对于Intel芯片的Mac(x64)和Windows/Linux的常见架构,预编译文件通常存在。但对于ARM64架构的Mac,这个预编译文件默认不存在,因此必须触发从源码编译。

然而,从源码编译需要本地开发环境具备编译工具链(如Python、C++编译器、node-gyp等)。如果环境不完整,安装就会失败。

2.2 可靠的安装方案

经过多次实践,我总结出最稳定可靠的安装命令组合。在你的项目根目录下执行:

# 首先,确保安装了node-gyp的全局构建工具
npm install -g node-gyp

# 然后,使用以下参数安装sqlite3
npm install sqlite3 --build-from-source --target_arch=arm64 --fallback-to-build

我们来拆解一下这几个参数:

  • --build-from-source:强制从源代码编译,忽略任何预编译的二进制文件。
  • --target_arch=arm64:明确告诉编译系统,目标架构是ARM64(即M1/M2芯片)。这对于交叉编译或在Rosetta 2环境下确保生成正确的二进制文件至关重要。
  • --fallback-to-build:如果其他安装方法失败,则回退到从源代码构建。这是一个保险策略。

注意:如果你在基于Intel的Mac或Windows/Linux上开发,可以省略--target_arch参数,或者使用--target_arch=x64。但为了脚本的统一性,你可以根据process.arch动态决定,这在后面会提到。

2.3 跨平台安装脚本优化

为了让项目在任何开发者的机器上都能顺利安装,我们可以将安装逻辑写进package.json的scripts中,甚至利用npm的preinstall或postinstall钩子。但更优雅的方式是创建一个安装脚本。

在项目根目录创建一个scripts/install-sqlite.js文件:

const { execSync } = require('child_process');
const os = require('os');

const platform = os.platform();
const arch = os.arch();

console.log(`检测到系统: ${platform}, 架构: ${arch}`);

let installCommand = 'npm install sqlite3';

if (platform === 'darwin' && arch === 'arm64') {
  // Apple Silicon Mac
  installCommand += ' --build-from-source --target_arch=arm64 --fallback-to-build';
  console.log('正在为Apple Silicon (M1/M2) Mac编译sqlite3...');
} else if (platform === 'darwin' && arch === 'x64') {
  // Intel Mac (可能运行在Rosetta 2下)
  // 为了兼容,也可以选择从源码编译
  installCommand += ' --build-from-source --target_arch=x64';
  console.log('正在为Intel Mac编译sqlite3...');
} else {
  // Windows (x64/ia32) 和 Linux
  // 通常有预编译版本,但也可以加上--fallback-to-build以防万一
  installCommand += ' --fallback-to-build';
  console.log('正在安装sqlite3(尝试使用预编译二进制)...');
}

try {
  execSync(installCommand, { stdio: 'inherit' });
  console.log('sqlite3 安装成功!');
} catch (error) {
  console.error('sqlite3 安装失败:', error.message);
  process.exit(1);
}

然后,在package.json的scripts里添加一条命令:

"scripts": {
  "start": "electron .",
  "install:sqlite": "node scripts/install-sqlite.js",
  "postinstall": "npm run install:sqlite"
}

这样,当其他开发者克隆你的项目后,只需运行npm install,postinstall钩子会自动触发我们的定制安装脚本,根据他们的系统架构选择正确的安装方式。

2.4 验证安装与连接数据库

安装成功后,我们回到main.js,开始集成SQLite。首先,在文件顶部引入模块:

// main.js 顶部添加
const sqlite3 = require('sqlite3').verbose();
const path = require('path');

verbose()模式会提供更详细的错误信息,在开发阶段非常有用。接下来,我们在应用准备就绪后创建数据库连接。一个常见的做法是将数据库文件放在用户的应用数据目录下,这样符合各操作系统的规范,也便于管理。

// 在 app.whenReady().then() 内部,createWindow() 之后
app.whenReady().then(() => {
  createWindow();
  initializeDatabase(); // 初始化数据库
  // ... 其他代码
});

function initializeDatabase() {
  // 获取用户数据目录,例如 ~/Library/Application Support/your-app-name 或 %APPDATA%\your-app-name
  const userDataPath = app.getPath('userData');
  const dbPath = path.join(userDataPath, 'app_data.db');

  const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, (err) => {
    if (err) {
      console.error('无法连接数据库:', err.message);
    } else {
      console.log(`已连接到数据库: ${dbPath}`);
      createTables(db); // 连接成功后创建表
    }
  });

  // 将db实例挂载到全局,方便其他函数使用(简单示例,生产环境建议更好管理)
  global.sharedObject = { db };
}

数据库连接建立后,我们需要创建存储数据的表。这里以用户表为例:

function createTables(db) {
  const createUserTableSQL = `
    CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      username TEXT NOT NULL UNIQUE,
      age INTEGER,
      email TEXT,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `;

  db.run(createUserTableSQL, function(err) {
    if (err) {
      console.error('创建表失败:', err.message);
    } else {
      console.log('用户表已就绪(或已存在)');
    }
  });
}

现在,运行npm start,检查终端日志。你应该能看到“已连接到数据库”和“用户表已就绪”的消息。如果遇到类似Module did not self-register的错误,那说明sqlite3原生模块的架构不匹配,需要回头检查编译步骤。

3. 实现主进程与渲染进程的CRUD通信

数据库连接和表结构准备好了,接下来要在主进程(Node.js环境)中实现具体的数据库操作函数,并通过Electron的进程间通信(IPC)机制,安全地暴露给渲染进程(浏览器环境)调用。

3.1 完善主进程数据库操作

我们在main.js中继续添加具体的CRUD操作函数,并绑定到IPC监听器上。首先,确保在文件顶部获取到我们之前创建的数据库实例。

// 为了方便,我们在initializeDatabase外也能访问db,这里用一个全局变量(简单示例)
let db;

function initializeDatabase() {
  // ... 之前的连接代码 ...
  db = new sqlite3.Database(dbPath, ...);
  // ... 不再需要挂载到global.sharedObject
}

然后,在app.whenReady().then()回调中,在initializeDatabase()之后,设置IPC监听器。我们使用ipcMain.handle处理需要返回值的异步调用(如查询),用ipcMain.on处理不需要返回值的调用(如增删改)。

const { ipcMain } = require('electron');

// ... 在 app.whenReady().then() 内部,initializeDatabase() 之后 ...
app.whenReady().then(() => {
  createWindow();
  initializeDatabase();
  setupIPCHandlers(); // 设置IPC处理器
  // ...
});

function setupIPCHandlers() {
  if (!db) {
    console.warn('数据库未初始化,IPC处理器设置延迟');
    return;
  }

  // 1. 创建用户 (C)
  ipcMain.handle('create-user', async (event, userData) => {
    return new Promise((resolve, reject) => {
      const { username, age, email } = userData;
      const sql = `INSERT INTO users (username, age, email) VALUES (?, ?, ?)`;
      db.run(sql, [username, age || null, email || null], function(err) {
        if (err) {
          reject(err.message);
        } else {
          // this.lastID 是插入行的ID
          resolve({ id: this.lastID, username, age, email });
        }
      });
    });
  });

  // 2. 读取所有用户 (R)
  ipcMain.handle('get-users', async (event) => {
    return new Promise((resolve, reject) => {
      const sql = `SELECT * FROM users ORDER BY created_at DESC`;
      db.all(sql, [], (err, rows) => {
        if (err) {
          reject(err.message);
        } else {
          resolve(rows);
        }
      });
    });
  });

  // 3. 更新用户 (U)
  ipcMain.handle('update-user', async (event, userData) => {
    return new Promise((resolve, reject) => {
      const { id, username, age, email } = userData;
      const sql = `UPDATE users SET username = ?, age = ?, email = ? WHERE id = ?`;
      db.run(sql, [username, age || null, email || null, id], function(err) {
        if (err) {
          reject(err.message);
        } else {
          // this.changes 表示受影响的行数
          resolve({ changes: this.changes, id });
        }
      });
    });
  });

  // 4. 删除用户 (D)
  ipcMain.handle('delete-user', async (event, id) => {
    return new Promise((resolve, reject) => {
      const sql = `DELETE FROM users WHERE id = ?`;
      db.run(sql, [id], function(err) {
        if (err) {
          reject(err.message);
        } else {
          resolve({ changes: this.changes });
        }
      });
    });
  });

  // 5. 根据ID查询单个用户(可选,用于编辑前填充表单)
  ipcMain.handle('get-user-by-id', async (event, id) => {
    return new Promise((resolve, reject) => {
      const sql = `SELECT * FROM users WHERE id = ?`;
      db.get(sql, [id], (err, row) => {
        if (err) {
          reject(err.message);
        } else {
          resolve(row);
        }
      });
    });
  });
}

注意,我们这里全部使用了ipcMain.handle,因为它返回一个Promise,可以很好地支持async/await语法,使得渲染进程的调用代码更简洁。db.run用于执行不返回数据的SQL语句(INSERT, UPDATE, DELETE),db.all用于返回所有行的查询,db.get用于返回单行。

3.2 扩展预加载脚本API

现在,我们需要更新preload.js,将主进程的这些IPC通道安全地暴露给渲染进程。

// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  // 数据库操作
  createUser: (userData) => ipcRenderer.invoke('create-user', userData),
  getUsers: () => ipcRenderer.invoke('get-users'),
  updateUser: (userData) => ipcRenderer.invoke('update-user', userData),
  deleteUser: (id) => ipcRenderer.invoke('delete-user', id),
  getUserById: (id) => ipcRenderer.invoke('get-user-by-id', id),

  // 工具函数
  on: (channel, func) => {
    // 用于监听主进程主动发送的消息(本例未使用,但模式通用)
    ipcRenderer.on(channel, (event, ...args) => func(...args));
  }
});

关键点在于,我们使用contextBridge.exposeInMainWorld将一组定义清晰的API(electronAPI)注入到渲染进程的window对象上。渲染进程中的JavaScript只能通过这个暴露出来的API与主进程通信,而不能直接访问Node.js的require或ipcRenderer,这构成了重要的安全边界。

3.3 构建渲染进程界面逻辑

最后,我们创建renderer.js,为HTML页面添加交互逻辑。这个文件运行在浏览器环境中,通过我们暴露的window.electronAPI来调用数据库操作。

// renderer.js
document.addEventListener('DOMContentLoaded', () => {
  const usernameInput = document.getElementById('username');
  const ageInput = document.getElementById('age');
  const createBtn = document.getElementById('createBtn');
  const loadBtn = document.getElementById('loadBtn');
  const userTableBody = document.querySelector('#userTable tbody');

  let editingUserId = null; // 用于跟踪当前正在编辑的用户ID

  // 1. 创建用户
  createBtn.addEventListener('click', async () => {
    const username = usernameInput.value.trim();
    const age = parseInt(ageInput.value);

    if (!username) {
      alert('用户名不能为空');
      return;
    }

    try {
      const userData = { username, age: isNaN(age) ? null : age };
      const newUser = await window.electronAPI.createUser(userData);
      console.log('用户创建成功:', newUser);
      usernameInput.value = '';
      ageInput.value = '';
      alert(`用户 "${username}" 创建成功,ID: ${newUser.id}`);
      loadUsers(); // 创建后刷新列表
    } catch (error) {
      console.error('创建用户失败:', error);
      alert(`创建失败: ${error}`);
    }
  });

  // 2. 加载并显示所有用户
  async function loadUsers() {
    try {
      const users = await window.electronAPI.getUsers();
      renderUserTable(users);
    } catch (error) {
      console.error('加载用户失败:', error);
      alert(`加载用户列表失败: ${error}`);
    }
  }

  loadBtn.addEventListener('click', loadUsers);

  // 首次加载数据
  loadUsers();

  // 渲染表格的函数
  function renderUserTable(users) {
    userTableBody.innerHTML = ''; // 清空现有内容

    if (users.length === 0) {
      const row = document.createElement('tr');
      row.innerHTML = `<td colspan="4" style="text-align: center;">暂无用户数据</td>`;
      userTableBody.appendChild(row);
      return;
    }

    users.forEach(user => {
      const row = document.createElement('tr');
      row.innerHTML = `
        <td>${user.id}</td>
        <td>${user.username}</td>
        <td>${user.age || 'N/A'}</td>
        <td>
          <button class="edit-btn" data-id="${user.id}">编辑</button>
          <button class="delete-btn" data-id="${user.id}">删除</button>
        </td>
      `;
      userTableBody.appendChild(row);
    });

    // 为动态生成的按钮绑定事件
    attachTableButtonEvents();
  }

  // 为表格中的编辑和删除按钮绑定事件
  function attachTableButtonEvents() {
    document.querySelectorAll('.edit-btn').forEach(btn => {
      btn.addEventListener('click', async (e) => {
        const id = e.target.getAttribute('data-id');
        await editUser(id);
      });
    });

    document.querySelectorAll('.delete-btn').forEach(btn => {
      btn.addEventListener('click', async (e) => {
        const id = e.target.getAttribute('data-id');
        if (confirm('确定要删除这个用户吗?')) {
          await deleteUser(id);
        }
      });
    });
  }

  // 3. 编辑用户
  async function editUser(id) {
    try {
      const user = await window.electronAPI.getUserById(id);
      if (user) {
        editingUserId = user.id;
        usernameInput.value = user.username;
        ageInput.value = user.age || '';
        createBtn.textContent = '更新用户';
        // 修改创建按钮的行为为更新
        createBtn.replaceWith(createBtn.cloneNode(true)); // 移除旧监听器
        const newCreateBtn = document.getElementById('createBtn');
        newCreateBtn.textContent = '更新用户';
        newCreateBtn.addEventListener('click', performUpdate);
      }
    } catch (error) {
      console.error('获取用户信息失败:', error);
    }
  }

  async function performUpdate() {
    const username = usernameInput.value.trim();
    const age = parseInt(ageInput.value);

    if (!username || !editingUserId) {
      alert('数据不完整');
      return;
    }

    try {
      const result = await window.electronAPI.updateUser({
        id: editingUserId,
        username,
        age: isNaN(age) ? null : age
      });
      alert(`用户更新成功,影响行数: ${result.changes}`);
      resetForm();
      loadUsers();
    } catch (error) {
      console.error('更新用户失败:', error);
      alert(`更新失败: ${error}`);
    }
  }

  // 4. 删除用户
  async function deleteUser(id) {
    try {
      const result = await window.electronAPI.deleteUser(id);
      if (result.changes > 0) {
        alert('用户删除成功');
        loadUsers();
      } else {
        alert('未找到要删除的用户');
      }
    } catch (error) {
      console.error('删除用户失败:', error);
      alert(`删除失败: ${error}`);
    }
  }

  // 重置表单和按钮状态
  function resetForm() {
    editingUserId = null;
    usernameInput.value = '';
    ageInput.value = '';
    createBtn.textContent = '新增用户';
    // 恢复创建按钮的原始行为
    createBtn.replaceWith(createBtn.cloneNode(true));
    const newCreateBtn = document.getElementById('createBtn');
    newCreateBtn.textContent = '新增用户';
    newCreateBtn.addEventListener('click', createUserHandler);
  }

  // 由于我们动态替换了按钮,需要重新定义原始的创建处理函数
  async function createUserHandler() {
    // 这是最初创建用户的逻辑,与createBtn的初始监听器相同
    const username = usernameInput.value.trim();
    const age = parseInt(ageInput.value);
    if (!username) {
      alert('用户名不能为空');
      return;
    }
    try {
      const userData = { username, age: isNaN(age) ? null : age };
      const newUser = await window.electronAPI.createUser(userData);
      alert(`用户 "${username}" 创建成功,ID: ${newUser.id}`);
      usernameInput.value = '';
      ageInput.value = '';
      loadUsers();
    } catch (error) {
      console.error('创建用户失败:', error);
      alert(`创建失败: ${error}`);
    }
  }

  // 初始化创建按钮的监听器
  createBtn.addEventListener('click', createUserHandler);
});

现在,重新启动应用(npm start)。你应该能看到一个功能完整的用户管理界面:可以新增用户、加载显示所有用户、编辑现有用户信息以及删除用户。所有的操作都通过IPC调用主进程,由主进程的SQLite模块执行真正的数据库操作,并将结果返回。

4. 高级技巧、性能优化与生产环境实践

一个基础的CRUD应用已经完成,但要将其用于真实项目,我们还需要考虑更多。下面是一些进阶主题,能让你Electron + SQLite的应用更加健壮和高效。

4.1 使用Async/Await包装回调风格的SQLite API

原生的sqlite3 API是基于回调的,这可能导致“回调地狱”。我们可以用util.promisify或手动封装,将其转换为更友好的Promise风格。这里我更喜欢手动封装,以便于添加自定义逻辑。

在main.js中,创建一个数据库操作工具模块(可以单独放在db.js文件中):

// utils/db.js
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const { app } = require('electron');

class Database {
  constructor() {
    const userDataPath = app.getPath('userData');
    this.dbPath = path.join(userDataPath, 'app_data.db');
    this.db = null;
  }

  connect() {
    return new Promise((resolve, reject) => {
      this.db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, (err) => {
        if (err) reject(err);
        else {
          console.log(`数据库连接成功: ${this.dbPath}`);
          resolve();
        }
      });
    });
  }

  run(sql, params = []) {
    return new Promise((resolve, reject) => {
      this.db.run(sql, params, function(err) {
        if (err) reject(err);
        else resolve({ lastID: this.lastID, changes: this.changes });
      });
    });
  }

  get(sql, params = []) {
    return new Promise((resolve, reject) => {
      this.db.get(sql, params, (err, row) => {
        if (err) reject(err);
        else resolve(row);
      });
    });
  }

  all(sql, params = []) {
    return new Promise((resolve, reject) => {
      this.db.all(sql, params, (err, rows) => {
        if (err) reject(err);
        else resolve(rows);
      });
    });
  }

  close() {
    return new Promise((resolve, reject) => {
      this.db.close((err) => {
        if (err) reject(err);
        else resolve();
      });
    });
  }

  // 初始化表结构
  async initialize() {
    await this.run(`
      CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        username TEXT NOT NULL UNIQUE,
        age INTEGER,
        email TEXT,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP
      )
    `);
    console.log('数据库表初始化完成');
  }
}

module.exports = new Database(); // 导出单例

然后在main.js中引入并使用这个封装好的类:

// main.js
const db = require('./utils/db');

async function initializeApp() {
  await db.connect();
  await db.initialize();
  // ... 后续启动窗口等操作
}

// IPC处理函数也变得非常简洁
ipcMain.handle('get-users', async () => {
  return await db.all('SELECT * FROM users ORDER BY created_at DESC');
});

4.2 数据库连接池与事务处理

对于简单的桌面应用,单个数据库连接通常足够。但如果你的应用有多个窗口或需要处理高并发操作(虽然桌面应用场景较少),可以考虑连接池。不过,SQLite本身是文件数据库,对并发的支持有限(写操作会锁定整个数据库)。更重要的实践是合理使用事务来保证数据一致性。

例如,在转账或批量插入数据时:

// 在 db.js 中添加事务方法
async function runTransaction(callback) {
  await this.run('BEGIN TRANSACTION');
  try {
    await callback(this); // 将db实例传入回调
    await this.run('COMMIT');
  } catch (error) {
    await this.run('ROLLBACK');
    throw error; // 重新抛出错误
  }
}

// 使用示例:批量插入用户
ipcMain.handle('bulk-create-users', async (event, userList) => {
  await db.runTransaction(async (txDb) => {
    for (const user of userList) {
      await txDb.run(
        'INSERT INTO users (username, age) VALUES (?, ?)',
        [user.username, user.age]
      );
    }
  });
  return { success: true, count: userList.length };
});

4.3 数据迁移与版本管理

随着应用迭代,数据库表结构可能需要变更。一个简单的版本管理方案是引入一个version表。

// 在 initialize() 方法中添加
async initialize() {
  // 创建版本表
  await this.run(`
    CREATE TABLE IF NOT EXISTS schema_version (
      version INTEGER PRIMARY KEY,
      applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);

  const currentVersion = await this.get('SELECT MAX(version) as version FROM schema_version');
  const dbVersion = currentVersion.version || 0;

  // 根据版本号执行迁移
  if (dbVersion < 1) {
    await this.run(`CREATE TABLE users (...)`); // 初始表
    await this.run(`INSERT INTO schema_version (version) VALUES (1)`);
  }
  if (dbVersion < 2) {
    await this.run(`ALTER TABLE users ADD COLUMN email TEXT`);
    await this.run(`INSERT INTO schema_version (version) VALUES (2)`);
  }
  // ... 更多版本迁移
}

4.4 生产环境打包注意事项

使用electron-builder或electron-forge打包时,需要确保sqlite3的原生模块能被正确打包。这通常需要在打包配置中指定asarUnpack,将原生模块排除在ASAR归档之外,因为它们在归档内可能无法正常加载。

以electron-builder为例,在package.json中配置:

"build": {
  "appId": "com.yourcompany.yourapp",
  "files": [
    "**/*",
    "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
    "!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}",
    "!**/node_modules/*.d.ts",
    "!**/*.map"
  ],
  "asarUnpack": [
    "**/node_modules/sqlite3/**"
  ],
  "mac": {
    "target": "dmg",
    "arch": ["x64", "arm64"] // 构建通用二进制包,同时支持Intel和Apple Silicon
  },
  "win": {
    "target": "nsis"
  },
  "linux": {
    "target": "AppImage"
  }
}

最关键的一步是,在打包前,你需要为目标平台重新编译sqlite3。你不能直接把在Apple Silicon Mac上编译的sqlite3模块打包进给Windows用户的应用。解决方案是使用CI/CD(持续集成/持续部署)在不同操作系统上分别构建,或者使用electron-rebuild在打包过程中针对Electron的Node版本重新编译所有原生模块。

# 安装 electron-rebuild
npm install --save-dev electron-rebuild

# 在 package.json 的脚本中添加
"scripts": {
  "postinstall": "electron-rebuild",
  "package": "electron-builder"
}

electron-rebuild会检查你的node_modules,并针对你项目使用的Electron版本,重新编译那些包含原生代码的模块。确保在打包前运行它。

4.5 性能监控与调试

对于数据量逐渐增大的应用,监控查询性能很重要。你可以封装自己的查询函数,添加简单的计时日志:

async function queryWithLog(sql, params) {
  const start = Date.now();
  try {
    const result = await db.all(sql, params);
    const duration = Date.now() - start;
    if (duration > 100) { // 记录慢查询(>100ms)
      console.warn(`慢查询警告 [${duration}ms]: ${sql}`);
    }
    return result;
  } catch (error) {
    console.error(`查询失败 [${sql}]:`, error);
    throw error;
  }
}

另外,考虑为频繁查询的字段(如username)添加索引,可以大幅提升查询速度。

CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);

至此,你已经拥有了一个在Electron中集成SQLite进行完整CRUD操作的、兼容M1/M2芯片的、并考虑了生产环境实践的坚实基础。从环境搭建、编译适配、进程通信到高级优化,这套方案应该能覆盖大多数桌面应用本地数据存储的需求。记住,本地数据库的选择只是架构的一部分,清晰的数据层抽象和稳健的错误处理同样重要。在实际项目中,我通常会再抽象一层DataStore类,将所有数据库操作和业务逻辑封装起来,让渲染进程的代码更加纯粹地关注界面交互,但这已经是另一个话题了。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐