2024年2月13日

IndexedDB

IndexedDBはブラウザに組み込まれたデータベースであり、localStorageよりもはるかに強力です。

  • キーと複数のキータイプによって、ほぼすべての種類の値を格納します。
  • 信頼性のためにトランザクションをサポートします。
  • キー範囲クエリ、インデックスをサポートします。
  • localStorageよりもはるかに大量のデータを格納できます。

その能力は、従来のクライアントサーバーアプリケーションには通常過剰です。 IndexedDBは、オフラインアプリケーション向けに、ServiceWorkersやその他のテクノロジーと組み合わせて使用することを目的としています。

仕様https://www.w3.org/TR/IndexedDBに記載されているIndexedDBへのネイティブインターフェースは、イベントベースです。

https://github.com/jakearchibald/idbのようなpromiseベースのラッパーを使用すると、async/awaitを使用することもできます。これは非常に便利ですが、ラッパーは完璧ではなく、すべての場合においてイベントを置き換えることはできません。そのため、まずはイベントから始め、IndexedDBを理解した上で、ラッパーを使用します。

データはどこにありますか?

技術的には、データは通常、ブラウザの設定、拡張機能などと同様に、訪問者のホームディレクトリに保存されます。

ブラウザとOSレベルのユーザーごとに、それぞれ独立したストレージがあります。

データベースを開く

IndexedDBの操作を開始するには、まずデータベースをopen(接続)する必要があります。

構文

let openRequest = indexedDB.open(name, version);
  • name – 文字列、データベース名。
  • version – 正の整数バージョン、デフォルトは1(以下で説明)。

異なる名前のデータベースを複数持つことができますが、すべて現在のオリジン(ドメイン/プロトコル/ポート)内に存在します。異なるWebサイトは、互いのデータベースにアクセスできません。

呼び出しはopenRequestオブジェクトを返し、そのオブジェクトのイベントをリッスンする必要があります

  • success:データベースの準備ができました。openRequest.resultに「データベースオブジェクト」があり、それ以降の呼び出しに使用します。
  • error:オープンに失敗しました。
  • upgradeneeded:データベースの準備はできていますが、バージョンが古くなっています(以下を参照)。

IndexedDBには、サーバーサイドデータベースにはない、「スキーマバージョニング」の組み込みメカニズムがあります。

サーバーサイドデータベースとは異なり、IndexedDBはクライアントサイドであり、データはブラウザに保存されるため、開発者は常にアクセスできるわけではありません。そのため、アプリの新しいバージョンを公開し、ユーザーがWebページにアクセスしたときに、データベースを更新する必要がある場合があります。

ローカルデータベースのバージョンがopenで指定されたバージョンよりも古い場合、特別なイベントupgradeneededがトリガーされ、必要に応じてバージョンを比較してデータ構造をアップグレードできます。

データベースがまだ存在しない場合(技術的にはバージョンは0)、upgradeneededイベントもトリガーされるため、初期化を実行できます。

アプリの最初のバージョンを公開したとしましょう。

次に、バージョン1でデータベースを開き、upgradeneededハンドラで次のように初期化を実行できます

let openRequest = indexedDB.open("store", 1);

openRequest.onupgradeneeded = function() {
  // triggers if the client had no database
  // ...perform initialization...
};

openRequest.onerror = function() {
  console.error("Error", openRequest.error);
};

openRequest.onsuccess = function() {
  let db = openRequest.result;
  // continue working with database using db object
};

その後、2番目のバージョンを公開します。

バージョン2で開いて、次のようにアップグレードを実行できます

let openRequest = indexedDB.open("store", 2);

openRequest.onupgradeneeded = function(event) {
  // the existing database version is less than 2 (or it doesn't exist)
  let db = openRequest.result;
  switch(event.oldVersion) { // existing db version
    case 0:
      // version 0 means that the client had no database
      // perform initialization
    case 1:
      // client had version 1
      // update
  }
};

現在のバージョンは2であるため、onupgradeneededハンドラには、初めてアクセスしてデータベースを持っていないユーザーに適したバージョン0のコードブランチと、アップグレード用のバージョン1のコードブランチがあります。

そして、onupgradeneededハンドラがエラーなしで終了した場合にのみ、openRequest.onsuccessがトリガーされ、データベースは正常に開かれたと見なされます。

データベースを削除するには

let deleteRequest = indexedDB.deleteDatabase(name)
// deleteRequest.onsuccess/onerror tracks the result
古いopenコールバージョンを使用してデータベースを開くことはできません

現在のユーザーデータベースのバージョンがopenコールのバージョンよりも高い場合、たとえば、既存のDBバージョンが3で、open(...2)を試みると、エラーとなり、openRequest.onerrorがトリガーされます。

これはまれですが、訪問者がプロキシキャッシュなどから古いJavaScriptコードを読み込んだ場合に発生する可能性があります。そのため、コードは古くても、データベースは新しいです。

エラーを防ぐために、db.versionをチェックして、ページの再読み込みを提案する必要があります。適切なHTTPキャッシュヘッダーを使用して、古いコードの読み込みを回避することで、このような問題が発生することはありません。

並列更新の問題

バージョニングについて話しているので、関連する小さな問題に取り組みましょう。

たとえば

  1. 訪問者がブラウザタブで、データベースバージョン1でサイトを開きました。
  2. その後、アップデートをロールアウトしたので、コードはより新しいものになりました。
  3. そして、同じ訪問者が別のタブでサイトを開きます。

そのため、DBバージョン1へのオープン接続を持つタブがあり、2番目のタブはupgradeneededハンドラでバージョン2に更新しようとします。

問題は、同じサイト、同じオリジンであるため、データベースが2つのタブ間で共有されていることです。そして、バージョン1とバージョン2の両方になることはできません。バージョン2に更新するには、最初のタブを含むバージョン1へのすべての接続を閉じる必要があります。

それを整理するために、versionchangeイベントが「古い」データベースオブジェクトでトリガーされます。それをリッスンして古いデータベース接続を閉じ(そしておそらくページの再読み込みを提案して、更新されたコードを読み込む)必要があります。

versionchangeイベントをリッスンせず、古い接続を閉じないと、2番目の新しい接続は行われません。 openRequestオブジェクトは、successではなくblockedイベントを発行します。そのため、2番目のタブは機能しません。

並列アップグレードを正しく処理するコードは次のとおりです。現在のデータベース接続が古くなった場合(dbバージョンが他の場所で更新された場合)にトリガーされ、接続を閉じるonversionchangeハンドラをインストールします。

let openRequest = indexedDB.open("store", 2);

openRequest.onupgradeneeded = ...;
openRequest.onerror = ...;

openRequest.onsuccess = function() {
  let db = openRequest.result;

  db.onversionchange = function() {
    db.close();
    alert("Database is outdated, please reload the page.")
  };

  // ...the db is ready, use it...
};

openRequest.onblocked = function() {
  // this event shouldn't trigger if we handle onversionchange correctly

  // it means that there's another open connection to the same database
  // and it wasn't closed after db.onversionchange triggered for it
};

…言い換えれば、ここでは2つのことを行います

  1. db.onversionchangeリスナーは、現在のデータベースバージョンが古くなった場合に、並列更新の試みについて通知します。
  2. openRequest.onblockedリスナーは、反対の状況について通知します。他の場所に古いバージョンへの接続があり、それが閉じないため、新しい接続を行うことができません。

db.onversionchangeでより適切に処理し、接続が閉じられる前に訪問者にデータを保存するように促すことができます。

または、代替アプローチとして、db.onversionchangeでデータベースを閉じずに、onblockedハンドラ(新しいタブ内)を使用して訪問者に警告し、他のタブを閉じるまで新しいバージョンを読み込むことができないことを伝えることができます。

これらの更新の衝突はまれにしか発生しませんが、少なくともonblockedハンドラを用意して、スクリプトがサイレントに停止しないようにする必要があります。

オブジェクトストア

IndexedDBに何かを保存するには、*オブジェクトストア*が必要です。

オブジェクトストアは、IndexedDBの中心的な概念です。他のデータベースの対応するものは、「テーブル」または「コレクション」と呼ばれます。データが保存される場所です。データベースには、ユーザー用、商品用など、複数のストアを含めることができます。

「オブジェクトストア」という名前ですが、プリミティブも保存できます。

複合オブジェクトを含む、ほぼすべての値を保存できます。

IndexedDBは、標準シリアル化アルゴリズムを使用して、オブジェクトを複製して保存します。 JSON.stringifyに似ていますが、より強力で、はるかに多くのデータ型を保存できます。

保存できないオブジェクトの例:循環参照を持つオブジェクト。このようなオブジェクトはシリアル化できません。 JSON.stringifyも、このようなオブジェクトに対しては失敗します。

ストア内のすべての値に一意のkeyが必要です。

キーは、数値、日付、文字列、バイナリ、または配列のいずれかの型である必要があります。一意の識別子であるため、キーによって値を検索/削除/更新できます。

すぐにわかるように、localStorageと同様に、ストアに値を追加するときにキーを提供できます。しかし、オブジェクトを保存する場合、IndexedDBではオブジェクトプロパティをキーとして設定できるため、はるかに便利です。または、キーを自動生成することもできます。

しかし、最初にオブジェクトストアを作成する必要があります。

オブジェクトストアを作成するための構文

db.createObjectStore(name[, keyOptions]);

操作は同期であり、awaitは必要ありません。

  • nameはストア名です。たとえば、書籍の場合は"books"です。
  • keyOptionsは、次の2つのプロパティのいずれかを持つオプションのオブジェクトです
    • keyPath – IndexedDBがキーとして使用するオブジェクトプロパティへのパス。たとえば、idです。
    • autoIncrementtrueの場合、新しく保存されたオブジェクトのキーは、常に増加する数値として自動的に生成されます。

keyOptionsを指定しない場合、後でオブジェクトを保存するときに明示的にキーを指定する必要があります。

たとえば、このオブジェクトストアはidプロパティをキーとして使用します

db.createObjectStore('books', {keyPath: 'id'});

オブジェクトストアは、DBバージョンを更新している間、upgradeneededハンドラでのみ作成/変更できます。

これは技術的な制限です。ハンドラの外部では、データの追加/削除/更新はできますが、オブジェクトストアはバージョンアップ中にのみ作成/削除/変更できます。

データベースバージョンのアップグレードを実行するには、主に2つのアプローチがあります

  1. バージョンごとのアップグレード関数を実装できます:1から2、2から3、3から4など。次に、upgradeneededでバージョンを比較し(たとえば、古い2、現在は4)、すべての中間バージョン(2から3、次に3から4)に対して、バージョンごとのアップグレードを段階的に実行できます。
  2. または、データベースを調べて、既存のオブジェクトストアのリストをdb.objectStoreNamesとして取得することもできます。そのオブジェクトは、存在を確認するためのcontains(name)メソッドを提供するDOMStringListです。そして、何が存在し、何が存在しないかによって、更新を行うことができます。

小規模なデータベースの場合、2番目のバリアントの方が簡単かもしれません。

2番目のアプローチのデモは次のとおりです

let openRequest = indexedDB.open("db", 2);

// create/upgrade the database without version checks
openRequest.onupgradeneeded = function() {
  let db = openRequest.result;
  if (!db.objectStoreNames.contains('books')) { // if there's no "books" store
    db.createObjectStore('books', {keyPath: 'id'}); // create it
  }
};

オブジェクトストアを削除するには

db.deleteObjectStore('books')

トランザクション

「トランザクション」という用語は一般的であり、多くの種類のデータベースで使用されます。

トランザクションは、すべて成功するかすべて失敗する必要がある操作のグループです。

たとえば、人が何かを購入するとき、次のことを行う必要があります

  1. アカウントからお金を引き落とします。
  2. アイテムをインベントリに追加します。

最初の操作を完了した後、停電など、何か問題が発生して2番目の操作を実行できないと、非常に困ります。どちらも成功する(購入完了、良い!)か、どちらも失敗する(少なくともその人はお金を保持しているので、再試行できる)必要があります。

トランザクションはそれを保証できます。

すべてのデータ操作は、IndexedDBのトランザクション内で行う必要があります。

トランザクションを開始するには

db.transaction(store[, type]);
  • store はトランザクションがアクセスするストア名です。例えば、"books" のようになります。複数のストアにアクセスする場合は、ストア名の配列にすることができます。
  • type – トランザクションの種類で、以下のいずれかです。
    • readonly – 読み取り専用で、デフォルト値です。
    • readwrite – データの読み取りと書き込みはできますが、オブジェクトストアの作成/削除/変更はできません。

versionchange トランザクションタイプもあります。このようなトランザクションはすべてを実行できますが、手動で作成することはできません。IndexedDBは、データベースを開く際に、upgradeneeded ハンドラのために versionchange トランザクションを自動的に作成します。そのため、データベース構造の更新、オブジェクトストアの作成/削除を行うことができる唯一の場所となります。

なぜ異なる種類のトランザクションがあるのでしょうか?

トランザクションを readonlyreadwrite のいずれかにラベル付けする必要がある理由は、パフォーマンスです。

多くの readonly トランザクションは、同じストアに同時にアクセスできますが、readwrite トランザクションはできません。 readwrite トランザクションは、ストアを書き込みのために「ロック」します。次のトランザクションは、前のトランザクションが同じストアへのアクセスを完了するまで待機する必要があります。

トランザクションが作成された後、次のようにストアにアイテムを追加できます。

let transaction = db.transaction("books", "readwrite"); // (1)

// get an object store to operate on it
let books = transaction.objectStore("books"); // (2)

let book = {
  id: 'js',
  price: 10,
  created: new Date()
};

let request = books.add(book); // (3)

request.onsuccess = function() { // (4)
  console.log("Book added to the store", request.result);
};

request.onerror = function() {
  console.log("Error", request.error);
};

基本的には4つの手順があります。

  1. (1) で、アクセスするすべてのストアを指定してトランザクションを作成します。
  2. (2) で、transaction.objectStore(name) を使用してストアオブジェクトを取得します。
  3. (3) で、オブジェクトストア books.add(book) にリクエストを実行します。
  4. (4) でリクエストの成功/エラーを処理します。その後、必要に応じて他のリクエストを作成できます。

オブジェクトストアは、値を格納するための2つのメソッドをサポートしています。

  • put(value, [key]) ストアに value を追加します。 key は、オブジェクトストアに keyPath または autoIncrement オプションがなかった場合にのみ指定されます。同じキーを持つ値が既に存在する場合は、置き換えられます。

  • add(value, [key]) put と同じですが、同じキーを持つ値が既に存在する場合は、リクエストが失敗し、"ConstraintError" という名前のエラーが生成されます。

データベースを開くのと同様に、リクエストを送信できます: books.add(book)、そして success/error イベントを待ちます。

  • addrequest.result は、新しいオブジェクトのキーです。
  • エラーは request.error にあります(存在する場合)。

トランザクションの自動コミット

上記の例では、トランザクションを開始し、add リクエストを行いました。しかし、前述したように、トランザクションには複数の関連するリクエストがあり、それらはすべて成功するか、すべて失敗する必要があります。トランザクションが完了し、これ以上のリクエストがないことをどのようにマークするのでしょうか?

簡単な答えは、「何もしない」です。

仕様の次のバージョン3.0では、トランザクションを手動で終了する方法が提供される可能性がありますが、現在の2.0では提供されていません。

すべてのトランザクションリクエストが完了し、マイクロタスクキューが空になると、自動的にコミットされます。

通常、すべてのリクエストが完了し、現在のコードが終了すると、トランザクションがコミットされると想定できます。

そのため、上記の例では、トランザクションを終了するために特別な呼び出しは必要ありません。

トランザクションの自動コミットの原則には、重要な副作用があります。 fetchsetTimeout などの非同期操作をトランザクションの途中で挿入することはできません。 IndexedDBは、これらが完了するまでトランザクションを待機させません。

以下のコードでは、行(*)request2 は失敗します。トランザクションが既にコミットされているため、その中でリクエストを行うことができないためです。

let request1 = books.add(book);

request1.onsuccess = function() {
  fetch('/').then(response => {
    let request2 = books.add(anotherBook); // (*)
    request2.onerror = function() {
      console.log(request2.error.name); // TransactionInactiveError
    };
  });
};

これは、fetch が非同期操作、つまりマクロタスクであるためです。トランザクションは、ブラウザがマクロタスクの実行を開始する前に閉じられます。

IndexedDB仕様の作成者は、トランザクションは短命であるべきだと考えています。主にパフォーマンス上の理由からです。

特に、readwrite トランザクションは、ストアを書き込みのために「ロック」します。そのため、アプリケーションのある部分が books オブジェクトストアに対して readwrite を開始した場合、同じことを行いたい別の部分は待機する必要があります。新しいトランザクションは、最初のトランザクションが完了するまで「ハング」します。トランザクションに時間がかかると、奇妙な遅延が発生する可能性があります。

では、どうすればよいでしょうか?

上記の例では、新しいリクエスト (*) の直前に新しい db.transaction を作成できます。

しかし、操作を1つのトランザクションにまとめておきたい場合は、IndexedDBトランザクションと「その他の」非同期処理を分割する方がさらに良いでしょう。

最初に fetch を実行し、必要に応じてデータを準備してから、トランザクションを作成し、すべてのデータベースリクエストを実行します。そうすればうまくいきます。

正常に完了した瞬間を検出するために、transaction.oncomplete イベントをリッスンできます。

let transaction = db.transaction("books", "readwrite");

// ...perform operations...

transaction.oncomplete = function() {
  console.log("Transaction is complete");
};

complete のみ、トランザクションが全体として保存されることを保証します。個々のリクエストは成功する可能性がありますが、最終的な書き込み操作は失敗する可能性があります(I/Oエラーなど)。

トランザクションを手動で中止するには、次のように呼び出します。

transaction.abort();

これにより、その中のリクエストによって行われたすべての変更がキャンセルされ、transaction.onabort イベントがトリガーされます。

エラー処理

書き込みリクエストは失敗する可能性があります。

これは、私たち側の潜在的なエラーだけでなく、トランザクション自体とは無関係の理由でも予期されることです。たとえば、ストレージクォータを超える可能性があります。そのため、このような場合を処理する準備をする必要があります。

失敗したリクエストは、トランザクションを自動的に中止し、すべての変更をキャンセルします。

状況によっては、既存の変更をキャンセルせずに障害を処理し(たとえば、別のリクエストを試行)、トランザクションを続行したい場合があります。それは可能です。 request.onerror ハンドラは、event.preventDefault() を呼び出すことで、トランザクションの中止を防ぐことができます。

以下の例では、既存のブックと同じキー(id)で新しいブックが追加されます。 store.add メソッドは、その場合に "ConstraintError" を生成します。トランザクションをキャンセルせずに処理します。

let transaction = db.transaction("books", "readwrite");

let book = { id: 'js', price: 10 };

let request = transaction.objectStore("books").add(book);

request.onerror = function(event) {
  // ConstraintError occurs when an object with the same id already exists
  if (request.error.name == "ConstraintError") {
    console.log("Book with such id already exists"); // handle the error
    event.preventDefault(); // don't abort the transaction
    // use another key for the book?
  } else {
    // unexpected error, can't handle it
    // the transaction will abort
  }
};

transaction.onabort = function() {
  console.log("Error", transaction.error);
};

イベント делегирование(委任)

すべてのリクエストに onerror/onsuccess が必要ですか?必ずしもそうではありません。代わりにイベント делегирование(委任)を使用できます。

IndexedDBイベントはバブルします:requesttransactiondatabase

すべてのイベントは、キャプチャとバブリングを伴うDOMイベントですが、通常はバブリングステージのみが使用されます。

そのため、db.onerror ハンドラを使用して、レポートなどの目的ですべてのエラーをキャッチできます。

db.onerror = function(event) {
  let request = event.target; // the request that caused the error

  console.log("Error", request.error);
};

…しかし、エラーが完全に処理された場合はどうでしょうか?その場合は報告したくありません。

request.onerrorevent.stopPropagation() を使用することにより、バブリングを停止し、したがって db.onerror を停止できます。

request.onerror = function(event) {
  if (request.error.name == "ConstraintError") {
    console.log("Book with such id already exists"); // handle the error
    event.preventDefault(); // don't abort the transaction
    event.stopPropagation(); // don't bubble error up, "chew" it
  } else {
    // do nothing
    // transaction will be aborted
    // we can take care of error in transaction.onabort
  }
};

検索

オブジェクトストアには、主に2種類の検索があります。

  1. キー値またはキー範囲による検索。「books」ストレージでは、book.id の値または値の範囲になります。
  2. 別のオブジェクトフィールド(例:book.price)による検索。これには、「インデックス」という名前の追加のデータ構造が必要です。

キーによる検索

まず、最初のタイプの検索、つまりキーによる検索を扱います。

検索メソッドは、正確なキー値と、いわゆる「値の範囲」(許容される「キー範囲」を指定するIDBKeyRange オブジェクト)の両方をサポートしています。

IDBKeyRange オブジェクトは、次の呼び出しを使用して作成されます。

  • IDBKeyRange.lowerBound(lower, [open]) は、≥lower(または open がtrueの場合は >lower)を意味します。
  • IDBKeyRange.upperBound(upper, [open]) は、≤upper(または open がtrueの場合は <upper)を意味します。
  • IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen]) は、lowerupper の間を意味します。 openフラグがtrueの場合、対応するキーは範囲に含まれません。
  • IDBKeyRange.only(key) – 1つの key のみで構成される範囲。めったに使用されません。

すぐに実用的な使用例を紹介します。

実際の検索を実行するには、次のメソッドがあります。これらは、正確なキーまたはキー範囲のいずれかである query 引数を受け入れます。

  • store.get(query) – キーまたは範囲で最初の値を検索します。
  • store.getAll([query], [count]) – すべての値を検索し、指定されている場合は count で制限します。
  • store.getKey(query) – クエリを満たす最初のキーを検索します。通常は範囲です。
  • store.getAllKeys([query], [count]) – クエリを満たすすべてのキーを検索します。通常は範囲で、指定されている場合は最大 count までです。
  • store.count([query]) – クエリを満たすキーの総数を取得します。通常は範囲です。

たとえば、ストアにたくさんの本があるとします。 id フィールドはキーであるため、これらのすべてのメソッドは id で検索できます。

リクエスト例

// get one book
books.get('js')

// get books with 'css' <= id <= 'html'
books.getAll(IDBKeyRange.bound('css', 'html'))

// get books with id < 'html'
books.getAll(IDBKeyRange.upperBound('html', true))

// get all books
books.getAll()

// get all keys, where id > 'js'
books.getAllKeys(IDBKeyRange.lowerBound('js', true))
オブジェクトストアは常にソートされています

オブジェクトストアは、内部で値をキーでソートします。

そのため、多くの値を返すリクエストは、常にキーの順にソートされた値を返します。

インデックスを使用したフィールドによる検索

他のオブジェクトフィールドで検索するには、「インデックス」という名前の追加のデータ構造を作成する必要があります。

インデックスは、指定されたオブジェクトフィールドを追跡するストアの「アドオン」です。そのフィールドの各値について、その値を持つオブジェクトのキーのリストを格納します。以下でより詳細な図を示します。

構文

objectStore.createIndex(name, keyPath, [options]);
  • name – インデックス名
  • keyPath – インデックスが追跡するオブジェクトフィールドへのパス(このフィールドで検索します)
  • option – プロパティを持つオプションのオブジェクト
    • unique – trueの場合、keyPath に指定された値を持つオブジェクトはストアに1つだけ存在できます。インデックスは、重複を追加しようとするとエラーを生成することでこれを強制します。
    • multiEntrykeyPath の値が配列の場合にのみ使用されます。その場合、デフォルトでは、インデックスは配列全体をキーとして扱います。ただし、multiEntry がtrueの場合、インデックスはその配列の各値のストアオブジェクトのリストを保持します。そのため、配列メンバーはインデックスキーになります。

この例では、id をキーとする書籍を保存します。

price で検索したいとしましょう。

まず、インデックスを作成する必要があります。オブジェクトストアと同様に、upgradeneeded で行う必要があります。

openRequest.onupgradeneeded = function() {
  // we must create the index here, in versionchange transaction
  let books = db.createObjectStore('books', {keyPath: 'id'});
  let index = books.createIndex('price_idx', 'price');
};
  • インデックスは price フィールドを追跡します。
  • 価格は一意ではなく、同じ価格の本が複数ある可能性があるため、unique オプションは設定しません。
  • 価格は配列ではないため、multiEntry フラグは適用されません。

inventory に4冊の本があるとします。 index が正確に何であるかを示す図を以下に示します。

前述のように、price の各値(2番目の引数)のインデックスは、その価格を持つキーのリストを保持します。

インデックスは自動的に最新の状態に保たれるため、気にする必要はありません。

これで、特定の価格を検索する場合、インデックスに同じ検索メソッドを適用するだけです。

let transaction = db.transaction("books"); // readonly
let books = transaction.objectStore("books");
let priceIndex = books.index("price_idx");

let request = priceIndex.getAll(10);

request.onsuccess = function() {
  if (request.result !== undefined) {
    console.log("Books", request.result); // array of books with price=10
  } else {
    console.log("No such books");
  }
};

IDBKeyRange を使用して範囲を作成し、安い/高価な本を探すこともできます。

// find books where price <= 5
let request = priceIndex.getAll(IDBKeyRange.upperBound(5));

インデックスは、追跡対象のオブジェクトフィールド(この場合は price)で内部的にソートされます。そのため、検索を実行すると、結果も price でソートされます。

ストアからの削除

delete メソッドは、クエリによって削除する値を検索します。呼び出し形式は getAll に似ています。

  • delete(query) – クエリに一致する値を削除します。

例えば

// delete the book with id='js'
books.delete('js');

価格や他のオブジェクトフィールドに基づいて書籍を削除したい場合、最初にインデックス内のキーを見つけ、次に delete を呼び出す必要があります。

// find the key where price = 5
let request = priceIndex.getKey(5);

request.onsuccess = function() {
  let id = request.result;
  let deleteRequest = books.delete(id);
};

すべてを削除するには

books.clear(); // clear the storage.

カーソル

getAll/getAllKeys のようなメソッドは、キー/値の配列を返します。

しかし、オブジェクトストレージは巨大になる可能性があり、利用可能なメモリよりも大きくなる可能性があります。 その場合、getAll はすべてのレコードを配列として取得できません。

どうすればいいですか?

カーソルは、それを回避する手段を提供します。

カーソル は、クエリが与えられるとオブジェクトストレージをトラバースし、一度に1つのキー/値を返す特別なオブジェクトであり、メモリを節約します。

オブジェクトストアは内部的にキーでソートされているため、カーソルはキーの順序(デフォルトは昇順)でストアを歩きます。

構文

// like getAll, but with a cursor:
let request = store.openCursor(query, [direction]);

// to get keys, not values (like getAllKeys): store.openKeyCursor
  • query は、getAll と同じように、キーまたはキー範囲です。
  • direction はオプションの引数で、使用する順序を指定します。
    • "next" – デフォルトでは、カーソルは最小のキーを持つレコードから上に移動します。
    • "prev" – 逆順:最大のキーを持つレコードから下に移動します。
    • "nextunique""prevunique" – 上記と同じですが、同じキーを持つレコードをスキップします(インデックス上のカーソルのみ、例えば、price=5 の複数の書籍がある場合、最初のものだけが返されます)。

カーソルの主な違いは、request.onsuccess が結果ごとに複数回トリガーされることです。

カーソルの使用方法の例を次に示します。

let transaction = db.transaction("books");
let books = transaction.objectStore("books");

let request = books.openCursor();

// called for each book found by the cursor
request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    let key = cursor.key; // book key (id field)
    let value = cursor.value; // book object
    console.log(key, value);
    cursor.continue();
  } else {
    console.log("No more books");
  }
};

主なカーソルメソッドは次のとおりです。

  • advance(count) – カーソルを count 回進め、値をスキップします。
  • continue([key]) – カーソルを、一致する範囲内の次の値(または、指定されている場合は key の直後)に進めます。

カーソルに一致する値がさらに存在するかどうかに関係なく、onsuccess が呼び出され、result で次のレコードを指すカーソル、または undefined を取得できます。

上記の例では、カーソルはオブジェクトストア用に作成されました。

しかし、インデックス上にカーソルを作成することもできます。 ご存知のように、インデックスを使用すると、オブジェクトフィールドで検索できます。 インデックス上のカーソルは、オブジェクトストア上のカーソルとまったく同じように機能します。一度に1つの値を返すことでメモリを節約します。

インデックス上のカーソルの場合、cursor.key はインデックスキー(例:価格)であり、オブジェクトキーには cursor.primaryKey プロパティを使用する必要があります。

let request = priceIdx.openCursor(IDBKeyRange.upperBound(5));

// called for each record
request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    let primaryKey = cursor.primaryKey; // next object store key (id field)
    let value = cursor.value; // next object store object (book object)
    let key = cursor.key; // next index key (price)
    console.log(key, value);
    cursor.continue();
  } else {
    console.log("No more books");
  }
};

Promise ラッパー

すべてのリクエストに onsuccess/onerror を追加するのは、非常に面倒な作業です。 イベントデリゲーションを使用することで、生活を楽にすることができます(たとえば、トランザクション全体にハンドラーを設定するなど)。しかし、async/await の方がはるかに便利です。

この章では、薄いpromiseラッパー https://github.com/jakearchibald/idb を使用します。 これは、Promise化された IndexedDB メソッドを使用してグローバル idb オブジェクトを作成します。

すると、onsuccess/onerror の代わりに、次のように書くことができます。

let db = await idb.openDB('store', 1, db => {
  if (db.oldVersion == 0) {
    // perform the initialization
    db.createObjectStore('books', {keyPath: 'id'});
  }
});

let transaction = db.transaction('books', 'readwrite');
let books = transaction.objectStore('books');

try {
  await books.add(...);
  await books.add(...);

  await transaction.complete;

  console.log('jsbook saved');
} catch(err) {
  console.log('error', err.message);
}

そのため、すべての「プレーンな非同期コード」と「try…catch」の機能が備わっています。

エラー処理

エラーをキャッチしない場合、エラーは最も近い外側の try..catch までフォールスルーします。

キャッチされないエラーは、window オブジェクトの「処理されないpromise拒否」イベントになります。

このようなエラーは、次のように処理できます。

window.addEventListener('unhandledrejection', event => {
  let request = event.target; // IndexedDB native request object
  let error = event.reason; //  Unhandled error object, same as request.error
  ...report about the error...
});

「非アクティブなトランザクション」の落とし穴

すでにわかっているように、トランザクションは、ブラウザが現在のコードとマイクロタスクを完了するとすぐに自動コミットされます。 したがって、トランザクションの途中に fetch のようなマクロタスクを配置すると、トランザクションはそれが完了するまで待機しません。 単に自動コミットされます。 そのため、その次のリクエストは失敗します。

promiseラッパーと async/await の場合も状況は同じです。

トランザクションの途中に fetch がある例を次に示します。

let transaction = db.transaction("inventory", "readwrite");
let inventory = transaction.objectStore("inventory");

await inventory.add({ id: 'js', price: 10, created: new Date() });

await fetch(...); // (*)

await inventory.add({ id: 'js', price: 10, created: new Date() }); // Error

fetch (*) の後の次の inventory.add は、「非アクティブなトランザクション」エラーで失敗します。これは、トランザクションがすでにコミットされてその時点で閉じられているためです。

回避策は、ネイティブIndexedDBを操作する場合と同じです。新しいトランザクションを作成するか、単に分割します。

  1. 最初にデータを準備し、必要なすべてを取得します。
  2. 次に、データベースに保存します。

ネイティブオブジェクトの取得

内部的には、ラッパーはネイティブIndexedDBリクエストを実行し、それに onerror/onsuccess を追加し、結果で拒否/解決するpromiseを返します。

ほとんどの場合、これは正常に機能します。 例は、libページ https://github.com/jakearchibald/idb にあります。

まれに、元の request オブジェクトが必要な場合は、promiseの promise.request プロパティとしてアクセスできます。

let promise = books.add(book); // get a promise (don't await for its result)

let request = promise.request; // native request object
let transaction = request.transaction; // native transaction object

// ...do some native IndexedDB voodoo...

let result = await promise; // if still needed

まとめ

IndexedDBは、「強力なlocalStorage」と考えることができます。 オフラインアプリに十分なほど強力でありながら、使い方が簡単なシンプルなキーバリューデータベースです。

最良のマニュアルは仕様書です。現在の仕様書は2.0ですが、3.0(それほど違いはありません)のいくつかのメソッドは部分的にサポートされています。

基本的な使い方は、いくつかのフレーズで説明できます。

  1. idb のようなpromiseラッパーを取得します。
  2. データベースを開きます:idb.openDb(name, version, onupgradeneeded)
    • onupgradeneeded ハンドラーでオブジェクトストレージとインデックスを作成するか、必要に応じてバージョンアップデートを実行します。
  3. リクエストの場合
    • トランザクション db.transaction('books') を作成します(必要に応じてreadwrite)。
    • オブジェクトストア transaction.objectStore('books') を取得します。
  4. 次に、キーで検索するには、オブジェクトストアで直接メソッドを呼び出します。
    • オブジェクトフィールドで検索するには、インデックスを作成します。
  5. データがメモリに収まらない場合は、カーソルを使用します。

小さなデモアプリを紹介します。

結果
index.html
<!doctype html>
<script src="https://cdn.jsdelivr.net/npm/idb@3.0.2/build/idb.min.js"></script>

<button onclick="addBook()">Add a book</button>
<button onclick="clearBooks()">Clear books</button>

<p>Books list:</p>

<ul id="listElem"></ul>

<script>
let db;

init();

async function init() {
  db = await idb.openDb('booksDb', 1, db => {
    db.createObjectStore('books', {keyPath: 'name'});
  });

  list();
}

async function list() {
  let tx = db.transaction('books');
  let bookStore = tx.objectStore('books');

  let books = await bookStore.getAll();

  if (books.length) {
    listElem.innerHTML = books.map(book => `<li>
        name: ${book.name}, price: ${book.price}
      </li>`).join('');
  } else {
    listElem.innerHTML = '<li>No books yet. Please add books.</li>'
  }


}

async function clearBooks() {
  let tx = db.transaction('books', 'readwrite');
  await tx.objectStore('books').clear();
  await list();
}

async function addBook() {
  let name = prompt("Book name?");
  let price = +prompt("Book price?");

  let tx = db.transaction('books', 'readwrite');

  try {
    await tx.objectStore('books').add({name, price});
    await list();
  } catch(err) {
    if (err.name == 'ConstraintError') {
      alert("Such book exists already");
      await addBook();
    } else {
      throw err;
    }
  }
}

window.addEventListener('unhandledrejection', event => {
  alert("Error: " + event.reason.message);
});

</script>
チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善の提案がある場合は、コメントする代わりに、GitHub issue またはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数語のコードを挿入するには、<code> タグを使用します。複数行の場合は、<pre> タグで囲みます。10行を超える場合は、サンドボックス(plnkrjsbincodepen…)を使用します。