MDNのIndexedDBのページを読んでReactで使ってみた


「Make IT アドベントカレンダー 2018」24日目の記事になります。
今日の担当は @haduki1208 でお送りいたします。

indexedDBに手を付けた理由

Monaca + React でアプリを作る際にSQLiteプラグインが有料プランでしか使えないことを知ってしまい途方に暮れていたところindexedDBに目を付けました。(Monaca + Reactの記事にしようと思っていたんですが進捗が悪いので内容を変えた次第でもあります。)

MDN IndexedDB
MDN IndexedDBを使用する <= この内容を参考にコードを書きました

indexedDBの特徴

  • KVS型データベース
  • 任意のオブジェクト(多種多様なデータ型)を保存できる
  • 非同期に処理を実行する
  • ストレージ容量に上限がある(上限に達した場合、データが削除される)

view部分

 > npx create-react-app indexeddb-app
App.jsx
import React, { Component } from "react";
import "./App.css";
import mydb from "./mydb";

export default class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      rows: [],
      users: [
        { name: "戸山香澄", musicalInstrument: "Gt.&Vo.", nickname: "星のカリスマ" },
        { name: "花園たえ", musicalInstrument: "Gt.", nickname: "兎追いし花園" },
        { name: "牛込りみ", musicalInstrument: "Ba.", nickname: "これでいちコロネ" },
        { name: "山吹沙綾", musicalInstrument: "Dr.", nickname: "発酵少女" },
        { name: "市ヶ谷有咲", musicalInstrument: "Key.", nickname: "甘辛パーソナリティ" }
      ]
    };
    this.renderTable();
  }

  // dbにあるデータを全件表示する。
  async renderTable() {
    const users = await mydb.getAll();
    this.setState({ rows: users });
  }

  // データを1件追加する。
  async addUser(index) {
    const user = Object.assign({}, this.state.users[index]);
    const id = await mydb.add(user);
    user.id = id;
    this.state.rows.push(user);
    this.setState({ rows: this.state.rows });
  }

  // データを1件削除する。
  async deleteUser(key) {
    await mydb.delete(key);
    const rows = this.state.rows.filter(user => user.id !== key);
    this.setState({ rows });
  }

  // 行のDOMを作る。
  renderRow(user) {
    return (
      <tr key={user.id}>
        <td>{user.name}</td>
        <td>{user.musicalInstrument}</td>
        <td>{user.nickname}</td>
        <td><button onClick={() => this.deleteUser(user.id)}>delete</button></td>
      </tr>
    );
  }

  render() {
    return (
      <div>
        <p>
          <button onClick={() => this.addUser(0)}>戸山香澄</button>
          <button onClick={() => this.addUser(1)}>花園たえ</button>
          <button onClick={() => this.addUser(2)}>牛込りみ</button>
          <button onClick={() => this.addUser(3)}>山吹沙綾</button>
          <button onClick={() => this.addUser(4)}>市ヶ谷有咲</button>
        </p>
        <table border="1">
          <thead>
            <tr>
              <td>名前</td>
              <td>楽器</td>
              <td>二つ名</td>
              <td></td>
            </tr>
          </thead>
          <tbody>
            {this.state.rows.map(user => this.renderRow(user))}
          </tbody>
        </table>
      </div>
    );
  }
}

create-react-appで作ったテンプレートに書き込みました。
Object.assignしている理由は同一オブジェクトだとindexedDBにadd(insert)されないことを確認したからです。

次にSELECT文(全件取得)、INSERT文、DELETE文の中身を作っていきます。

※(12/24/12時)renderTableが無駄な処理をしていたのを気づいたので修正しました。

ネイティブコード

mydb.jsx
const db_constants = {
  name: "mydb",
  version: 1,
}
const store_constants = {
  name: "users",
  storeOptions: { keyPath: "id", autoIncrement: true },
  indexes: [
    { indexName: "name", unique: false },
    { indexName: "musicalInstrument", unique: false },
    { indexName: "nickname", unique: false }
  ]
};

// IDBに接続する
const connectIDB = (resolve, reject) => {
  const request = indexedDB.open(db_constants.name, db_constants.version);
  request.onsuccess = event => {
    resolve(event.target.result);
  };
  request.onerror = event => {
    reject(new Error("なぜ私のウェブアプリでIndexedDBを使わせてくれないのですか?!"));
  };
  request.onupgradeneeded = event => {
    const db = event.target.result;
    if (!Array.from(db.objectStoreNames).includes(store_constants.name)) {
      // store"users"が存在しないとき、新規作成する。
      const objectStore = db.createObjectStore(store_constants.name, store_constants.storeOptions);
      for (const index of store_constants.indexes) {
        objectStore.createIndex(index.indexName, index.indexName, { unique: index.unique });
      }
    }
  }
}

class mydb extends Promise {
  // 全件取得する
  getAll() {
    return new Promise((resolve, reject) => {
      this.then(db => {
        const transaction = db.transaction(store_constants.name, "readonly");
        const objectStore = transaction.objectStore(store_constants.name);
        const request = objectStore.getAll();
        request.onsuccess = event => {
          resolve(event.target.result);
        };
      });
    });
  }
  // 1行追加する。成功したとき、idを返す。
  add(user) {
    return new Promise((resolve, reject) => {
      this.then(db => {
        const transaction = db.transaction(store_constants.name, "readwrite");
        const objectStore = transaction.objectStore(store_constants.name);
        const request = objectStore.add(user);
        request.onsuccess = event => {
          resolve(event.target.result);
        };
      });
    });
  }
  // 1行削除する。
  delete(key) {
    return new Promise((resolve, reject) => {
      this.then(db => {
        const transaction = db.transaction(store_constants.name, "readwrite");
        const objectStore = transaction.objectStore(store_constants.name);
        const request = objectStore.delete(key);
        request.onsuccess = event => {
          resolve(event.type);
        };
      });
    });
  }
}

export default new mydb(connectIDB);

indexedDBは非同期なので、import後すぐにgetAllを実行すると「変数dbはnullです。」と怒られる始末。どうにかしてindexedDB.openがonsuccessする(コネクションが張れる)までgetAll、add、deleteが実行されても実行待機状態にしたいと考えた結果、Promiseを継承させるということに。もっときれいにならないものかと苦悩。

Developer_tools > Application > IndexedDB > mydb > users で中身が確認できます。
ボタンを押すとuserがIDBに追加されると思います。

どのコードも似たような感じなので、もう少しまとめられそうな気もします。

ライブラリ(Dexie.js)を使ったコード

注記: IndexedDB API は強力ですが、シンプルな用途にとってはとても複雑に見えるかもしれません。シンプルな API が好ましいのでしたら、IndexedDB をより開発者フレンドリーに扱える localForage や dexie.js 、ZangoDB、PouchDB、JsStore などのライブラリを検討してください。

MDNがおすすめするライブラリを使います。dexie.jsを使ってコードを置き換えます。

> npm i dexie
mydb.jsx
import Dexie from "dexie";

const db_constants = {
  name: "mydb",
  version: 1,
}
const store_constants = {
  name: "users",
  indexes: "++id, name, musicalInstrument, nickname"
};

import Dexie from "dexie";

const db_constants = {
  name: "mydb",
  version: 1,
}
const store_constants = {
  name: "users",
  indexes: "++id, name, musicalInstrument, nickname"
};

class mydb {
  constructor() {
    const db = new Dexie(db_constants.name);
    db.version(db_constants.version).stores({
      [store_constants.name]: store_constants.indexes
    });
    this.db = db;
  }
  // 全件取得する
  getAll() {
    return this.db[store_constants.name].toArray();
  }
  // 1行追加する。成功したとき、idを返す。
  add(user) {
    return this.db[store_constants.name].add(user);
  }
  // 1行削除する。
  delete(key) {
    return this.db[store_constants.name].delete(key);
  }
}

export default new mydb();

コードはmydb.jsxの中身をそのまま入れ替えるだけです。
ブラウザをリロードしたら、Developer_tools > Application > IndexedDB > mydb で「Delete batabase」ボタンを押して、ページを更新してください。エラーなく実行できると思います。

ややこしいプロパティを設定していたstore_constantsは見違えるほどスリムになりました。
transaction, objectStore, request の3連単ともおさらばです。

※(12/24/15時)toArray,add,deleteがPromiseを返すのでわざわざnew Promiseする必要がないことに気づきました。

使ってみた感想

RDBしか使ってこなかった身からするとKVSを見ると食わず嫌いが起きてしまいますが慣れてしまえば、というのが実際ある。Web SQLが廃止になるためブラウザ上ではindexedDBしか使えなくなりますが、RDBライクな書き方にラップしたライブラリもあるため積極的にIDBを使っていきたいと思います。

Make IT アドベントカレンダー 2018もそろそろ終わりを迎えます。
サークルのメンバーでアドベントカレンダーをやってみよう!とのことで始まりましたが「情報を発信することの意義」「人に伝えることの難しさ」が後輩に伝わったのであれば、このアドベントカレンダーは大団円です。
明日は @fumihumi さんが投稿してくださいます。