[HTML5] IndexedDBでデータの保存や読み込みを行う - Dexie.js編

JavaScriptがブラウザ内にデータを保存する場合、ちょっとした物であればWebStorageが簡単に使えて便利なのですが、この子は5〜10Mbyte程度の容量しかありません。また純粋なKVSであるがためにそれ以上のことはもちろんできません。

そんな時に利用するのがIndexedDB。WebStorageと比べ非常に大容量で、RDBで言うインデックスやテーブルなど様々な機能が搭載されています。今回はIndexedDBのお話……をしようかと思ったのですが、直接IndexedDBを触るコードは書いていて正直ダルいので、便利ライブラリを通して触ることにしますw

IndexedDBとは

IndexedDBもWebStorageと同様にKVS(Key-Value Store)の一種ですが、こちらは値に「オブジェクト」をつっこむことが可能です。つまり1つのキーに対して複数の項目を持つことが可能というわけです。

また次のような特徴があります。

ディスク容量
ユーザー環境に依存しますが、FireFoxの場合は10M〜2GByteとWebStorageと比較すると遥かに大容量の領域が利用できます。
データ型
論理値、数値、文字列、date、オブジェクト、配列、正規表現、undefined、nullなどJavaScriptで扱える様々なデータ型を扱えます。blobも扱えますのでファイルをそのまま保存しておくことも可能です。
トランザクション
例えば複数のデータを書き込もうとした際に、2回目で失敗したら1回目の操作も無かったことにすることが可能です。更新中の不完全なデータを読み込むこともありません。
オブジェクトストア
RDBで言うところのテーブルです。用途に応じて複数の空間を持つことができます。
インデックス/カーソル
IndexedDBでは値に複数の項目を持てるわけですが、この項目にインデックスを張ることにより、キーを知らないデータもまとめて取得することができます。例えば「名簿」のデータから年齢が20歳以上の人を抜き出すなんてこともできます。

WebStorageとの比較

基本的にIndexedDBの方が機能的には優れているのですが、その分最初の学習コストが非常に高く初学者には難しい内容となっています。それに伴いコード量も多くなります。

ポイントWebStorageIndexedDB
ディスク容量5〜10M10M以上
データ型文字列のみ様々なデータ型に対応
テーブルなしあり(オブジェクトストア)
トランザクションなしあり
インデックスキーのみキー以外の項目にも貼れる(複数可)
アクセス範囲ドメイン(オリジン)単位ドメイン(オリジン)単位
学習コスト少ない多い
コード量少ない多い
  • 「テーブル」はRDBで言うテーブルをイメージしてください。用途ごとに保存領域を分けることができるかということを意図しています。

ライブラリ比較

IndexedDBさんはいわゆるlow-level APIであるため、魅力的な機能が豊富に用意されていますが、WebStorageと比べるとコード量が多くなる傾向にあります。そのためそのまま触るのではなく何らかのライブラリを通した方が幸せになれそうです。

MDNでオススメされているのは以下の4つのライブラリ。

名前 GitHub スター数 ファイル容量 備考
localForage URL 17.6k 28.8k Mozilla製。Indexed,WebStorage,WebSQLを環境に合わせて自動切り替え。操作はKVS的。
Dexie.js URL 5.5k 70.8k IndexedDBのラッパー。わかりやすい。
ZangoDB URL 879 165k MongoDB的なI/F
JsStore URL 359 10.2k SQL的なI/Fもあり

スター数だけで比較するとlocalForageかDexie.jsの2択になりそうですが、ZangoDBやJsStoreも機能的な特徴で惹かれますね。JsStoreはファイル容量が驚くほど少ないのも魅力的。ちなみにGitHubを見る限りではコードのメンテナンスはどれも継続して行われているようです。(いずれも執筆時点での情報です)

シンプルさとファイル容量からlocalForageも捨てがたいのですが、WebStorageのインターフェースがベースとなっているようなので、今回はIndexedDBの機能を一通り使いたいため、Dexie.jsを採用します。

Dexie.jsからIndexedDBをさわる

HTMLの準備

Dexie.jsはCDNにアップされているので、scriptタグでそのまま読み込むことが可能です。バージョン情報3.0.2の部分をlatestに置き換えると常に最新バージョンが読み込まれます。ダウンロードして自分のサイトに置きたい場合はCDNに上がっているファイルを直接保存します。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>IndexedDB + Dexi.js Sample</title>
</head>
<body>

<script src="https://unpkg.com/dexie@3.0.2/dist/dexie.min.js"></script>
<script>
  // ここにコードを書く
</script>
</body>
</html>

npmからもインストール可能です。

$ npm install dexie

これ以降のコードはJavaScriptのみを掲載しますが、先ほどのHTMLのscriptタグ内にあると思ってご覧ください。

データベースを準備する

ここではsocialという名前のデータベースを作成し、friendsという名前のオブジェクトストア(テーブル)を準備しました。

const db = new Dexie("SocialDB");

db.version(1)
  .stores({
    friends: "name,age"  // 一番最初に来るnameがキー。ageがインデックス。
  });

それぞれのメソッドの意味は次のようになります。

new Dexie()
渡された文字列でIndexedDBの「データベース」を準備します。存在しなければ新しく作成され、最終的にDexieクラスのインスタンスを返却します。
version()
IndexedDBはデータベースにバージョン番号を付与できます。要はカラムを変更したい場合などにバージョンを上げることで特定の処理が実行されるよう仕込んでおくことができます。
stores()
IndexedDBのオブジェクトストア(テーブル)を準備します。存在しなければ新たに作成されます。ここではfriendsという名前のオブジェクトストアを作成していますが、"name,age"とある箇所はインデックスを意味します。SQLCREATE TABLEのようにテーブルの構成要素を全て記述するわけではありません。

現在の状態を図にすると以下のようになります。

シンプルなCRUD

データを挿入/更新する

先ほど準備したfriendsに対してデータを追加します。キーも含めた連想配列put()に渡すだけです。基本的にIndexedDBは非同期処理となるため最終的にPromiseが返されます。

db.friends
    // データ挿入(Promiseが返る)
    .put({
      name: "佐藤", age: 20, hobby:"川下り"
    })
    // エラー処理
    .catch((error)=>{
      console.error(error);
    });

今回はnameがキーとなっていますので、もし佐藤さんがすでに存在していた場合は値が上書きされます。もし意図せず上書きをしたくない場合はput()add()に置き換えて実行します。

データを取得する

データを取得したい場合にはget()にキーを渡します。これもPromiseが返されるため実際のデータはthen()で受け取った上で処理するか、第2引数にコールバック関数を渡すこともできます。

db.friends.get("佐藤")    // データを取得(Promiseが返る)
    // データ処理
    .then((friend)=>{
      console.log(friend.hobby);
    })
    // エラー処理
    .catch((error)=>{
      console.error(error);
    });

もしもキーが存在しない場合はundefinedが返却されます。

データを削除する

データを削除する場合にはdelete()にキーを渡します。Promiseが返されるのも同様です。

db.friends.delete("佐藤")   // データを削除(Promiseが返る)
  // エラー処理
  .catch((error)=>{
    console.error(error);
  });

キーが存在せず削除できなかった場合は特にエラーにはなりません(catchは実行されない)。

データをまとめて操作する

for文などでグルグル回しても良いのですが、bulkシリーズを利用すれば一気に処理を行うことができます。

まとめて追加

bulkPut()でまとめて追加できます。

db.friends.bulkPut([
    {name:"本田", age:20, hobby:"ロボット"},
    {name:"山葉", age:21, hobby:"ピアノ演奏"},
    {name:"川崎", age:22, hobby:"航空機"},
    {name:"鈴木", age:23, hobby:"東京タワー"}
  ])
  .catch((error)=>{
    console.log(error);
  });

まとめて取得

bulkGet()に取得したいキーを配列で渡すことでまとめて取得できます。結果も配列として取り出せるので順番はbulkGet()で指定した通りに並んできます。存在しないキーを指定した場合はundefinedが返されます。

db.friends.bulkGet(["川崎", "本田"])
  .then((friends)=>{
    friends.forEach( (friend)=>{
      if( friends !== undefined ){
        console.log(friend.name, friend.hobby);
      }
    });
  })
  .catch((error)=>{
    console.log(error);
  });

まとめて削除

bulkDelete()に削除したいキーを配列で渡せば一括削除できます。存在しないキーを指定した場合でもエラーにはなりません(catchは実行されません)

db.friends.bulkDelete(["山葉", "鈴木"])
  .catch((error)=>{
    console.log(error);
  });

ブラウザの開発者ツールから確認する

WebStorageと同様にブラウザの開発者ツールからIndexedDBの値を確認することができます。GoogleChromeの場合はデータの閲覧と削除は可能ですが、編集は出来ないようですね。

なおIndexedDBの値をJavaScriptからデータを更新しても、自動で開発者ツールの内容は書き換わりません。そのためデータベース名のあたりで右クリックし「Refresh IndexedDB」を実行する必要があります。

Dexie.jsでインデックスを張る

冒頭で取り上げた通り、データベースを開いた後に、stores()を利用しオブジェクトストアに主キーやインデックスを張ることができます。

主キー

KVSのキーに当たるものを「主キー」と呼びます。これはstores()でオブジェクトストア(テーブル)を定義する際に文字列の一番最初に来る項目名がこれにあたります。

通常KVSでの主キーはこちらで準備する必要があるわけですが、Dexie.jsはIndexedDBの機能を利用しオートインクリメントの設定が可能です。

db.version(1)
  .stores({
    friends1: "name",  // 一番最初に来るのが「主キー」
    friends2: "++id",  // 主キーの名前の前に「++」を付けるとオートインクリメント
    friends3: "++",    // 「++」だけでもオートインクリメントになる
  });

2種類のオートインクリメントが用意されていますが、違いは値に主キーの値が含まれるかどうかです。

(async ()=>{
  await db.friends2.put({name:"山田", age:20, hobby:"登山"});  // "++id"
  await db.friends3.put({name:"山田", age:20, hobby:"登山"});  // "++"

  const data2 = await db.friends2.get(1);  // "++id" 
  const data3 = await db.friends3.get(1);  // "++"
  console.log(data2, data3);
})()

実行結果は以下の通りで++idの方は返却された値にid: 1が含まれていますが、++の方にはありません。

{name: "山田", age: 20, hobby: "登山", id: 1}
{name: "山田", age: 20, hobby: "登山"}

インデックス

RDBのように値の方にもインデックスを張ることができます。主キーと同様に項目名の前に記号を付けると特別な意味を持ちます。1つのオブジェクトストア(テーブル)に対して、複数同時に利用することもできます。

db.version(1)
  .stores({
    friends1: "++,nickname",      // 2番目以降に書くとその項目がインデックス化されます
    friends2: "++,&nickname",     // "&"を付けるとユニークインデックス
    friends3: "++,*email",        // "*"を付けると配列にインデックス
    friends4: "++,[first+last]",  // ["キー1"+"キー2"]とすると複合インデックス
  });

Dexie.jsでデータを検索する

where()を利用することで、インデックスを張った項目を検索することができます。WebStorageは単純なKVSだったので指定したキーを取り出すだけでしたが、検索できるとデータベース感が出てきますねw

絞り込む

WhereClauseクラスにあるメソッドを使うことで、事前にインデックスが張ってある項目のデータを絞り込むことができます。

完全一致 - equals

例えばこの中から年齢(age)が22歳のレコードだけ取り出すには、以下のようにwhere()に検索対象とする項目を指定し、条件判定を行うメソッドequals()で具体的な条件を指定、最後に検索結果の一覧を操作するメソッドeach()で実際の各データへ処理を行います。

db.friends.where("age")    // 対象となる項目
  .equals(22)              // 条件判定を行うメソッド
  .each((friend)=>{        // 検索結果を操作するメソッド
    console.log(friend);
  })

a〜bの間 - between

先ほどのequals()between()へ変更すると指定した2つの間の中間のレコードを取得できます。最初の値(a)は含む、2番目の値(b)は含まない点に注意が必要です。

db.friends.where("age")    // ageを対象
  .between(21, 23)         // 21以上、23未満
  .each((friend)=>{        // レコード数分繰り返す
    console.log(friend);
  })

a以上とa以下 - aboveOrEqual,belowOrEqual

以上はaboveOrEqual()

db.friends.where("age")    // ageを対象
  .aboveOrEqual(22)        // 22以上
  .each((friend)=>{        // レコード数分繰り返す
    console.log(friend);
  })

以下はbelowOrEqual()

db.friends.where("age")    // ageを対象
  .belowOrEqual(22)        // 22以下
  .each((friend)=>{        // レコード数分繰り返す
    console.log(friend);
  })

aを超えるとa未満 - above,below

aを超える場合はabove()

db.friends.where("age")    // ageを対象
  .above(22)               // 22を超える (22は含まない)
  .each((friend)=>{        // レコード数分繰り返す
    console.log(friend);
  })

a未満はbelow()

db.friends.where("age")    // ageを対象
  .below(22)               // 22未満 (22は含まない)
  .each((friend)=>{        // レコード数分繰り返す
    console.log(friend);
  })

取り出す

WhereClauseクラスのメソッドで絞り込んだデータをCollectionクラスのメソッドで取り出しつつ操作します。

順番に取り出す - each

each()で検索結果のデータを順番に処理できます。

db.friends.where("age")    // ageを対象
  .above(22)               // 22を超える (22は含まない)
  .each((friend)=>{        // レコード数分繰り返す
    console.log(friend);
  })

配列として一括で受け取る - toArray

toArray()で検索結果のデータを配列として一発で受け取れます。

db.friends.where("age")    // ageを対象
  .above(22)               // 22を超える (22は含まない)
  .toArray((friend)=>{     // 検索結果を全て取得(ループしない)
    console.log(friend);
  })

取り出す位置を指定 - offset,limit

offset()で開始位置を、limit()でデータ件数を指定できます。offset()で一番最初の値は0になります。

db.friends.where("age")    // ageを対象
  .aboveOrEqual(20)        // 20以上
  .offset(2)               // 2番目からスタート
  .limit(1)                // 1件だけ取得
  .toArray((friend)=>{     // 検索結果を全て取得(ループしない)
    console.log(friend);
  })

検索結果をカウント - count

count()で検索結果のレコード数を数えることができます。count()はPromiseを返すのでthen()などで結果を受け取ります。

db.friends.where("age")    // ageを対象
  .aboveOrEqual(20)        // 20以上
  .count()                 // 検索結果をカウント
  .then((val)=>{
    console.log(val)
  })

Dexie.jsでソートする

オブジェクトストア - orderBy

オブジェクトストア(テーブル)に対してorderBy()を実行することで、指定したインデックスによる並べ替えを行うことができます。結果はCollectionとして返ってくるのでeach, toArray, offsetなどが利用できます。

db.friends
    .orderBy("age")        // 並べ替え (インデックスを指定)
    .toArray((friend)=>{   // Collectionクラスのメソッドが利用可能
      console.log(friend);
    })

検索結果 - sortBy

sortBy()を検索結果に使用すると、指定したインデックスで並べ替えが行なえます。sortBy()はPromiseを返すためthen()などで結果を受け取ります。

db.friends.where("age")    // ageを対象
  .above(20)               // 20を超える (20は含まない)
  .sortBy("name")          // "name"でソートし直す
  .then((friend)=>{        // ソート結果を全て取得
    console.log(friend);
  })

sortBy()はIndexedDBの機能ではなく、Dexie.jsで実装されているため、パフォーマンス面で期待通りの動きをしない可能性があります。

検索結果を反転する - reverse

reverse()を検索結果に使用すると、現在の順番を逆転することができます。最終的にCollectionを返却します。

db.friends.where("age")    // ageを対象
  .above(20)               // 20を超える (20は含まない)
  .reverse()               // 現在の順番を反転する
  .toArray((friend)=>{     // 検索結果を全て取得
    console.log(friend);
  })

Dexie.jsでトランザクション

トランザクションを利用するにはtransaction()のコールバック関数に処理を記述するだけです。

正常に処理が終了すると自動的に更新処理が確定(コミット)され、transaction()内で何らかの「例外」が発生した場合には自動的に巻き戻し(ロールバック)の処理が行われます。

const db = new Dexie("SocialDB");

db.version(2)  // diaryを追加したのでバージョンをアップ
  .stores({
    friends: "name,age",  // 友達リスト
    diary: "++id,name"    // 日記(新規追加)
  });

//--------------------------
// トランザクション
//--------------------------
db.transaction("rw", db.friends, db.diary, ()=>{
  // firendsにデータ追加
  db.friends.add({name:"綾小路", age:21, hobby:"写真"});

  // diaryにデータ追加
  db.diary.add({name:"綾小路", title:"初めての日記", body:"はじめまして。綾小路です"});
})
.then(()=>{
  // トランザクション正常終了後に処理をしたい場合はここに書く
})
.catch((error)=>{
  console.log(error);
});

transactionの引数の意味はそれぞれ以下の通り。

"rw"
トランザクションのモードを指定します。rwは読み書き、rは読み取り専用モード。
db.friends, db.diary
トランザクションに含めるオブジェクトストアを必要なだけ指定します。配列による指定も可。

なお意図的にロールバックのテストがしたい場合は以下のようにtransaction内でthrowしてやります。実行後にレコードが新たに追加されていないか確認してみましょう。

db.transaction("rw", db.friends, db.diary, ()=>{
  db.friends.add({name:"山田", age:25, hobby:"水泳"});
  db.diary.add({name:"山田", title:"初めての日記", body:"はじめまして。山田です"});

  // 「例外」を意図的に発生させる
  throw "ロールバックのテスト";
})
.catch((error)=>{
  console.log(error);
});

参考ページ