Storing data in the browser

LocalStorage, sessionStorage

<https://javascript.info/localstorage> ノート。

ウェブストレージオブジェクト localStorage および sessionStorage により、ブラウザーにキーと値のペアを保存することができる。これらのオブジェクトの面白いところは、データがページの更新 (sessionStorage) やブラウザーの完全な再起動 (localStoregae) にも影響を受けないことにある

Cookie がすでにありながら、さらなるオブジェクトがなぜ必要なのだろうか:

  • Cookie とは異なり、ウェブストレージオブジェクトは要求ごとにサーバーに送信されるわけではない。そのため、データをより多く保存することができる。

  • これもまた cookie とは異なり、サーバーは HTTP ヘッダーを介してストレージオブジェクトを操作することができない。すべては JavaScript で行われる。

  • ストレージはオリジン拘束性がある。つまり、異なるプロトコルやサブドメインは、異なるストレージオブジェクトを割り出し、互いにデータにアクセスすることはできない。

どちらのストレージオブジェクトも同じメソッドとプロパティを備えている:

  • setItem(key, value): キーと値のペアを格納する。

  • getItem(key): キーで値を取得する。

  • removeItem(key): キーとその値を削除する。

  • clear(): すべて削除する。

  • key(index): 任意の位置のキーを取得する。

  • length: 保存する項目の数。

Map に似ていると憶えればいい。それに key(index) が付いたものだと考えられる。

localStorage demo

localStorage の主な機能:

  • 同じオリジンのすべてのタブとウィンドウで共有される。

  • データに有効期限がない。ブラウザーの再起動はもちろん、OS の再起動後もデータが残る。

例えば、次のコードをまず実行する:

localStorage.setItem('test', 1);

そしてブラウザーを閉じたり開いたり、あるいは同じページを別のウィンドウで開くだけで、このようにして値を取得できる:

localStorage.getItem('test'); // 1

同じオリジンであればよく、URL パスは異なっていてもよい。localStorage は同じオリジンを持つすべてのウィンドウで共有されるので、あるウィンドウでデータを設定すると、その変更は別のウィンドウからも見えるようになる。

Object-like access

また、普通のオブジェクトの方法でキーを取得、設定することもできる。これは歴史的な理由で認められており、ほとんど機能しているが、一般的には推奨されない。

  1. もしキーが利用者によって生成されたものであれば、どんなものでもあり得る。 lengthtoString あるいは localStorage の他の組み込みメソッドでも。この場合、{get,set}Item() は問題なく動作するが、オブジェクト風アクセスは失敗する。

  2. イベント storage があり、データを変更したときに起こる。そのイベントは、オブジェクト風アクセスでは起こらない。

Looping over keys

ストレージオブジェクトは概念としてはコレクションであるものの、反復可能機能を提供していない。いちおう forin ループを書けるが、必要のない組み込みフィールドもキーとして出てくる。なので、本書では Object.keys(localStorage) を反復処理することを推奨している。

Strings only

ストレージオブジェクトはキーと値の両方が文字列でなければならない。数値やオブジェクトなど他の型であった場合は、自動的に文字列に変換される。

オブジェクトを保存したければ JSON がある。また、デバッグ用にストレージオブジェクトを JSON にすることもある:

JSON.stringify(localStorage, null, 2);

sessionStorage

sessionStoragelocalStorage に比べると使用頻度が低い。プロパティーやメソッドは同じだ、より限定的だ。

  • sessionStorage は現在のブラウザータブ内にしか存在しない。

    • 同じページを表示する別のタブでは、別のストレージを持つ。

    • しかし、同じタブ内の iframe 間では共有される(同じオリジンから来たとする)。

  • データはページの更新には耐えるが、タブを閉じたり開いたりするのには耐えられない。

sessionStorage.setItem('test', 1);

このコードを実行して、画面を更新すると

sessionStorage.getItem('test');

で値がまだ得られる。しかし、同じページを別のタブで開き、そこでもう一度試してみると、上記のコードは null を返す。これはまさに、sessionStorage がオリジンだけでなく、ブラウザーのタブにも束縛されているためだ。そのため、sessionStorage の使用は控えられる。

Storage event

localStoragesessionStorage のデータが更新されると、イベント storage が起こる。そのときのイベントのプロパティーは次のとおり:

  • key: 変更されたキー。clear() が呼び出された場合は null.

  • oldValue: 古い値。キーが新しく追加された場合は null.

  • newValue: 新しい値。キーが削除された場合は null.

  • url: 更新が発生したドキュメントの URL.

  • storageArea: 更新が発生した localStorage または sessionStorage オブジェクト。

重要なのは、このイベントは、ストレージにアクセス可能なすべてのウィンドウオブジェクトで発生するということだ(イベントを発生させたオブジェクト自身以外で)。

ウィンドウが二つあり、同じサイトであるとする。そのため、localStorage はそれらの間で共有される。(本書のコードをテストするために、このページを二つのブラウザウィンドウで開くといいだろう)

両方のウィンドウが window.onstorage を listen していれば、それぞれのウィンドウはもう一方のウィンドウで発生した更新に反応する。

イベントは event.url として、データが更新されたドキュメントの URL を含むことに注意。イベントは sessionStoragelocalStorage の両方に対して同じなので、event.storageArea は変更された方を参照する。変更に「応答」するために、そこに何かを設定し直したいと思うこともあるかもしれない。

これにより、同じオリジンからの異なるウィンドウでメッセージを交換できる。

最近のブラウザーは Broadcast channel API という同一生成元ウィンドウ間通信のための特別な API も対応しており、こちらはより充実した機能を備えているが、あまり充実していない。localStorage を基本にした、この API を polyfill するライブラリーがあり、どこでも利用できるようになっている。

Tasks

Autosave a form field

変更するたびにその値を自動保存するテキストエリア欄を作れ。利用者が誤ってページを閉じてしまい、再び開いたときに、未完成の入力が所定の位置にあるようにしろ。

学習者ノート

Clear ボタンの存在がヒントになっている。onclick でもストレージを更新する必要がある。「変更するたびに」をチェックするイベントハンドラーは oninput に仕込む。

IndexedDB

<https://javascript.info/indexeddb> ノート。

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

  • ほとんどの種類の値をキーで保存でき、キーの型は複数対応。

  • 信頼性の高いトランザクションをサポート。

  • キーレンジクエリー、インデックスをサポート。

  • localstorage よりはるかに大きなデータ量を保存できる。

その力は、従来のクライアントサーバーアプリケーションでは過剰だ。IndexedDB はオフラインのアプリケーションを想定しており、ServiceWorkers や他の技術と組み合わせることを想定している。

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

また、<https://github.com/jakearchibald/idb> のような Promise ベースのラッパーの助けを借りて、async/await を利用することもできる。これはかなり便利だが、ラッパーは完璧ではなく、すべての状況でイベントを置き換えることはできない。そこで、まずはイベントから始めて、IndexedDB を理解した後に、ラッパーを使うことにする。


技術的には、データは通常、ブラウザーの設定や拡張機能などとともに、訪問者のホームディレクトリーに保存される。ブラウザーや OS レベルの利用者によって、それぞれ独立した格納領域を持っている。

Open database

IndexedDB を使い始めるには、まずデータベースを開く(接続する)。

let openRequest = indexedDB.open(name, version);
  • name: データベースの名前を示す文字列

  • version: 正の数で示されるバージョン値

異なる名前のデータベースを多数持つことができるが、それらはすべて現在のオリジン内に存在する。異なるウェブサイトが互いのデータベースにアクセスすることはできない。

この呼び出しが返すオブジェクトを openRequest とする。そのイベントをなるべく listen する。

  • success: データベースが準備できた。データベースオブジェクト openRequest.result を今後の呼び出しに使用する。

  • error: 接続失敗。

  • upgradeneeded: データベースの準備はできているが、バージョンが古い。

IndexedDB には、サーバーサイドデータベースにはない「スキーマのバージョン管理」という機構が組み込まれている。サーバーサイドのデータベースとは異なり、IndexedDB はクライアントサイドで、データはブラウザーに保存されるため、開発者はそれにフルタイムでアクセスすることができない。そのため、私たちがアプリケーションの新バージョンを公開し、利用者が私たちのウェブページにアクセスしたとき、データベースを更新する必要が生じることがある。ローカルのデータベースのバージョンが open() で指定されたものより小さい場合、特別なイベント upgradeneeded が発生し、必要に応じてバージョンを比較し、データ構造をアップグレードすることができる。

イベント upgradeneeded は、データベースがまだ存在しない(バージョンが 0 である)場合にも起こされるので、初期化を実行できる。例えば、アプリケーションの最初のバージョンを公開したとする。そして、バージョン 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;
    // ...
};

そして後日、バージョン 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) {
    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 が起動し、データベースは正常に開かれたとみなされる。

データベースを削除するには次のようにする:

indexedDB.deleteDatabase(name);

古い open() 呼び出しバージョンを使ってデータベースを開くことはできない。現在のユーザーデータベースのバージョンが open() 呼び出しのものより新しい場合、例えば既存の DB のバージョンが 3 で open(..., 2) をしようとすると、失敗して openRequest.onerror が発動する。

レアケースだが、例えば代理キャッシュから古い JavaScript コードを読み込んだ場合、このようなことが起こり得る。つまり、コードは古くても、データベースは新しいということだ。

エラーから守るために、db.version をチェックし、ページの再読み込みを提案する必要がある。古いコードを読み込まないように、適切な HTTP キャッシュヘッダーを使用すれば、このような問題が発生することはないだろう。

Parallel update problem

バージョン管理について話しながら、関連する小さな問題に取り組もう。例えば、次のような場合だ:

  1. ある訪問者がブラウザーのタブでデータベースのバージョン 1 のサイトを開いたとする。

  2. その後、アップデートが行われ、コードが新しくなった。

  3. そして、同じ訪問者が別のタブでこのサイトを開く。

つまり、DB バージョン 1 への接続を開いているタブがあり、別のタブはその upgradeneeded ハンドラーでバージョン 2 に更新しようとする状況だ。

問題は、同じサイト、同じオリジンなので、データベースが両方のタブで共有されていることだ。そして、データベースはバージョン 1 と 2 の両方であることはできない。バージョン 2 への更新を実行するには、最初のタブの接続も含めて、バージョン 1 への接続をすべて閉じなければならない。

それを整理するために、イベント versionchange は古くなったほうのデータベースオブジェクト上で引き起こる。私たちはそれを listen して、古いデータベース接続を閉じなければならない。そしておそらく、更新されたコードを読み込むために、ページの再読み込みを利用者に促す。

もし、イベント versionchange を listen せず、古い接続を閉じないのであれば、二回目の新しい接続は行われないでしょう。オブジェクト openRequestsuccess ではなく、イベント blocked を発生させる。そのため、二つ目タブは機能しない。

以下は、並列更新を正しく処理するためのコードだ。onversionchange ハンドラーを導入し、現在のデータベース接続が古くなった場合(他の場所で DB バージョンが更新された場合)に引き起こして、接続を閉じる。

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

// Implement appropriately.
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() {
    // As described in the book.
};

言い換えれば、ここでは二つのことを行っている:

  1. db.onversionchange は、現在のデータベースのバージョンが古くなった場合、並行して行われる更新を通知する。

  2. openRequest.onblocked は、その逆の状況、つまり、他の場所に古いバージョンへの接続があり、それが閉じないため、新しい接続ができないことを通知する。

db.onversionchange では、より優雅に処理し、接続が閉じられる前にデータを保存するように訪問者に促したりすることができる。あるいは、db.onversionchange でデータベースを閉じずに、(新しいタブの)``onblocked`` ハンドラーを使って訪問者に警告し、他のタブを閉じるまで新しいバージョンを読み込むことができないことを伝えるという方法もある。

このような更新の衝突はめったに起こらないが、少なくともスクリプトが黙して死なぬように、onblocked ハンドラーでなるべく何らかの処理をする。

Object store

IndexedDB に何かを保存するには、オブジェクトストアが必要だ。オブジェクトストアは IndexedDB の核となる概念だ。他のデータベースにおけるテーブルまたはコレクションに相当する。データが保存されている場所だ。データベースには、利用者用、商品用など、複数のストアが存在する場合がある。 「オブジェクト」ストアという名前だが、プリミティブも格納できる。

複雑なオブジェクトを含む、ほとんどすべての値を格納することができる。IndexedDB は標準的なシリアライズアルゴリズムを使用して、オブジェクトを複製して保存する。これは JSON.stringify() のようなものだ、より強力で、より多くのデータ型を格納できる。保存できないオブジェクトの例として、循環参照を持つオブジェクトがある。このようなオブジェクトはシリアライズできない。JSON.stringify() もこのようなオブジェクトに対しては失敗する。

ストア内のすべての値に対して、一意のキーが必要だ。キーは、数値、日付、文字列、バイナリー、配列のいずれかでなければならない。これは一意的な識別子なので、キーによって値の検索、削除、更新を行える。

学習者ノート

このオブジェクトストアの概念図に見覚えがないだろうか。

すぐにわかるように、localStorage と同様に、値をストアに追加するときにキーを与えることができる。しかし、オブジェクトを保存する場合、IndexedDB ではオブジェクトのプロパティーをキーとして設定することができ、より便利だ。あるいは、キーを自動生成することもできる。しかし、まずはオブジェクトストアを作成する必要がある。

db.createObjectStore(name[, keyOptions]);

この操作は同期的であり、await は無用であることに注意。

  • name: ストアの名前

  • keyOptions

    • keyPath: IndexedDB がキーとして使用するオブジェクトプロパティーへのパス。

    • autoIncrement: もし true なら、新しく保存されるオブジェクトのキーは、増加し続ける数値として自動的に生成される。

もし keyOptions を指定しないなら、オブジェクトを保存する際に、後でキーを明示的に指定する必要がある。例えば、このオブジェクトストアでは、キーとして id プロパティーを用いる:

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

オブジェクトストアの作成も変更も、DB のバージョンを更新しながら upgradeneeded ハンドラーでしか行えない。

これは技術的な制限だ。ハンドラーの外ではデータの追加、削除、更新ができるようになるが、オブジェクトストアはバージョン更新中にしか作成、削除、変更することができない。

データベースのバージョンをアップグレードするには、主な方法が二つある。

  1. バージョン 1 から 2 へ、2 から 3 へ、3 から 4 へなど、バージョンごとのアップグレード機能を実装することができる。upgradeneeded でバージョンを比較し、中間バージョンごとに段階的にバージョンごとのアップグレードが可能だ。

  2. あるいは、データベースを調べることもできる。既存のオブジェクトストアの一覧を db.objectStoreNames として得る。このオブジェクトは DOMStringList 型で、メソッド contains(name) で存在するかどうかをチェックできる。そして、何が存在して、何が存在しないかによって、更新できる。

小規模なデータベースでは、後者の方法がより単純かもしれない。デモ:

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');

Transactions

トランザクションという用語は一般的なもので、多くの種類のデータベースで用いられている。トランザクションはオールオアナッシングである一連の操作だ。例えば、ある人が何かを買うと、次のような処理が必要だ:

  1. 彼の口座から代金ぶんの金額を差し引く。

  2. 品物を彼の持ち物に追加する。

もし、操作 1 を完了させた後、消灯などの理由で操作 2 に失敗したらかなりまずい。両方とも成功するか、両方とも失敗するかでなければならない。失敗しても、少なくとも彼には所持金が維持されているので、再施行できる。トランザクションはそれを保証する。

IndexedDB では、すべてのデータ操作はトランザクション内で行う必要がある。トランザクションを開始するには:

db.transaction(store[, type]);
  • store: トランザクションがアクセスしようとするストア名。複数のストアにアクセスする場合は、ストア名の配列。

  • type: トランザクション種類。次のいずれか:

    • readonly: 読み取りのみ(既定値)

    • readwrite: 読み書きのみで、オブジェクトストアの作成、削除、変更は不能。

また、versionchange トランザクション型もある。このようなトランザクションは、何でもできるが、手動で作成できない。 IndexedDB はデータベースに接続する際に、アップグレードが必要なハンドラーのために、versionchange トランザクションを自動的に作成する。そのため、データベースの構造を更新したり、オブジェクトストアを作成したり削除したりすることができる単一の場所となる。

トランザクションに読み取り専用と読み取り書き込みのラベルを付ける理由は、性能にある。多くの読み取り専用トランザクションは、同じストアに同時にアクセスできるが、読み書きトランザクションはそうはできない。読み書きトランザクションは、書き込みのためにストアをロックする。次のトランザクションは、同じストアにアクセスする前に、前のトランザクションが終了するのを待たねばならない。

トランザクションが作成されたら、ストアに商品を追加できる:

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)

// ... (4)

ここに段階が四つある:

  1. トランザクションを作成し、アクセスするすべてのストアを指定する。

  2. トランザクションを使用してストアオブジェクトを得る。

  3. オブジェクトストアへの要求 books.add(book) を実行する。

  4. 要求の成功・失敗を処理し、必要であれば他の要求などもできる。

オブジェクトストアには、値を格納するためのメソッドが二つある。

  • put(value, [key]): ストアに value を追加する。key は、オブジェクトストアが keyPath または autoIncrement オプションを持っていない場合に限って与えられる。同じ key である値がすでにあれば、それは置き換えられる。

  • add(value, [key]): put() と同じだ、同じ key である値がすでにある場合、要求は失敗し、"ConstraintError" というエラーが発生する。

データベースを開くのと同様に、books.add(book) という要求を送信し、イベント success/failure を待機できる。

  • add() に対する request.result は新しいオブジェクトのキーだ。

  • もしあれば、エラーは request.error で参照できる。

Transactions’ autocommit

上の例では、トランザクションを開始し、要求を追加した。しかし、トランザクションには関連する要求が複数あり、それらはすべて成功するか、すべて失敗するかのどちらかでなければならないと述べた。では、トランザクションを終了することを、これ以上要求ないことを示すにはどうすればよいだろうか。

簡単な答えは「しない」。仕様の次のバージョン 3.0 では、トランザクションを明示的に終了する方法があるだろうが、今の 2.0 ではそれがない。すべてのトランザクション要求が終了し、マイクロタスクキューが空になると、自動的にコミットされる。

通常、トランザクションはそのすべての要求が完了し、現在のコードが終了したときにコミットされると考えられる。したがって、上記の例では、トランザクションを終了するための特別な呼び出しが必要ない。

トランザクションの自動コミット原則には、重要な副作用がある。トランザクションの途中で fetch()setTimeout() のような非同期操作を挿入することはできない。 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 トランザクションがストアを書き込み用にロックすることだ。つまり、もしアプリケーションのある部分が book オブジェクトストアに対して readwrite を開始したら、同じことをしたい他の部分は待たなければならない。新しいトランザクションは、最初のものが完了するまで固まっているのだ。これは、トランザクションが長い時間かかる場合、奇妙な遅延につながる可能性がある。

では、どうすればいいのか。上の例では、新しい要求の直前に新しい db.transaction を作ることができる (*)。しかし、IndexedDB トランザクションと他の非同期処理を分割して、一つのトランザクションでまとめて処理したい場合は、もっと良い方法だろう。

まず、fetch() を行い、必要ならデータを準備し、その後、トランザクションを作成し、すべてのデータベース要求を実行する。それからそれが動作する。

成功の瞬間を検出するには、イベント transaction.oncomplete を listen すればよい。

トランザクションが全体として保存されることを保証するのはイベント complete しかない。個々の要求は成功するかもしれないが、最終的な書き込み操作は I/O エラーなどでうまくいかないかもしれない。

手動でトランザクションを中止するには、次のようにする:

transaction.abort();

これにより、トランザクション中の要求が行ったすべての変更が取り消され、イベント transaction.onabort が引き起こされる。

Error handling

書き込み要求は失敗するかもしれない。これは予想されることで、我々の過失の可能性だけでなく、トランザクション自体に関連しない理由によることもある。したがって、そのような場合に対処できるように準備しておく必要がある。

失敗した要求は自動的にトランザクションを中止し、すべての変更を取り消す。

状況によっては、既存の変更を取り消すことなく、失敗を処理してトランザクションを続行したいと思うかもしれない。ハンドラー request.onerrorevent.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) {
    if (request.error.name == "ConstraintError") {
        // ... 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);
};

Event delegation

すべての要求に対して onerror/onsuccess を毎回のように設ける必要はなく、代わりにイベント委譲を使えばいい。

IndexedDB のイベントは request, transaction, database の順に bubble する。

イベントはすべて DOM イベントであり、捕捉と bubbling を行うが、通常は bubbling 段階しか用いられない。そのため、ハンドラー db.onerror を使ってすべてのエラーを捕捉し、報告やその他の目的に利用できる。

db.onerror = function(event) {
    let request = event.target; // the request that caused the error
    console.log("Error", request.error);
};

しかし、エラーが完全に処理された場合は報告したくない。request.onerror の中で event.stopPropagation() を使うことで、bubbling を停止して 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
    }
};

学習者ノート

どうも ConstraintError が生じる仕組みを理解しておく必要がありそうだ。

Searching

オブジェクトストアでの検索には、類型が主に二つある:

  1. キー値またはキー範囲による検索。books ストレージでは book.id の値または値の範囲だ。

  2. book.price など、別のオブジェクトフィールドによる検索。これには、”index”という名前の追加的データ構造が必要だ。

By key

まず、検索の最初の類型であるキーによる検索を扱う。

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

次の呼び出しでオブジェクト IDBKeyRange を生成する:

  • IDBKeyRange.lowerBound(lower, [open])

  • IDBKeyRange.upperBound(upper, [open])

  • IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen])

  • IDBKeyRange.only(key)

学習者ノート

メソッドの意味と引数の意味は、オプション引数が Boolean であることさえわかれば、残りは直観的理解でかまわないだろう。

実際の検索を行うには以下のメソッドがある。これらのメソッドでは引数 query に完全一致のキーか、キー範囲を指定する:

  • store.get(query): 最初の値をキーまたは範囲指定で検索する。

  • store.getAll([query], [count]): 値をすべて検索し、与えられた場合は count で制限する。

  • store.getKey(query): 問い合わせ(通常は範囲)を満たす最初のキーを検索する。

  • store.getAllKeys([query], [count]): 問い合わせ(通常は範囲)を満たすキーを全てを、与えられた場合は count までを検索する。

  • store.count([query]): 問い合わせ(通常は範囲)を満たすキーの総数を得る。

オブジェクトストアは常にソートされている。オブジェクトストアは内部的にキーで値をソートする。そのため、多くの値を返す要求では、常にキーでソートされた状態で値が返される。

By a field using an index

他のオブジェクトフィールドで検索するには、インデックスというデータ構造を追加的に作成する必要がある。インデックスは、与えられたオブジェクトフィールドを追跡するストアの追加機能だ。そのフィールドの値それぞれに対して、その値を持つオブジェクトのキーのリストを格納する。

objectStore.createIndex(name, keyPath, [options]);
  • name: 作成するインデックスの名前。

  • keyPath: インデックスが跡を追うべきオブジェクトフィールドを指すパス。このフィールドで検索することになる。

  • option: 次のプロパティーからなるオプショナルなオブジェクト。

    • unique: 値が true の場合、keyPath に指定した値を持つオブジェクトはストアにひとつしかないかもしれない。重複したものを追加しようとすると、インデックスがエラーを発生させるようにする。

    • multiEntry: keyPath の値が配列である場合に限り用いられる。この場合、既定では、インデックスが配列全体をキーとして扱う。しかし、もし multiEntrytrue ならば、インデックスはその配列の要素それぞれに対してストアオブジェクトのリストを保持する。つまり、配列の要素がインデックスのキーになる。

本書よりも先にコードを示す:

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');
};

この例では、キーは id であり、本を保存する。ここで、price で検索したいとする。まず、インデックスを作成する必要がある。オブジェクトストアと同様に upgradeneed ハンドラーで行う。

  • インデックスはフィールド price を追跡する。

  • フィールド price は一意的ではなく、同じ価格の本が複数存在する可能性があるので、オプション unique は設定しない。

  • フィールド price は配列ではないので、フラグ multiEntry も指定しない。

ここで在庫に本が四冊あるとする。index が何であるかを示すとこうなる:

price

list

3

['html']

5

['css']

10

['js', 'nodejs']

このように、createIndex() 呼び出しの price の値ごとのインデックスには、その price を持つ key のリストが保持される。このインデックスは自動的に更新される。

ある価格を検索したいときは、同じ検索方法をインデックスに適用するだけでよい:

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");
    }
};

学習者ノート

先に books.createIndex() を済ませているので books.index() でそれを得られる。

IDBKeyRange を用いて安い本/高い本を探すこともできる。次の例では price が 5 またはそれ未満の本を検索する:

let request = priceIndex.getAll(IDBKeyRange.upperBound(5));

インデックスは、内部的には追跡されたオブジェクトのフィールドでソートされている。この場合、検索を行うと結果も price によってソートされる。

Deleting from store

メソッド delete() は、問い合わせによって削除する値を検索するもので、呼び出し形式は getAll() に似ている。

  • delete(query): 問い合わせにマッチする値を削除する。

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

もし price や他のオブジェクトフィールドに基づいて本を削除したいのであれば、まずインデックスでキーを見つけ、それから delete() を呼び出す必要がある。

let request = priceIndex.getKey(5);

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

全削除をするにはメソッド clear() を呼ぶ:

books.clear();

Cursors

メソッド getAll(), getAllKeys() などは配列を返すが、場合によってはメモリーに収まらないほど巨大な結果となり得る。その場合にはメソッド呼び出しは失敗する。この事態を回避するためにカーソルという手段が用意されている。

カーソルは特別なオブジェクトであり、問い合わせを与えるとオブジェクト格納域を走査し、一度に一つのキーや値を返すため、メモリーを節約することができる。オブジェクトストアは内部的にキーでソートされているので、カーソルはキー順に走査する。未指定時は昇順。

let request = store.openCursor(query, [direction]);
  • querygetAll() と同様にキーまたはキー範囲だ。

  • direction はオプションナル引数で、どの順番で使用するかを指定する:

    • "next": 既定値で、カーソルは最も低いキーを持つレコードから上に向かって歩く。

    • "prev": 逆順。キーが大きいレコードから下へ向かって歩く。

    • "nextunique", "prevunique": 上記それぞれと同じだ、同じキーを持つレコードを飛ばす。例えば price=5 の複数の本に対しては最初の一冊のみが返される。

カーソル処理では request.onsuccess が各結果に対して一度ずつ、複数回引き起こされる。

主なカーソルメソッドは以下の通り:

  • advance(count): カーソルを count 回進め、値を飛ばす。

  • continue([key]): 範囲一致の次の値か、キーが指定されている場合はキーの直後にカーソルを進める。

カーソルに一致する値がさらにあるかどうかにかかわらず onsuccess が呼び出され、その結果、カーソルが次のレコードを指すか、undefined であるかになる。

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");
    }
};

上記の例では、オブジェクトストアに対してカーソルを作成したが、インデックスに対するカーソルを作成することもできまる。インデックス上のカーソルは、オブジェクトストア上のカーソルと全く同じように、一度に値を一つ返すことでメモリーを節約する。インデックスに対するカーソルでは、cursor.key はインデックスキーであり、オブジェクトキーには cursor.primaryKey プロパティーを用いる必要がある。

コード略。

Promise wrapper

すべての要求に 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);
}

学習者ノート

ラッパーの実装を見ないと何も言えない。

Error handling

もし、エラーを捕捉しなければ、最も近くに囲まれている trycatch まで落ちる。捕捉されなかったエラーは、オブジェクト window の “unhandled promise rejection” イベントになる。このようなエラーは、次のように処理できる。

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...
});

“Inactive transaction” pitfall

トランザクションはブラウザーが現在のコードとマイクロタスクを終了するとすぐに自動コミットされる。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() は “inactive transaction” エラーで失敗する。その時点ではトランザクションがすでにコミットされ閉じているからだ。回避策は、ネイティブの IndexedDB で作業するときと同じだ。新しいトランザクションを作成するか、物事を分割することだ。

  1. まずデータを準備し、必要なものをすべて取得する。

  2. データベースに保存する。

Getting native objects

内部的には、ラッパーはネイティブ IndexedDB 要求を実行し、それに onerror/onsuccess を追加し、その結果で拒否か解決をする Promise を返す。

ほとんどの場合、これで問題なく動作する。<https://github.com/jakearchibald/idb>

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