IndexedDB模式管理:动态ObjectStore的替代方案

IndexedDB模式管理:动态ObjectStore的替代方案

本文探讨了IndexedDB中动态添加ObjectStore的局限性,指出createObjectStore只能在数据库版本升级时调用。针对在IndexedDB中实现类似localStorage的多分区异步存储需求,文章推荐避免频繁修改数据库模式,而是通过在单个ObjectStore中利用数据内部属性或键前缀进行逻辑分区,从而实现高效且易于维护的数据管理,并提供详细代码示例。

IndexedDB中动态ObjectStore创建的挑战

indexeddb是一个强大的客户端存储解决方案,但其设计哲学与传统的文件系统或键值存储有所不同。在indexeddb中,idbdatabase.createobjectstore() 方法用于创建新的对象存储(object store),但此方法并非在任何时候都可以调用。它有一个严格的限制:只能在 idbopendbrequest 的 onupgradeneeded 回调函数中被调用。

onupgradeneeded 事件仅在以下两种情况下触发:

  1. 数据库首次创建时。
  2. 调用 indexedDB.open() 时,指定的新版本号大于当前数据库的版本号时(即进行版本升级)。

这意味着,如果你的数据库已经存在且版本号没有变化,你将无法在 onsuccess 回调或其他运行时上下文中动态地创建新的对象存储。尝试在 onsuccess 中调用 db.createObjectStore() 会导致运行时错误,因为此时数据库的事务模式不允许进行模式修改操作。

原始问题中尝试在 onsuccess 中检查 db.objectStoreNames.contains(storeKey) 并调用 createObjectStore 的做法,以及尝试通过“假装”升级版本(例如 db.bumpVersion(db.version + 1))来触发 onupgradeneeded,都是不可行的。IndexedDB 没有提供直接在 onsuccess 中触发版本升级的API。

为什么应避免频繁修改数据库模式

从数据库设计的角度来看,频繁地修改数据库模式(Schema)通常是不推荐的。数据库模式(例如,创建、删除或修改表/对象存储)应该是相对稳定的。onupgradeneeded 回调的目的是处理数据库版本升级时的模式迁移,例如添加新的对象存储、创建索引或删除旧的存储。将每次数据逻辑分区都映射为一个新的对象存储,会导致以下问题:

  • 版本管理复杂性: 每次添加新的逻辑分区都需要递增数据库版本号,并编写相应的 onupgradeneeded 逻辑。随着逻辑分区的增多,版本号会快速增长,onupgradeneeded 中的逻辑会变得异常复杂且难以维护。
  • 性能开销: 数据库版本升级通常是一个重量级操作,可能涉及数据迁移和重新索引,这会带来显著的性能开销,尤其是在启动时。
  • 不符合设计原则: IndexedDB 的对象存储更像关系型数据库中的“表”,它们用于存储特定类型的数据集合。将“分区”的概念提升到模式级别,而不是数据级别,违背了这一设计原则。

推荐方案:通过数据内部属性实现逻辑分区

为了实现类似 localStorage 的多分区异步存储,同时避免频繁修改IndexedDB模式,最佳实践是在一个或少数几个固定的对象存储中,通过数据内部的属性或键前缀来区分不同的逻辑分区。

例如,你可以创建一个名为 data 的单一对象存储。当需要存储属于不同“逻辑存储”的数据时,可以在存储的数据对象中添加一个 storeName 属性,或者在键本身添加一个前缀。

以下是一个基于此思想的 LocalStorageAsync 实现示例:

class LocalStorageAsync {   #database;   #logicalStoreName; // 用于区分逻辑分区的名称   static #DB_NAME = 'LocalStorageAsyncDB'; // 数据库名称   static #PHYSICAL_OBJECT_STORE = 'key_value_data'; // 唯一的物理对象存储名称    /**    * 构造函数,初始化IndexedDB连接    * @param {string} logicalStoreName 逻辑存储的名称,例如 'default', 'foo'    */   constructor(logicalStoreName = 'default') {     this.#logicalStoreName = logicalStoreName;     // 使用固定的数据库版本号,只有当数据库首次创建或需要结构性升级时才改变     const openRequest = indexedDB.open(LocalStorageAsync.#DB_NAME, 1);       this.#database = new Promise((resolve, reject) => {       openRequest.onupgradeneeded = (event) => {         const db = event.target.result;         // 仅在onupgradeneeded中创建物理对象存储         if (!db.objectStoreNames.contains(LocalStorageAsync.#PHYSICAL_OBJECT_STORE)) {           // 使用keyPath,这里我们将组合键作为主键           db.createObjectStore(LocalStorageAsync.#PHYSICAL_OBJECT_STORE, { keyPath: 'fullKey' });           // 如果需要按逻辑存储名称查询,可以添加索引           // db.createIndex('by_logicalStoreName', 'logicalStoreName', { unique: false });         }       };        openRequest.onsuccess = (event) => {         resolve(event.target.result);       };        openRequest.onerror = (event) => {         console.error("IndexedDB open error:", event.target.errorCode, event.target.error);         reject(new Error("Failed to open IndexedDB."));       };     });   }    /**    * 获取一个事务和对象存储实例    * @param {IDBTransactionMode} mode 事务模式 ('readonly' 或 'readwrite')    * @returns {Promise<IDBObjectStore>}    */   async #getTransactionStore(mode) {     const db = await this.#database;     const transaction = db.transaction(LocalStorageAsync.#PHYSICAL_OBJECT_STORE, mode);     transaction.onerror = (event) => {       console.error("Transaction error:", event.target.errorCode, event.target.error);     };     return transaction.objectStore(LocalStorageAsync.#PHYSICAL_OBJECT_STORE);   }    /**    * 生成用于存储的唯一键    * @param {string} key 用户提供的键    * @returns {string} 包含逻辑存储名称前缀的完整键    */   #generateFullKey(key) {     return `${this.#logicalStoreName}__${key}`;   }    /**    * 从指定的逻辑存储中获取值    * @param {string} key 要获取的键    * @returns {Promise<string|null>} 对应的值,如果不存在则为null    */   async getItem(key) {     const store = await this.#getTransactionStore('readonly');     const fullKey = this.#generateFullKey(key);     const request = store.get(fullKey);      return new Promise((resolve, reject) => {       request.onsuccess = (event) => {         const result = event.target.result;         resolve(result ? result.value : null);       };       request.onerror = (event) => {         console.error("Error getting item:", event.target.errorCode, event.target.error);         reject(event.target.error);       };     });   }    /**    * 将键值对存储到指定的逻辑存储中    * @param {string} key 要设置的键    * @param {string} value 要设置的值    * @returns {Promise<void>}    */   async setItem(key, value) {     const store = await this.#getTransactionStore('readwrite');     const fullKey = this.#generateFullKey(key);     // 存储一个包含fullKey、原始值和逻辑存储名称的对象     const dataToStore = {       fullKey: fullKey,       value: value,       logicalStoreName: this.#logicalStoreName // 存储逻辑存储名称,方便未来查询或索引     };     const request = store.put(dataToStore);      return new Promise((resolve, reject) => {       request.onsuccess = () => resolve();       request.onerror = (event) => {         console.error("Error setting item:", event.target.errorCode, event.target.error);         reject(event.target.error);       };     });   }    /**    * 从指定的逻辑存储中移除键值对    * @param {string} key 要移除的键    * @returns {Promise<void>}    */   async removeItem(key) {     const store = await this.#getTransactionStore('readwrite');     const fullKey = this.#generateFullKey(key);     const request = store.delete(fullKey);      return new Promise((resolve, reject) => {       request.onsuccess = () => resolve();       request.onerror = (event) => {         console.error("Error removing item:", event.target.errorCode, event.target.error);         reject(event.target.error);       };     });   }    /**    * 清空指定逻辑存储中的所有数据    * 注意:这会遍历并删除,而不是直接清空整个物理存储    * @returns {Promise<void>}    */   async clear() {     const store = await this.#getTransactionStore('readwrite');     const request = store.openCursor();     const keysToDelete = [];      return new Promise((resolve, reject) => {       request.onsuccess = (event) => {         const cursor = event.target.result;         if (cursor) {           // 检查是否属于当前逻辑存储           if (cursor.value.logicalStoreName === this.#logicalStoreName) {             keysToDelete.push(cursor.primaryKey);           }           cursor.continue();         } else {           // 游标遍历完毕,开始删除           if (keysToDelete.length === 0) {             resolve();             return;           }            let deleteCount = 0;           const deleteNext = () => {             if (deleteCount < keysToDelete.length) {               const deleteRequest = store.delete(keysToDelete[deleteCount]);               deleteRequest.onsuccess = () => {                 deleteCount++;                 deleteNext();               };               deleteRequest.onerror = (event) => {                 console.error("Error deleting item during clear:", event.target.errorCode, event.target.error);                 reject(event.target.error);               };             } else {               resolve();             }           };           deleteNext();         }       };       request.onerror = (event) => {         console.error("Error opening cursor for clear:", event.target.errorCode, event.target.error);         reject(event.target.error);       };     });   } }  // 使用示例 async function runExample() {   console.log("--- Initializing LocalStorageAsync instances ---");   const defaultStore = new LocalStorageAsync();   const fooStore = new LocalStorageAsync('foo');   const barStore = new LocalStorageAsync('bar');    console.log("n--- Setting items ---");   await defaultStore.setItem('user', 'Alice');   await defaultStore.setItem('theme', 'dark');   await fooStore.setItem('user', 'Bob');   await fooStore.setItem('settings', 'enabled');   await barStore.setItem('user', 'Charlie');    console.log("n--- Getting items ---");   console.log("Default store - user:", await defaultStore.getItem('user')); // Expected: Alice   console.log("Foo store - user:", await fooStore.getItem('user'));       // Expected: Bob   console.log("Bar store - user:", await barStore.getItem('user'));       // Expected: Charlie   console.log("Default store - theme:", await defaultStore.getItem('theme')); // Expected: dark   console.log("Foo store - settings:", await fooStore.getItem('settings')); // Expected: enabled   console.log("Default store - nonExistent:", await defaultStore.getItem('nonExistent')); // Expected: null    console.log("n--- Removing item from fooStore ---");   await fooStore.removeItem('user');   console.log("Foo store - user after removal:", await fooStore.getItem('user')); // Expected: null    console.log("n--- Clearing defaultStore ---");   await defaultStore.clear();   console.log("Default store - user after clear:", await defaultStore.getItem('user')); // Expected: null   console.log("Default store - theme after clear:", await defaultStore.getItem('theme')); // Expected: null   console.log("Foo store - settings (should remain):", await fooStore.getItem('settings')); // Expected: enabled }  runExample().catch(e => console.error("Example failed:", e));

注意事项与总结

  1. 单一物理对象存储: 上述示例使用了一个名为 key_value_data 的单一物理对象存储来存放所有数据。这是推荐的做法,因为它避免了频繁的模式修改。
  2. 键前缀(Key Prefixing): 通过在用户提供的键前加上逻辑存储名称(例如 default__myKey),可以确保不同逻辑存储中的同名键不会冲突。这是实现逻辑分区最直接有效的方式。
  3. 数据内部属性: 除了键前缀,你也可以在存储的数据对象中额外添加一个 logicalStoreName 属性。这样做的好处是,如果未来需要查询某个特定逻辑存储的所有数据(例如,清空某个逻辑存储),可以通过遍历游标并检查 logicalStoreName 属性来实现,或者为 logicalStoreName 属性创建索引以提高查询效率。
  4. 性能考虑: 对于非常大的数据集,如果频繁需要按 logicalStoreName 查询所有数据,创建索引 (db.createIndex(‘by_logicalStoreName’, ‘logicalStoreName’, { unique: false });) 会显著提高查询性能。
  5. 版本号管理: 数据库的版本号(在 indexedDB.open() 的第二个参数中指定)应该保持稳定,只在真正需要修改数据库模式(例如,添加新的物理对象存储或索引)时才递增。对于逻辑分区,无需更改版本号。

通过采用这种数据层面的分区策略,你可以灵活地管理多个逻辑存储,而无需承担 IndexedDB 模式频繁变更带来的复杂性和性能开销。这种方法更加符合 IndexedDB 的设计理念,并提供了更健壮、可维护的解决方案。

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享