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 関数の実装例

続いて、オブジェクトストアへの追加、取得、更新、削除の基本 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 の第2引数(version)を上げ、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 の応用とページネーション

カーソルを使うと、大規模データを順次処理したり、オフセットを使わないシンプルなページネーションを実装できます。

 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 は速くて強力ですが、設計とデータの扱い方次第でパフォーマンスが大きく変わります。ユースケースに応じて、次のような方法で最適化できます。

  • オブジェクトストアの設計は、実際の使い方に合わせます。例えば、読み取りが多いならインデックスを用意し、書き込みが多いならキー設計をシンプルにします。
  • 画像や音声など大きなバイナリデータは Blob で保存するか、必要に応じてファイルAPIやサービスワーカーを使って管理します。必要に応じて圧縮することも検討できます。
  • トランザクションはできるだけ短くし、ロック時間を短くするため、重い処理はトランザクションの外で行うようにします。
  • インデックスは検索を速くできる一方で、追加や更新が遅くなるので、本当に必要なものだけ作るようにします。
  • 多数の小さなデータを扱う場合、getAll() で一度に取り出すとメモリを圧迫する可能性があります。カーソルを使って分割して処理することでメモリ使用量を削減できます。

セキュリティとプライバシー

IndexedDB のデータは同一オリジンポリシーに従い、ドメインごと・プロトコルごとに隔離されます。ユーザーがブラウザデータを削除したりプライベートモードを使用しているとデータが失われる可能性があることを前提に設計するようにします。

まとめと推奨設計パターン

TypeScriptでIndexedDBを効果的に使うには、型と非同期処理を整え、バージョン管理とトランザクション設計を意識しつつ、共通処理をラップして保守性を高めることが重要です。

  • TypeScript で型を定義し、IndexedDB の操作は Promise/async/await にラップして扱うと安全性と読みやすさが向上します。
  • スキーマ変更はバージョン管理で onupgradeneeded を使い、重い処理は遅延させるようにします。
  • トランザクション設計は短く、同一トランザクション内で重い非同期処理を避けるようにします。
  • ラッパークラスを作って、エラーハンドリングやログ出力、型定義などの共通処理の重複を減らすようにします。

YouTubeチャンネルでは、Visual Studio Codeを用いて上記の記事を見ながら確認できます。 ぜひYouTubeチャンネルもご覧ください。

YouTube Video