Sciter.js 指南 - GUI程序在本地配置与数据的存储与管理

224 阅读8分钟

欢迎来到 Sciter.js 的世界!对于许多桌面 GUI 应用来说,能够在本地持久化存储和管理数据是至关重要的。无论你是经验丰富的前端开发者,还是从其他语言转过来的新手,理解如何在 Sciter.js 中处理本地数据都将是开发过程中的关键一步。本指南将介绍 Sciter.js 提供的两种主要本地数据存储机制:内建的 Storage 模块和集成的 SQLite 数据库。

1. Sciter.js 内建 Storage (@storage)

Sciter.js 提供了一个名为 Storage 的内建模块,它实现了一个基于文件的、类似 NoSQL 的持久化对象存储。你可以把它想象成一个本地的、持久化的 JavaScript 文档数据库,非常适合存储应用程序配置、用户偏好或结构相对简单的数据。

1.1 基本概念

  • 持久化: 数据存储在文件中,即使应用关闭或重启,数据依然存在。
  • 对象存储: 你可以直接存储 JavaScript 对象、数组、基本类型等。
  • 根对象 (storage.root): 数据库的核心是一个根对象。所有的数据都作为这个根对象的属性或嵌套属性来存储。
  • 索引 (Index): 为了快速查找数据,可以创建索引。

1.2 初始化和打开数据库

首先,你需要导入 StorageEnv (用于获取标准路径) 模块。然后使用 Storage.open() 打开或创建一个数据库文件。如果文件不存在,它会被创建。

import * as Storage from "@storage";
import * as Env from "@env";

// 建议将数据库文件存储在用户文档或应用数据目录
const dbPath = Env.path("documents") + "/my-app-data.db"; 
// 或者 Env.path("appdata") + "/YourAppName/my-app-data.db";

let storage = Storage.open(dbPath);

// 检查数据库是否是新创建的,如果是,则初始化
if (!storage.root) {
  console.log("Initializing new database...");
  storage.root = {
    version: 1,
    users: {},
    settings: {
      theme: "light"
    },
    // ... 其他初始数据结构
  };
  storage.commit(); // 确保初始结构被写入
}

let root = storage.root; // 获取根对象,后续操作都基于此对象

// 别忘了在应用退出前关闭数据库
document.on("beforeunload", function() {
  if (storage) {
    storage.close();
    storage = null;
    root = null;
  }
});

1.3 基本数据操作 (CRUD)

数据操作就像操作普通的 JavaScript 对象一样简单。

// 创建/更新 (Set)
root.settings.theme = "dark";
root.users["user123"] = { name: "Alice", email: "alice@example.com" };

// 读取 (Get)
let currentTheme = root.settings.theme; // "dark"
let user = root.users["user123"]; // { name: "Alice", ... }

// 删除 (Delete)
delete root.users["user123"];

// **重要**: 默认情况下,Storage 会自动提交更改。
// 但在某些复杂操作或需要确保写入时,可以手动调用 storage.commit();
storage.commit(); 

1.4 使用索引 (storage.createIndex)

当需要根据某个字段快速查找大量数据时,索引非常有用。例如,存储笔记并按创建日期或 ID 查找。

// 在初始化时创建索引
function initDb(storage) {
  storage.root = {
    version: 1,
    // 按日期索引笔记 (日期可能重复,所以 unique = false)
    notesByDate: storage.createIndex("date", false),
    // 按 ID 索引笔记 (ID 必须唯一,所以 unique = true)
    notesById: storage.createIndex("string", true)
  };
  return storage.root;
}

// ... 打开数据库,如果需要则调用 initDb ...
var root = storage.root || initDb(storage);

// 添加数据到索引
let note1 = { id: "uuid1", date: new Date(2023, 10, 1), text: "Note 1" };
let note2 = { id: "uuid2", date: new Date(2023, 10, 5), text: "Note 2" };

root.notesByDate.set(note1.date, note1);
root.notesById.set(note1.id, note1);
root.notesByDate.set(note2.date, note2);
root.notesById.set(note2.id, note2);

storage.commit();

// 通过索引查询数据
let noteFromId = root.notesById.get("uuid1"); // 获取 ID 为 uuid1 的笔记

// 遍历按日期排序的笔记 (Index 对象本身是可迭代的)
console.log("Notes by date:");
for (let note of root.notesByDate) {
  console.log(` - ${note.date.toLocaleDateString()}: ${note.text}`);
}

// 删除索引中的数据
// 对于非唯一索引,删除时需要提供键和值
root.notesByDate.delete(note1.date, note1); 
// 对于唯一索引,只需要提供键
root.notesById.delete(note1.id);

storage.commit();

1.5 存储自定义类的对象

Storage 可以存储自定义类的实例,并在从数据库读取时恢复其原型链,这意味着对象的方法可以被正确调用。

import * as Sciter from "@sciter"; // 用于生成 UUID

export class Note {
  constructor(text, date = undefined, id = undefined) {
    this.id = id || Sciter.uuid(); // 生成唯一 ID
    this.date = date || new Date();
    this.text = text;

    // 添加到 Storage (假设 storage 和 root 已定义)
    let root = storage.root;
    root.notesByDate.set(this.date, this);
    root.notesById.set(this.id, this);
    storage.commit();

    console.log(`Note created: ${this.id}`);
    // 可以触发事件通知 UI 更新
    document.post(new Event("new-note", { bubbles: true, data: this }));
  }

  // 实例方法
  updateText(newText) {
    this.text = newText;
    // 更新存储 (因为对象是引用,直接修改属性即可,但需要 commit)
    // 注意:如果索引键 (date, id) 改变,需要先 delete 再 set
    storage.commit();
    console.log(`Note updated: ${this.id}`);
  }

  // 实例方法
  delete() {
    let root = storage.root;
    root.notesByDate.delete(this.date, this); 
    root.notesById.delete(this.id);
    storage.commit();
    console.log(`Note deleted: ${this.id}`);
    // 可以触发事件通知 UI 更新
    document.post(new Event("delete-note", { bubbles: true, data: this }));
  }

  // 静态方法:通过 ID 获取 Note 实例
  static getById(id) {
    // Storage 会自动恢复 Note 的原型
    return storage.root.notesById.get(id); 
  }

  // 静态方法:获取所有笔记 (按日期排序)
  static all() {
    return root.notesByDate; // 返回 Index 对象,可直接迭代
  }
}

// **重要**: 必须注册类,Storage 才能正确地序列化和反序列化
storage.registerClass(Note);

// --- 使用 ---
let newNote = new Note("My first persistent note!");

// ... 稍后或应用重启后 ...
let retrievedNote = Note.getById(newNote.id);
if (retrievedNote) {
  console.log("Retrieved note text:", retrievedNote.text);
  retrievedNote.updateText("Updated text!");
  // retrievedNote.delete(); 
}

console.log("All notes:");
for(let note of Note.all()) {
  console.log(` - ID: ${note.id}, Text: ${note.text}`);
}

2. Sciter.js SQLite 集成 (sciter-sqlite)

对于需要更复杂的关系数据模型、强大的查询能力(如 JOIN、GROUP BY)和事务支持的场景,Sciter.js 提供了与 SQLite 的集成。SQLite 是一个轻量级的、基于文件的关系型数据库。

2.1 基本概念

  • 关系型数据库: 数据存储在结构化的表中,表之间可以建立关系。
  • SQL: 使用结构化查询语言 (SQL) 来操作数据。
  • Native Library: SQLite 功能是通过加载一个本地库 (sciter-sqlite.dll 或类似文件) 来实现的。

2.2 设置和加载

你需要确保 sciter-sqlite 库文件位于 Sciter 引擎可以找到的地方(通常与主程序或 Sciter DLL 在同一目录,或在 PATH 环境变量中)。然后使用 loadLibrary 加载它。

import { loadLibrary } from "@sciter";

// 尝试加载 SQLite 库
if (!globalThis.SQLite) { // 避免重复加载
  try {
    globalThis.SQLite = loadLibrary("sciter-sqlite");
    if (!globalThis.SQLite) {
      throw new Error("Failed to load sciter-sqlite library. Make sure it's accessible.");
    }
    console.log("SQLite library loaded successfully.");
  } catch (e) {
    console.error(e.message);
    // 在 UI 中提示用户错误,或禁用数据库功能
    document.body.innerText = "Error loading SQLite library!"; 
  }
}

2.3 打开和关闭数据库

使用 SQLite.open() 打开数据库。你可以指定一个文件路径,或者使用 :memory: 创建一个临时的内存数据库。

if (globalThis.SQLite) { // 确保库已加载
  let db;
  try {
    // 打开/创建文件数据库
    const dbFilePath = Env.path("documents") + "/my-sqlite-app.db";
    db = SQLite.open(dbFilePath); 
    console.log(`Database opened: ${dbFilePath}`);

    // 或者,打开内存数据库 (应用关闭后数据丢失)
    // db = SQLite.open(":memory:");
    // console.log("In-memory database opened.");

    // ... 执行数据库操作 ...

  } catch (e) {
    console.error("Database operation failed:", e);
  } finally {
    // **重要**: 完成操作后务必关闭数据库连接
    if (db) {
      db.close();
      console.log("Database closed.");
    }
  }
}

2.4 执行 SQL 语句 (db.exec)

db.exec() 是执行 SQL 语句的核心方法。

  • 无参数查询: 直接传入 SQL 字符串。
  • 带参数查询: 传入 SQL 字符串(使用 ? 作为占位符)和一个包含参数值的数组。这是推荐的方式,可以防止 SQL 注入。
  • 返回值:
    • 对于 SELECT 查询,成功时返回一个 Recordset 对象。
    • 对于 INSERT, UPDATE, DELETE 等操作,返回一个数组 [resultCode, rowsAffected]resultCode 是 SQLite 的结果代码 (如 SQLite.OK),rowsAffected 是受影响的行数。
    • 对于 CREATE TABLE 等 DDL 语句,通常返回 [SQLite.OK, 0]
    • 如果发生错误,会抛出异常。
if (db) { // 确保 db 已打开
  try {
    // 创建表 (DDL)
    db.exec(`
      CREATE TABLE IF NOT EXISTS stocks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        date TEXT,
        trans TEXT,
        symbol TEXT UNIQUE,
        qty REAL,
        price REAL
      )
    `);
    console.log("Table 'stocks' created or already exists.");

    // 插入数据 (DML) - 字符串方式 (不推荐)
    // db.exec("INSERT INTO stocks (date, trans, symbol, qty, price) VALUES ('2006-01-05','BUY','RHAT',100,35.14)");

    // 插入数据 - 使用参数绑定 (推荐)
    let [result, rows] = db.exec(
      "INSERT OR IGNORE INTO stocks (date, trans, symbol, qty, price) VALUES (?, ?, ?, ?, ?)",
      ["2006-04-05", "BUY", "MSFT", 1000, 72.00]
    );
    if (result === SQLite.OK) {
      console.log(`Inserted ${rows} row(s) for MSFT.`);
    } else {
       console.warn(`Failed to insert MSFT, result code: ${result}`);
    }
    
    [result, rows] = db.exec(
      "INSERT OR IGNORE INTO stocks (date, trans, symbol, qty, price) VALUES (?, ?, ?, ?, ?)",
      ["2006-04-06", "SELL", "IBM", 500, 53.00]
    );
     if (result === SQLite.OK) {
      console.log(`Inserted ${rows} row(s) for IBM.`);
    } else {
       console.warn(`Failed to insert IBM, result code: ${result}`);
    }

    // 更新数据
    [result, rows] = db.exec("UPDATE stocks SET qty = ? WHERE symbol = ?", [1500, "MSFT"]);
    console.log(`Updated ${rows} row(s) for MSFT.`);

    // 查询数据 (SELECT)
    console.log("\nStocks ordered by price:");
    let rs = db.exec("SELECT symbol, qty, price FROM stocks ORDER BY price DESC");

    // 检查返回的是否是 Recordset
    if (Asset.instanceOf(rs, "Recordset")) {
      // 处理 Recordset (见下一节)
      showRecordset(rs);
    } else {
      console.error("Expected a Recordset, but got:", rs);
    }

    // 删除数据
    // [result, rows] = db.exec("DELETE FROM stocks WHERE symbol = ?", ["IBM"]);
    // console.log(`Deleted ${rows} row(s) for IBM.`);

  } catch (e) {
    console.error("SQL execution error:", e);
  }
}

2.5 处理查询结果 (Recordset)

Recordset 对象代表 SELECT 查询返回的结果集。

  • 迭代: 使用 rs.next() 移动到下一行,当没有更多行时返回 false。通常与 do...whilewhile 循环结合使用。
  • 访问列数据:
    • 通过索引: rs[0], rs[1] ...
    • 通过列名: rs["symbol"], rs["price"] (注意:Sciter 的 SQLite 包装器可能不支持直接通过列名访问,示例中使用的是索引。请以实际测试为准或查阅最新文档。如果不支持,你需要知道列的顺序)。
  • 获取列信息:
    • rs.length: 列的数量。
    • rs.name(index): 获取指定索引列的名称。
    • rs.name(index, "database"), rs.name(index, "table"), rs.name(index, "field"): 获取更详细的列来源信息。
function showRecordset(rs) {
  if (!rs || !Asset.instanceOf(rs, "Recordset") || !rs.valid) {
      console.log("No records found or invalid recordset.");
      return;
  }

  // 1. 获取表头
  let headers = [];
  for (let i = 0; i < rs.length; ++i) {
    headers.push(rs.name(i));
  }
  console.log("| " + headers.join(" | ") + " |");
  console.log("|-" + headers.map(h => "-".repeat(h.length)).join("-|-") + "-|");

  // 2. 迭代数据行
  let rowCount = 0;
  do {
    let rowData = [];
    for (let i = 0; i < rs.length; ++i) {
      // 访问数据,注意 rs[i] 返回的是原始值
      rowData.push(rs[i]); 
    }
    console.log("| " + rowData.join(" | ") + " |");
    rowCount++;
  } while (rs.next()); // 移动到下一行

  console.log(`\nTotal rows: ${rowCount}`);
  
  // Recordset 使用完毕后会自动释放资源,无需手动 close rs
}

// 在 db.exec("SELECT ...") 之后调用
// if (Asset.instanceOf(rs, "Recordset")) {
//   showRecordset(rs);
// }

2.6 SQLite 语法差异

Sciter 集成的 SQLite 通常遵循标准的 SQLite 语法。主要的“差异”在于你如何通过 Sciter 的 JavaScript API (db.exec, Recordset) 与之交互,而不是 SQL 语言本身。你需要熟悉 db.exec 的参数和返回值,以及如何处理 Recordset

3. 如何选择:Storage vs SQLite?

  • 使用 Storage (@storage) 当:

    • 数据结构相对简单,主要是键值对或嵌套对象/数组。
    • 主要需求是快速存储和检索整个对象。
    • 不需要复杂的跨对象查询、连接或聚合。
    • 希望使用更接近 JavaScript 原生对象的操作方式。
    • 性能要求不高,或者数据量不大时,索引能满足查询需求。
  • 使用 SQLite (sciter-sqlite) 当:

    • 数据是关系型的,需要在多个表之间建立联系。
    • 需要执行复杂的 SQL 查询(JOIN, GROUP BY, WHERE 子句等)。
    • 需要 ACID 事务保证数据一致性。
    • 数据量较大,需要关系型数据库的查询优化能力。
    • 团队熟悉 SQL。

在某些应用中,甚至可以两者结合使用:用 Storage 存储应用配置和简单的状态,用 SQLite 存储核心的业务数据。

4. 最佳实践

  1. 错误处理: 始终将数据库操作(打开、执行、关闭)放在 try...catch...finally 块中,以捕获潜在错误并确保资源(如数据库连接)被正确释放。
  2. 数据库位置: 使用 Env.path("documents")Env.path("appdata") 将数据库文件存储在用户特定的、可写的位置,避免权限问题。
  3. 关闭连接: 确保在应用程序退出时 (document.on("beforeunload")) 或不再需要数据库时调用 storage.close()db.close()
  4. 参数化查询 (SQLite): 绝对优先使用 db.exec(sql, params) 的方式执行 SQL,以防止 SQL 注入攻击。
  5. 索引 (Storage & SQLite): 合理使用索引 (storage.createIndex 或 SQLite 的 CREATE INDEX) 来优化查询性能,但也要注意索引会增加写操作的开销。
  6. 数据验证: 在将数据写入数据库之前,进行必要的验证,确保数据类型和格式正确。
  7. 事务 (SQLite): 对于需要原子性的一系列操作(要么全部成功,要么全部失败),使用 SQLite 的事务 (BEGIN TRANSACTION, COMMIT, ROLLBACK)。Sciter 的 db.exec 可以执行这些命令。
  8. 备份与迁移: 考虑数据库备份策略。如果未来可能更新数据库结构,需要设计数据迁移方案(例如,通过检查 storage.root.version 或 SQLite 的 PRAGMA user_version)。