在这里插入图片描述

欢迎大家加入开源鸿蒙跨平台开发者社区,一起共建开源鸿蒙跨平台生态。

本文对应项目模块:cordova/src/main/resources/rawfile/www/js/db.js 中底部那一段「通用 CRUD 和查询方法」,以及它们在上层业务中的用法。


1. 为什么要单独写一篇 CRUD 封装?

在上一篇模块 02 里,我们主要从「数据库结构」的角度,介绍了 FinanceDatabase 是如何用 6 张表承载整个财务管家的数据世界的。但有了表结构,还不够,你还需要一整套 好用、稳定、可复用的 CRUD 封装,否则每次操作数据都会写一大堆重复的 IndexedDB 事务代码。

这个模块的核心目标只有一个:

把所有对 IndexedDB 的读写,统一收敛到少数几个通用方法里,让上层业务代码几乎不用关心事务、store、index 等底层细节。

从长期维护来看,这种封装方式能显著减少 bug,也方便你在将来替换存储实现(比如迁到云端或加一层同步)。


2. 通用查询:getAll 与 getByIndex

先看两个最常用的查询封装,它们几乎支撑了整个应用的读操作:

/**
 * 通用方法:获取所有记录
 */
async getAll(storeName) {
  return new Promise((resolve, reject) => {
    const transaction = this.db.transaction([storeName], 'readonly');
    const store = transaction.objectStore(storeName);
    const request = store.getAll();

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);
  });
}

/**
 * 通用方法:按索引查询
 */
async getByIndex(storeName, indexName, value) {
  return new Promise((resolve, reject) => {
    const transaction = this.db.transaction([storeName], 'readonly');
    const store = transaction.objectStore(storeName);
    const index = store.index(indexName);
    const request = index.getAll(value);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);
  });
}

2.1 getAll:最简单的批量读取

getAll 的设计非常直接:

  • 入参只有一个 storeName,表示要从哪张“表”(objectStore)里取数据;
  • 内部开启一个 readonly 事务,确保不会修改数据;
  • 调用 store.getAll() 一次性取出所有记录;
  • 用 Promise 包装,让调用方可以用 await 写法,简化异常处理逻辑。

在业务代码中,你会频繁看到类似用法:

async getAccounts() {
  return this.getAll('accounts');
}

async getAllTransactions() {
  return this.getAll('transactions');
}

也就是说,上层根本不需要知道 IndexedDB 的存在,只要调用 getAccounts() 就能拿到一个数组,像操作普通 JS 数据一样继续处理即可。

2.2 getByIndex:按索引查询的统一入口

getByIndex 则是对 IndexedDB 索引查询的统一封装:

  • storeName 指定表;
  • indexName 指定使用哪个索引(例如 typemonthaccountId);
  • value 是索引匹配值;
  • 内部通过 store.index(indexName).getAll(value) 取出所有符合条件的记录。

典型用法包括:

// 按账户获取交易
async getTransactionsByAccount(accountId) {
  return this.getByIndex('transactions', 'accountId', accountId);
}

// 获取某个月的预算
async getMonthBudget(year, month) {
  const monthKey = `${year}-${String(month).padStart(2, '0')}`;
  const budgets = await this.getByIndex('budgets', 'month', monthKey);
  return budgets.length > 0 ? budgets[0] : null;
}

// 按类型获取分类(收入/支出)
async getCategoriesByType(type) {
  return this.getByIndex('categories', 'type', type);
}

可以看到,这三个高层方法都没有直接操作事务和索引,所有“底层活”都交给了 getByIndex

  • 你只需要记住每张表有哪些索引,就能很方便地在业务代码里做各种组合查询;
  • 将来如果某个索引实现需要调整(例如换成范围查询),只需要修改 getByIndex 一处,不必全项目搜一遍。

3. 通用写入:add、update、delete

再看写操作的三个通用方法:

/**
 * 通用方法:添加记录
 */
async add(storeName, data) {
  return new Promise((resolve, reject) => {
    const transaction = this.db.transaction([storeName], 'readwrite');
    const store = transaction.objectStore(storeName);
    const request = store.add(data);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(data);
  });
}

/**
 * 通用方法:更新记录
 */
async update(storeName, data) {
  return new Promise((resolve, reject) => {
    const transaction = this.db.transaction([storeName], 'readwrite');
    const store = transaction.objectStore(storeName);
    const request = store.put(data);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(data);
  });
}

/**
 * 通用方法:删除记录
 */
async delete(storeName, key) {
  return new Promise((resolve, reject) => {
    const transaction = this.db.transaction([storeName], 'readwrite');
    const store = transaction.objectStore(storeName);
    const request = store.delete(key);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve();
  });
}

3.1 add:插入新记录

add 用来插入新数据,它默认要求传入的 data 已经包含主键字段(例如 id),这也是为什么在高层方法中经常会看到:

async addAccount(account) {
  account.id = this.generateId();
  account.createdAt = new Date().toISOString();
  return this.add('accounts', account);
}

async addTransaction(transaction) {
  transaction.id = this.generateId();
  transaction.createdAt = new Date().toISOString();
  return this.add('transactions', transaction);
}

这里有一个设计上的小细节:

  • 生成主键 ID 的逻辑统一放在数据库层的 generateId() 里,而不是散落在各个页面;
  • 这保证了所有表的主键风格一致,也方便后面做数据导出 / 导入时进行比对。

3.2 update:幂等更新

update 使用的是 store.put(data),而不是 add,意味着:

  • 如果主键已存在,则更新记录;
  • 如果主键不存在,则当作新记录插入。

这样做的一个直接收益是:在做数据导入时,你可以直接用 update 把备份文件里的每条记录写回数据库,而不用在意它是“新增”还是“覆盖”,逻辑十分干净。

3.3 delete:统一删除入口

delete 封装了从指定 store 删除某个 key 的逻辑:

async deleteAccount(accountId) {
  return this.delete('accounts', accountId);
}

async deleteTransaction(transactionId) {
  return this.delete('transactions', transactionId);
}

async deleteGoal(goalId) {
  return this.delete('goals', goalId);
}

对上层来说,删除操作只需要知道主键 ID 即可,无需每次写一遍事务和对象仓库的样板代码。


4. ID 生成与清库:辅助能力

除了 CRUD,还有两个辅助能力值得一提:

/**
 * 生成唯一ID
 */
generateId() {
  return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

/**
 * 清空数据库
 */
async clear() {
  const stores = ['accounts', 'transactions', 'categories', 'budgets', 'goals', 'tags'];
  for (const storeName of stores) {
    await new Promise((resolve, reject) => {
      const transaction = this.db.transaction([storeName], 'readwrite');
      const store = transaction.objectStore(storeName);
      const request = store.clear();

      request.onerror = () => reject(request.error);
      request.onsuccess = () => resolve();
    });
  }
  console.log('[DB] Database cleared');
}
  • generateId 使用时间戳 + 随机串的组合,只要在单机环境中,基本能保证主键不冲突;
  • clear 则是一次性清空所有表的工具方法,在“清空所有数据”、“恢复出厂设置”等场景中非常有用,上层只需要调用 await financeDB.clear() 即可。

5. ArkTS 视角:为什么要把 CRUD 做成这样?

从 ArkTS 和整体架构的角度看,这套 CRUD 封装还有一个隐藏好处:

  • 你的 ArkTS 插件(比如 FileManagerPlugin)完全不用关心数据库结构;
  • 插件只负责“文件层”的导入导出,真正的数据落库逻辑仍然由 db.js 处理;
  • 这使得原生插件与业务数据表结构之间 弱耦合,未来即使调整 IndexedDB 的实现,也不需要修改 ArkTS 侧代码。

举个简单的链路例子:

  1. 用户在设置页点击“导出数据”;
  2. JS 调用 window.financeDB.exportData(),内部用 getAll 把 6 张表的数据全部导出;
  3. JS 再调用 cordova.exec('FileManager', 'exportData', [...]),把这份 JSON 发给 ArkTS;
  4. ArkTS 插件只负责把这块 JSON 写到用户选择的目录,不关心里面的字段结构;
  5. 将来导入时,ArkTS 插件从文件读回 JSON,交给 JS,JS 再调用 financeDB.importData(),内部用 update 写回各个 store。

在这个流程中,通用 CRUD 封装就像是一个「稳定的地基」,让原生层与 Web 层各司其职,又通过简单清晰的接口组合在一起。


6. 小结:这套 CRUD 封装的几个实践经验

综合来看,这一小段看似不起眼的 CRUD 封装,其实蕴含了不少可以复用到其他项目的经验:

  1. 用 Promise 包装 IndexedDB 事件,统一和 async/await 对齐,彻底告别回调地狱;
  2. 把所有 CRUD 操作收敛到少数几个通用方法,上层只通过表名和数据对象来调用,大幅减少样板代码;
  3. 高层方法按业务划分,例如 getAccountsgetMonthBudgetgetTransactionsByAccount 等,让页面逻辑只面对业务语义,而不是技术细节;
  4. 主键生成与时间戳统一在数据库层处理,避免各个页面重复造轮子,也避免出现风格不一致的问题;
  5. 导出/导入接口围绕这套 CRUD 搭建,使得备份与恢复逻辑既简单又可靠;
  6. 与 ArkTS 插件保持弱耦合,让原生层专注系统能力,Web 层专注业务与数据结构。

在后续的模块中,无论是交易列表、预算管理,还是报表分析、趋势分析,所有数据读写都会不断复用到这套 CRUD 和查询封装。可以说,只要你把这个模块吃透,整个项目的数据读写路径就已经掌握了七八成。",“EmptyFile”:false}]} -> any}

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐