PWAでのストレージの選択
オフラインでも使えたり、高速な応答性能を持つWebページを作るためには、ディスク/メモリ上からデータを引っ張ってくる必要がある。最近の流れでは、cache API と IndexedDB が有力な選択肢。以下のすみ分けがあり補完的な関係にあるので、PWA等では両方を使うことになる。
- URLで指定されるリソースの格納先: CacheAPI
- 例: 画像・JS・CSS・HTMLファイル
- それ以外のリソースの格納先: IndexedDB
- 例: ユーザ情報(IDやニックネーム)・操作履歴
DBへの接続とスキーマ定義
まずはDBに接続する必要がある。DB名とバージョンを指定して接続して、IDBDatabase
を取得する。1個あれば十分なので、いくつも作らない。
以下のようなやり方で使えるように、connect
関数を実装してみよう。
const db = await connect(DBNAME, VERSION);
DBへの接続方法(というよりIndexedDBのAPI全般)は、ハンドラを設定するという少しめずらしい仕様だ。そこで、これをPromise にするために、全体をnew Promise
でくくる。ハンドラの中で resolve ないし reject をしてあげるのがポイント。
function connect(dbname, version) {
const dbp = new Promise((resolve, reject) => {
const req = window.indexedDB.open(dbname, version);
req.onsuccess = ev => resolve(ev.target.result);
req.onerror = ev => reject('fails to open db');
req.onupgradeneeded = ev => schemeDef(ev.target.result);
});
dbp.then(d => d.onerror = ev => alert("error: " + ev.target.errorCode));
return dbp;
}
DBへの接続時に設定する onupgradeneeded イベントハンドラにおいて、格納データのスキーマ定義、インデックス定義、各種制約を課すことができる(↑でのschemeDef)。ここでは、実際によく使うであろうIDの自動生成機能をつけてみる。
function schemeDef(db) {
db.createObjectStore(DOCNAME, { keyPath: IDNAME, autoIncrement: true });
}
はじめてDBに接続した時や、DBのバージョンを上げた時などにこのロジックが走る。上述の例では、IDNAME で定義したフィールドがIDになり、自動的に1ずつ付与される。
データの投入
DBに接続したら、今度はデータを投入してみよう。前述の通り、IDは自動的に付与されるので投入時には記載しないが、DB投入時に自動採番されるのでその結果を知りたいよね?ということでこんな感じで使えると嬉しい。
const obj = { val: 20 };
const saved = await put(db, obj); // -> { id: 1, val: 20 }
上記の仕様を実装するとこんな感じ。さっきと同じく全体をPromiseで包んであげることで、利用する際はイベントハンドラを設定するという見慣れない方法で実装しないで済むようになる。自動採番の結果は、req.result
で取得できるのがポイント (参考: https://stackoverflow.com/questions/12502830/how-to-return-auto-increment-id-from-objectstore-put-in-an-indexeddb )。
async function put (db, obj) { // returns obj in IDB
return new Promise((resolve, reject) => {
const docs = db.transaction([DOCNAME], 'readwrite').objectStore(DOCNAME);
const req = docs.put(obj);
req.onsuccess = () => resolve({ [IDNAME]: req.result, ...obj });
req.onerror = reject;
});
}
上記の関数は、新規作成(insert) の時だけでなく、すでにIDが割り振られている更新時にもきちんと動作する。なのであまり気にしていないのだが、insert時には IDBObjectStore#add
, 既存のデータを更新する際には IDBObjectStore#put
を使う、という使い分けがあるようだ。
データの取得(ID指定)
IDを指定して格納されたデータを取得してみる。以下のような使い方が出来ればハッピー。
const obj = await get(db, 1) // -> { id: 1, val: 20 }
実装は put とほとんど同じ。指定されたIDが存在しなければ undefined で resolve される。
async function get (db, id) { // NOTE: if not found, resolves with undefined.
return new Promise((resolve, reject) => {
const docs = db.transaction([DOCNAME, ]).objectStore(DOCNAME);
const req = docs.get(id);
req.onsuccess = () => resolve(req.result);
req.onerror = reject;
});
}
データの取得 (クエリ)
IDを指定してピンポイントにデータをとるのではない場合は、カーソルを使う。これも使い方に癖がある。以下は全件とってきて、最後のデータ(最新のデータ)を取得する例だが、それ以外にもいろいろなカーソルの使い方があるので必要に応じて覚えていきたい。
async function load (db) {
return new Promise(async (resolve, reject) => {
const saves = [];
const req = db.transaction([DOCNAME]).objectStore(DOCNAME).openCursor();
const onfinished = () => {
log(`${saves.length} saves found.`);
if (saves.length > 0) {
resolve(saves[saves.length - 1]);
}
};
req.onerror = reject;
req.onsuccess = (ev) => {
const cur = ev.target.result;
if (cur) {
saves.push(cur.value);
cur.continue();
} else {
onfinished();
}
};
});
}
最後に
使い方が結構ややこしいので、直接IndexDBを使うよりも、サードパーティのライブラリを使うのがいいかも。まあ具体的にはDexie.js
。