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 頻道。