TypeScript 与 IndexedDB

TypeScript 与 IndexedDB

本文介绍 TypeScript 与 IndexedDB 的相关内容。

我们将通过实际示例来讲解 TypeScript 与 IndexedDB。

YouTube Video

TypeScript 与 IndexedDB

IndexedDB 是一种低级别的 NoSQL 存储,可以在浏览器中持久化结构化数据。通过 TypeScript,可以用类型安全的方式表示数据结构,降低错误率并提升可维护性。

基本术语和工作流程

IndexedDB 是浏览器内部的一个小型数据库。它通过具有名称和版本的数据库、对象存储区、事务、索引和游标等机制来管理数据。数据库具有版本,当版本升级时,会调用 onupgradeneeded 来更新结构,例如创建或修改表。

打开 IndexedDB(基本模式)

首先,展示一个使用 IndexedDB 打开数据库并在 onupgradeneeded 时根据需要创建对象存储的示例。

 1// Open an IndexedDB database and create an object store if needed.
 2// This code shows the classic callback-based IndexedDB API wrapped into a Promise.
 3function openDatabase(dbName: string, version: number): Promise<IDBDatabase> {
 4  return new Promise((resolve, reject) => {
 5    const request = indexedDB.open(dbName, version);
 6
 7    request.onerror = () => {
 8      reject(request.error);
 9    };
10
11    request.onupgradeneeded = (event) => {
12      const db = request.result;
13      if (!db.objectStoreNames.contains('todos')) {
14        // Create object store with keyPath 'id'
15        db.createObjectStore('todos', { keyPath: 'id' });
16      }
17    };
18
19    request.onsuccess = () => {
20      resolve(request.result);
21    };
22  });
23}
24
25// Usage example:
26openDatabase('my-db', 1)
27  .then(db => {
28    console.log('DB opened', db.name, db.version);
29    db.close();
30  })
31  .catch(err => console.error('Failed to open DB', err));
  • 此代码打开数据库并记录操作是否成功或失败。
  • 如果需要,在 onupgradeneeded 中创建 todos 存储。

在 TypeScript 中定义类型(模型)

接下来,我们用 TypeScript 定义数据类型。这样可以保证后续 CRUD 操作的类型安全。

1// Define the TypeScript interface for a Todo item.
2// This helps with type safety in the database operations.
3interface Todo {
4  id: string;          // primary key
5  title: string;
6  completed: boolean;
7  createdAt: number;
8}
  • 此处定义了 Todo 类型。

基本 CRUD 功能实现示例

接下来,演示对对象存储进行增、查、改、删等基本操作。每个函数接收一个 IDBDatabase 并返回一个 Promise。

 1// CRUD utilities for the 'todos' object store.
 2// Each operation uses a transaction and returns a Promise for easier async/await usage.
 3
 4function addTodo(db: IDBDatabase, todo: Todo): Promise<void> {
 5  return new Promise((resolve, reject) => {
 6    const tx = db.transaction('todos', 'readwrite');
 7    const store = tx.objectStore('todos');
 8    const req = store.add(todo);
 9
10    req.onsuccess = () => {
11      console.log('Todo added', todo.id);
12    };
13    req.onerror = () => reject(req.error);
14
15    tx.oncomplete = () => resolve();
16    tx.onerror = () => reject(tx.error);
17  });
18}
  • 此函数向 IndexedDB 的 todos 存储区添加一个新的 Todo。返回 Promise 异步处理,操作完成时 resolve。
 1function getTodo(db: IDBDatabase, id: string): Promise<Todo | undefined> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readonly');
 4    const store = tx.objectStore('todos');
 5    const req = store.get(id);
 6
 7    req.onsuccess = () => resolve(req.result as Todo | undefined);
 8    req.onerror = () => reject(req.error);
 9  });
10}
  • 此函数检索指定 ID 的 Todo,若找到则返回该对象。若未找到匹配数据,则返回 undefined
 1function updateTodo(db: IDBDatabase, todo: Todo): Promise<void> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readwrite');
 4    const store = tx.objectStore('todos');
 5    const req = store.put(todo);
 6
 7    req.onsuccess = () => {
 8      console.log('Todo updated', todo.id);
 9    };
10    req.onerror = () => reject(req.error);
11
12    tx.oncomplete = () => resolve();
13    tx.onerror = () => reject(tx.error);
14  });
15}
  • 此函数更新现有的 Todo 数据。更新成功时,会记录更新后的 Todo 的 ID。
 1function deleteTodo(db: IDBDatabase, id: string): Promise<void> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readwrite');
 4    const store = tx.objectStore('todos');
 5    const req = store.delete(id);
 6
 7    req.onsuccess = () => {
 8      console.log('Todo deleted', id);
 9    };
10    req.onerror = () => reject(req.error);
11
12    tx.oncomplete = () => resolve();
13    tx.onerror = () => reject(tx.error);
14  });
15}
  • 此函数删除指定 ID 的 Todo。删除成功时会记录删除的 ID。

  • 这些函数会根据事务的完成或错误来处理(resolve)或拒绝(reject)Promise。通过 console.log 输出便于追踪执行过程。

索引和复合查询

利用 IndexedDB 的索引可高效查询特定字段。这里为 createdAt 字段创建索引,并提供范围查询示例。

 1// When opening DB, create an index for createdAt.
 2// Then demonstrate a range query using the index.
 3
 4function openDatabaseWithIndex(dbName: string, version: number): Promise<IDBDatabase> {
 5  return new Promise((resolve, reject) => {
 6    const request = indexedDB.open(dbName, version);
 7
 8    request.onupgradeneeded = () => {
 9      const db = request.result;
10      if (!db.objectStoreNames.contains('todos')) {
11        const store = db.createObjectStore('todos', { keyPath: 'id' });
12        // Create an index on createdAt for sorting/filtering
13        store.createIndex('by-createdAt', 'createdAt', { unique: false });
14      } else {
15        const store = request.transaction!.objectStore('todos');
16        if (!store.indexNames.contains('by-createdAt')) {
17          store.createIndex('by-createdAt', 'createdAt', { unique: false });
18        }
19      }
20    };
21
22    request.onerror = () => reject(request.error);
23    request.onsuccess = () => resolve(request.result);
24  });
25}
  • 此函数打开数据库并对 createdAt 字段创建或验证 by-createdAt 索引。这样可高效按创建时间进行查找和排序。
 1async function getTodosCreatedAfter(db: IDBDatabase, timestamp: number): Promise<Todo[]> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readonly');
 4    const store = tx.objectStore('todos');
 5    const index = store.index('by-createdAt');
 6    const range = IDBKeyRange.lowerBound(timestamp, true); // exclusive
 7    const req = index.openCursor(range);
 8
 9    const results: Todo[] = [];
10    req.onsuccess = (event) => {
11      const cursor = (event.target as IDBRequest).result as IDBCursorWithValue | null;
12      if (cursor) {
13        results.push(cursor.value as Todo);
14        cursor.continue();
15      } else {
16        resolve(results);
17      }
18    };
19    req.onerror = () => reject(req.error);
20  });
21}
  • 此函数仅检索在指定时间戳之后创建的 Todo。使用索引可高效地按创建时间顺序扫描数据。

  • 在本示例中,在数据库升级期间创建了 by-createdAt 索引,通过游标列举在指定时间之后创建的 Todo 项。

基于 Promise 的轻量封装器

底层的IndexedDB API编写起来很复杂,重复的类似操作会导致冗余和漏洞。因此,通过准备一个通用的TypeScript包装类来抽象这些操作,我们可以提升代码的简洁性和可维护性。下面给出专注于基础功能的实现。

1// A minimal TypeScript wrapper around IndexedDB to simplify common operations.
2// This class is generic over the store's value type and assumes 'keyPath' is 'id'.
3
4class IDBWrapper<T extends { id: string }> {
5  private dbPromise: Promise<IDBDatabase>;
6
7  constructor(private dbName: string, private version: number, private storeName: string) {
8    this.dbPromise = this.open();
9  }
  • 该类封装了 IndexedDB 操作,并提供类型安全的 CRUD 方法。假定对象存储的主键为 id
 1  private open(): Promise<IDBDatabase> {
 2    return new Promise((resolve, reject) => {
 3      const req = indexedDB.open(this.dbName, this.version);
 4      req.onerror = () => reject(req.error);
 5      req.onupgradeneeded = () => {
 6        const db = req.result;
 7        if (!db.objectStoreNames.contains(this.storeName)) {
 8          db.createObjectStore(this.storeName, { keyPath: 'id' });
 9        }
10      };
11      req.onsuccess = () => resolve(req.result);
12    });
13  }
  • 会在数据库打开时按需新建对象存储。存储初始化通过升级事件完成。
 1  async add(item: T): Promise<void> {
 2    const db = await this.dbPromise;
 3    await new Promise<void>((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.add(item);
 7      req.onsuccess = () => {
 8        console.log('added', item.id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • 向 IndexedDB 存储中添加数据。添加后在控制台输出该数据的 ID。
 1  async get(id: string): Promise<T | undefined> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readonly');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.get(id);
 7      req.onsuccess = () => resolve(req.result as T | undefined);
 8      req.onerror = () => reject(req.error);
 9    });
10  }
  • 获取指定 ID 的数据。数据不存在则返回 undefined
 1  async put(item: T): Promise<void> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.put(item);
 7      req.onsuccess = () => {
 8        console.log('put', item.id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • 更新已有数据或添加新数据。处理后记录被更新的数据 ID。
 1  async delete(id: string): Promise<void> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.delete(id);
 7      req.onsuccess = () => {
 8        console.log('deleted', id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • 删除指定 ID 的数据。删除成功后输出被删除的 ID。
 1  async getAll(): Promise<T[]> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readonly');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.getAll();
 7      req.onsuccess = () => resolve(req.result as T[]);
 8      req.onerror = () => reject(req.error);
 9    });
10  }
11}
  • 获取存储中的所有数据。返回值为类型为 T 的数组。
 1// Example usage with Todo type:
 2interface Todo {
 3  id: string;
 4  title: string;
 5  completed: boolean;
 6  createdAt: number;
 7}
 8
 9const todoStore = new IDBWrapper<Todo>('my-db', 1, 'todos');
10
11(async () => {
12  const newTodo: Todo = { id: '1', title: 'Learn IndexedDB', completed: false, createdAt: Date.now() };
13  await todoStore.add(newTodo);
14  const fetched = await todoStore.get('1');
15  console.log('fetched', fetched);
16  newTodo.completed = true;
17  await todoStore.put(newTodo);
18  const all = await todoStore.getAll();
19  console.log('all todos', all);
20  await todoStore.delete('1');
21})();
  • 下述代码为 IDBWrapper 类的实际用法示例。这展示了添加、检索、更新、列出和删除 Todo 数据的流程。

  • 该封装器可简便实现基本的 CRUD 操作。在实际环境中,还需要处理错误处理和结构管理(索引)。

结构迁移(版本升级)

要更改数据库架构,请增加indexedDB.open的第二个参数(版本号),并在onupgradeneeded中进行更新。你需要设计成在现有事务完成后再进行,避免破坏性的更改。

 1// Example of handling upgrade to version 2: add an index and perhaps migrate data.
 2// onupgradeneeded receives an event where oldVersion and newVersion are accessible.
 3
 4function upgradeToV2(dbName: string): Promise<IDBDatabase> {
 5  return new Promise((resolve, reject) => {
 6    const req = indexedDB.open(dbName, 2);
 7    req.onupgradeneeded = (ev) => {
 8      const db = req.result;
 9      const oldVersion = (ev as IDBVersionChangeEvent).oldVersion;
10      console.log('Upgrading DB from', oldVersion, 'to', db.version);
11      let store: IDBObjectStore;
12      if (!db.objectStoreNames.contains('todos')) {
13        store = db.createObjectStore('todos', { keyPath: 'id' });
14      } else {
15        store = req.transaction!.objectStore('todos');
16      }
17      // Add index if not present
18      if (!store.indexNames.contains('by-completed')) {
19        store.createIndex('by-completed', 'completed', { unique: false });
20      }
21
22      // Optional: data migration logic if necessary can go here,
23      // but heavy migrations often should be done lazily on read.
24    };
25    req.onsuccess = () => resolve(req.result);
26    req.onerror = () => reject(req.error);
27  });
28}
  • onupgradeneeded 内的重操作可能导致 UI 阻塞,应尽量精简;如有必要,考虑延迟迁移(如应用启动时分阶段处理)。

事务注意事项(生命周期与错误)

事务会在创建它们的脚本执行结束前自动提交。在事务中使用 await 时,事务可能被提前提交;在同一事务里进行多异步操作需要特别留意。

 1// Bad pattern: awaiting outside transaction callbacks can cause tx to auto-commit.
 2// Good pattern is to chain requests and resolve on tx.oncomplete as shown earlier.
 3
 4// Example: Do multiple operations inside single tx, avoid awaiting inside.
 5function multiOperation(db: IDBDatabase, items: Todo[]): Promise<void> {
 6  return new Promise((resolve, reject) => {
 7    const tx = db.transaction('todos', 'readwrite');
 8    const store = tx.objectStore('todos');
 9
10    for (const item of items) {
11      const req = store.put(item);
12      req.onerror = () => console.error('put error', req.error);
13      // Do NOT await here; just schedule requests synchronously.
14    }
15
16    tx.oncomplete = () => {
17      console.log('All operations in transaction completed');
18      resolve();
19    };
20    tx.onerror = () => reject(tx.error);
21  });
22}
  • 注意事务生命周期;必要时使用单独事务,或将操作同步安排在同一个事务内。

游标应用与分页

通过使用游标,你可以顺序处理大规模数据,或者无需使用偏移量就实现简单的分页。

 1// Example: fetch first N items using a cursor (ascending by key).
 2function fetchFirstN(db: IDBDatabase, n: number): Promise<Todo[]> {
 3  return new Promise((resolve, reject) => {
 4    const tx = db.transaction('todos', 'readonly');
 5    const store = tx.objectStore('todos');
 6    const req = store.openCursor();
 7    const out: Todo[] = [];
 8    req.onsuccess = (ev) => {
 9      const cursor = (ev.target as IDBRequest).result as IDBCursorWithValue | null;
10      if (cursor && out.length < n) {
11        out.push(cursor.value as Todo);
12        cursor.continue();
13      } else {
14        resolve(out);
15      }
16    };
17    req.onerror = () => reject(req.error);
18  });
19}
  • 通过游标逐项读取可减少内存占用。实现分页时通常需记录最后读取的主键。

错误处理与降级方案

1// Feature detection
2if (!('indexedDB' in window)) {
3  console.warn('IndexedDB is not supported. Falling back to localStorage.');
4  // implement fallback logic...
5}
  • 由于不同浏览器的实现差异或用户的隐私设置(如无痕浏览),IndexedDB 可能无法使用。因此,需要检查 indexedDB 是否存在,如果不存在,则提供例如 localStorage 这样的备选方案。

性能与最佳实践

IndexedDB 快速且强大,但其性能会因设计和数据处理方式不同而有很大差异。根据具体使用场景,可以通过以下方式进行优化:。

  • 根据实际使用情况设计对象存储区。例如,如果读操作较多,可以提供索引;如果写操作较多,则应保持键(key)的设计简单。
  • 图片、音频等大型二进制数据应以 Blob 形式存储,必要时可通过 File API 或 service worker 进行管理。必要时也可以考虑进行压缩。
  • 尽量缩短事务时间,繁重的处理应在事务外进行,以减少锁定时间。
  • 索引能加快查询速度,但会降低插入和更新速度,因此只创建真正需要的索引。
  • 如果处理大量小数据,使用 getAll() 一次性检索可能会耗尽内存。可以通过游标分批处理方式来降低内存使用。

安全性与隐私

IndexedDB 数据根据同源策略按域名和协议隔离。须假设用户删除浏览器数据或使用隐私模式时数据可能会丢失,从而进行设计。

总结与推荐设计模式

要有效地将 TypeScript 与 IndexedDB 结合使用,需准备类型和异步处理,注意版本管理和事务设计,并对常用处理进行封装以提高可维护性。

  • 用 TypeScript 定义类型并用 Promise/async/await 封装 IndexedDB 操作能提升安全性与代码可读性。
  • 结构变更应通过版本管理和 onupgradeneeded 实现,重操作尽可能延后处理。
  • 事务应足够短小,避免在同一事务内进行繁重异步处理。
  • 通过创建封装类,可以减少错误处理、日志记录和类型定义等重复性的通用处理。

您可以在我们的YouTube频道上使用Visual Studio Code跟随上述文章进行学习。 请也查看我们的YouTube频道。

YouTube Video