しかたらむーの てっくぶろ

組み込みソフトの古参エンジニアがモダンなフロントエンドエンジニアに転生 その進化の記録です

Vue.js + Typescript で IndexedDBを使ったToDoアプリを作ってみる

はじめに

以前の記事で localStorage を使ってTodoリストをブラウザ上で永続化するTodoアプリを作ってみました。 ブラウザ上のストレージには localStorage の他に、よりデータベースライクな使い方が可能な indexedDB という機能があります。

今回はこの indexedDB を使ったTodoアプリに挑戦してみます。基本的には以前のTodoアプリをベースに localStorage 関連の実装を indexedDB を使う実装に変更していくアプローチで進めます。 しばし、お付き合いのほどお願いします。

techblo.shikataramuno.com

出来上がりはこんな感じです!

見た目は localStorage 版と何にも変わりはありません。(すみません)

f:id:Shikataramuno:20190318142714p:plain

今回のコードはこちらになります。
GitHub - Shikataramuno/vue-ts-Indexed-DB: Vue.js + Typescript で Indexed Databaseを使ってみる

IndexedDB って何?

まずは、ここから
indexedDB とは、ブラウザ上でJavaScriptを使って操作する高機能なデータベースのことです。

以前に紹介した localStorage もブラウザ上でデータの永続化を実現できますが、検索や更新などの機能が乏しく、どちらかというとフラットなファイルを直接読み書きするような使い方でした。
これに比べ、indexedDBRDBMSの様な使い方ができるのが魅力です。

同一オリジンポリシーに則っていて同一ドメイン内でのみアクセス可能。キーでインデックス付けされたオブジェクトの保存が可能といった特徴があります。

indexedDB 内には複数のデータベースが定義でき、個々のデータベースに複数の ObjectStore という塊を定義します。この ObjectStore で複数のJavaScriptオブジェクトをキーに関連付けて管理します。RDBMSでいうテーブルがObjectStore に、テーブル内の個々のレコードが ObjectStore 内のJavaScriptオブジェクトに該当するイメージです。

詳しくは
IndexedDB API - Web APIs | MDN
を参照ください。

IndexedDB どう使うの?

ではどの様に使うかですが、概ね、以下のパターンで使う様です。

  • データベースに接続する。
  • データベース上のObjectStoreを作成する。
  • ObjectStoreを指定し、CRUD(Create、Reference、Update、Delete)操作を行う。

CRUD操作を行うためには、接続したデータベースに transaction オブジェクトを生成します。
そして、この transaction オブジェクトに対して ObjectStore を指定してCRUDの操作を実行します。
また、indexedDB に対する操作はすべて非同期なので、コンテキスト制御には注意が必要です。

では、もう少し詳しく、一つ一つの操作を見ていきましょう。

indexedDBと接続する

ブラウザのWindowオブジェクトに定義されている indexedDB オブジェクトの open メソッドを使って接続します。 データベース名とバージョン番号が指定でき、indexedDB 上にデータベースが存在しない場合は新たにデータベースが定義され、バージョン番号に初期値として0がセットされます。

接続が成功すると onsuccess イベントが発火しますので、イベントハンドラでイベントオブジェクトからデータベースへの参照を取得します。以降、この参照を使ってトランザクションを発行します。

open メソッドで現在のバージョン番号と異なるバージョン番号が指定されていれば onsuccess イベントの前に onupgradeneeded イベントハンドラが呼び出されます。

この onupgradeneeded イベントハンドラで新たな ObjectStore を定義するなど、データベースのスキーマを変更することができます。onupgradeneededスキーマを変更した後、onnsuccess イベントが呼び出され新たなスキーマのデータベースへの参照が得られる仕組みになっています。この辺りはSQLiteと少し似てますね。

詳しくは
Using IndexedDB - Web APIs | MDN
を参照ください。

indexedDBにアクセスする

個々のレコードに対するCRUDでは、transaction オブジェクトを経由してadd、get、update(put)、deleteを実行します。
全てのレコードに対し、シーケンシャルにアクセスする場合は cursor を使います。(詳しくは後述)

indexedDBの中身を見てみる

localStorage と同様に indexedDB もブラウザ上で中身を確認できます。

f:id:Shikataramuno:20190627101834p:plain

コンポーネント&クラス構成

localStorage 版のクラス構成とほぼ同じです。ただし、Todosクラスには indexedDB に対応するため修正しています。

f:id:Shikataramuno:20190627101950j:plain

Singleton パターンでアプリケーション内で唯一のインスタンスとしている点は localStorage 版と同じです。

主な変更点は、
constructorで localStorage にアクセスしTodoリストを取得していましたが、indexedDB は非同期呼び出しなのでconstructorとは別のメソッドを定義して初期化します。別に定義するメソッドで indexedDB と接続、indexedDB からtodoリスト読み込みとprivate変数への格納を行う様にします。getTodos メソッドではprivate変数への参照を外部に公開するのみとします。

その他、CRUDのC,U,DのAPIとしてaddTodoupdateTododeleteTodoのメソッドを定義し、これらをFacadeとしてDB更新を行うとともに、リスト表示も自動的に更新されるようにtodosの再読み込みを同期して呼び出す構成とします。

f:id:Shikataramuno:20190627102025j:plain

さぁ 作ってみましょう

プロジェクトの定義

プロジェクト名を vue-ts-indexed-dbとしてvue create コマンドでプロジェクトを作成します。

作成時のオプションや可視性(publicやらprivateやら)を省略するための設定、 デバッグ文を出力できるようにするための設定などは以下の記事を参考にしてください。

techblo.shikataramuno.com

indexedDB を使う上でのポイントについて説明したいと思います。

indexedDBへの接続

indexedDB への接続処理は models/Todos.tsクラスに実装します。

  private dbName: string = 'TodosDb';
  private todosObjectStore: string = 'Todos';
  private db!: IDBDatabase;

  async init(): Promise<any> {
    await this.connectDb();
    this.todos = (await this.retrieveTodos() as Todo[]);
  }
  private async connectDb(): Promise<string> {
    const p: Promise<string> =
     new Promise<string>((resolve: (value?: string) => void, reject: (reason?: any) => void) => {
      const req: IDBOpenDBRequest = window.indexedDB.open(this.dbName, 1);
      req.onsuccess = (ev: Event) => {
        this.db = ((ev.target as IDBOpenDBRequest).result as IDBDatabase);
        resolve('success to open db');
      };
      req.onerror = (ev: Event) => {
        const err = 'fails to open db';
        reject(err);
      };
      req.onupgradeneeded = (ev: Event) => {
        const dbReq: IDBOpenDBRequest = ev.target as IDBOpenDBRequest;
        this.db = dbReq.result as IDBDatabase;
        if (this.db.objectStoreNames.contains(this.todosObjectStore)) {
          this.db.deleteObjectStore(this.todosObjectStore); // Todos ObjectStoreの作成
        }
        this.db.createObjectStore(this.todosObjectStore, {autoIncrement: true});
      };
    });
    return p;
  }

5行目のinitメソッドが初期化のエントリポイントになります。
UI側のTodoList.vueの created ライフサイクルフックでこの init メソッドを呼び出します。

indexedDB への接続を行う実装は9行目からのprivateメソッドである connectDb が該当します。
UI側の処理と同期させるため Promise で同期をとる様にしています。

12行目で indexedDB 上の TodosDb というデータベースを open します。
open メソッドから受け取る IDBOpenDBRequestオブジェクトにイベントハンドラを定義していきます。

13行目の onsuccess イベントハンドラTodosDb への接続が成功した場合に呼び出され、イベントオブジェクトから IDBDatabease を取得します。

17行目の onerror イベントハンドラは接続に失敗した場合に呼び出されます。

21行名の onupgradeneeded イベントハンドラTodosDb のバージョンが12行目の open メソッドの第二引数で指定した値と異なる場合に呼び出されます。

初回は TodosDb が未定義の状態なので open メソッドで TodosDb が定義されバージョン番号は初期値の0がセットされます。open メソッドでバージョン番号に1を設定しているので、todosDb が未定義の場合、必ずこのonupgradeneeded イベントハンドラが呼び出されます。
イベントハンドラでは、イベントオブジェクトから TodosDbへの参照を取得し27行目の createObjectStore でテーブルに該当する ObjectStore Todos を作成しています。

cursorによるJavaScriptオブジェクトの読み込み

次に curosr を使った ObjectStore 内のスキャンです。
Todos.ts クラスの retreiveTodos メソッドで実装しています。

  private async retrieveTodos(): Promise<Todo[]> {
    const p: Promise<Todo[]> =
      new Promise<Todo[]>((resolve: (value?: Todo[]) => void, reject: (reason?: any) => void) => {
      const objStore: IDBObjectStore =
        this.db.transaction(this.todosObjectStore, 'readwrite').objectStore(this.todosObjectStore) ;
      const range: IDBKeyRange = IDBKeyRange.lowerBound(0);
      const cur: IDBRequest<IDBCursorWithValue | null> = objStore.openCursor(range);
      const todos: Todo[] = [];
      cur.onsuccess = (e) => {
        const cursor: IDBCursorWithValue = (e.target as IDBRequest).result as IDBCursorWithValue;
        if (cursor) {
          const data: Todo = cursor.value as Todo;
          const todo: Todo = new Todo(
            Number(cursor.key),
            data.tag,
            data.todo,
            data.complete,
          );
          // console.log(rec)
          todos.push(todo);
          cursor.continue();
        } else {
          resolve(todos);
        }
      };
      cur.onerror = (err) => {
        reject(err);
      };
    });
    return p;
  }

これも非同期で処理されるため、Promise で同期をとるようにしています。ここでは、Todo クラスのリストを戻り値とするため、Promiseジェネリック型を Todo[] としています。

7行目で ObjectStore をスキャンするための Cursor を取得しています。

openCursor が成功すると onsuccess イベントハンドラが呼び出されます。この時、読み出すレコード(すなわちJavaScriptオブジェクト)が存在する場合、イベントオブジェクトに cursor がセットされ、オブジェクトとその key が取得できます。読み出すレコードがなくなると Cursorはセットされないので、これで終端を判断します。

11行目~20行目で Cursor から Todo オブジェクトを取得し配列に push しています。

21行目で次のレコードを取得するため cursor.continue メソッドを呼び出し、次の onsuccess イベントハンドラが呼び出されるのを待ちます。

個別のJavaScriptオブジェクトへのCRUD操作

個別のJavaScriptオブジェクトへのCRUD操作です。今回のアプリでは、C,U,Dの操作を Todos.ts クラスに実装しています。何れの操作も基本的な流れは同じで、key を指定するか否かの違いくらいです。

add 操作は新たにJavaScriptオブジェクトを挿入するので key は不要ですが、update (ObjectSotreのinterfaceではput)や delete では必須ではありませんが key の指定が必要になります。

  async updateTodo(target: Todo): Promise<any> {
    await this.update(target);
    this.todos = (await this.retrieveTodos() as Todo[]);
  }
  private async update(target: Todo): Promise<string> {
    const p: Promise<string> =
    new Promise<string>((resolve: (value?: string) => void, reject: (reason?: any) => void) => {
      const key: IDBValidKey = target.id;
      const tx: IDBTransaction = this.db.transaction(this.todosObjectStore, 'readwrite');
      tx.onerror = (e: Event) => {
        reject(e);
      };
      tx.oncomplete = (e: Event) => {
        resolve('transaction update complete');
      };
      tx.objectStore(this.todosObjectStore).put(target, key);
    });
    return p;
  }

上記は update 操作の実装です。adddelete とも基本は同じです。

16行目で ObjectStoreput メソッドで key で指定したオブジェクトに更新をかけています。CRUDでは onsuccess の代わりにトランザクションの完了イベントとして oncomplete を受け取ります。(13行目)

出来上がり!

npm run serve で動かしてみましょう。 localStorage 版と同じ様にTodoリストが永続化できましたでしょうか? 実際にデベロッパーツールで確認してみて、Chromeで再読み込みしてみてください。 todoが永続化できていればOKです。

謝辞

今回は localStorage との対比として indexedDB を使ってみました。

Todoアプリでは、あえてデータベースが必要なわけではないので indexedDB の恩恵は感じられなかったかもしれませんが、 も少しフィルタリングや検索が必要なアプリでは威力を発揮するかもしれませんね。

長々と書きましたが、最後まで読んで下さりありがとうございました。
何かのお役に立てれば幸いです。