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 中定義型別(Models)

接下來,我們將用 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 函式實作範例

接著將展示從物件存儲新增、查詢、修改、刪除等基本 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。

  • 這些函式會根據交易是否完成或發生錯誤來決定 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}
  • 須注意交易的生命週期;必要時拆開交易,或於單一交易中同步執行操作。

游標應用與分頁功能

透過游標(cursor),可以依序處理大量資料,或者不靠 offset 實現簡易分頁。

 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}
  • 透過游標逐一抓取資料,可降低記憶體用量。實作分頁時,通常會記住最後讀取的 key。

錯誤處理與備援機制

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 既快速又強大,但其效能會依設計方式與資料處理方式而有很大差異。根據使用情境,可以用下列方式進行最佳化:。

  • 根據實際用途設計物件存儲區。例如,讀取操作多時可增加索引;寫入操作多時則應簡化鍵值設計。
  • 大型二進位資料如圖片和音訊應以 Blob 形式儲存,或根據需要使用 File API 或服務工作者來管理。如有需要,也可以考慮壓縮資料。
  • 盡量縮短交易時間,將大量處理移到交易外進行,以減少鎖定時間。
  • 索引可以加快查詢速度,但會降低插入與更新的速度,因此只建立真正必要的索引。
  • 當需要處理大量小型資料時,同時用 getAll() 取出可能會耗盡記憶體。可以利用游標分批處理資料,來減少記憶體用量。

安全與隱私

IndexedDB 資料遵循同源政策,每一網域與協定皆獨立。設計時應預期使用者刪除瀏覽器資料或使用無痕模式時,資料可能會遺失。

總結與推薦設計模式

若要在 TypeScript 中有效使用 IndexedDB,必須準備好型別與非同步處理機制,注意版本管理與交易設計,並且將共通邏輯包裝起來以提升可維護性。

  • 在 TypeScript 定義型別,並利用 Promise/async/await 封裝 IndexedDB 操作,可提升安全性與程式可讀性。
  • 結構變更應配合版本管理與 onupgradeneeded,且盡量避免進行大量處理,需延遲執行時可分批處理。
  • 設計時應讓交易保持簡短,並避免於同一交易中進行大量非同步處理。
  • 透過建立包裝類別,可以減少錯誤處理、紀錄與型別定義等重複的共通流程。

您可以在我們的 YouTube 頻道上使用 Visual Studio Code 來跟隨上述文章一起學習。 請也查看我們的 YouTube 頻道。

YouTube Video