はじめに
以前の記事で localStorage
を使ってTodoリストをブラウザ上で永続化するTodoアプリを作ってみました。
ブラウザ上のストレージには localStorage
の他に、よりデータベースライクな使い方が可能な indexedDB
という機能があります。
今回はこの indexedDB
を使ったTodoアプリに挑戦してみます。基本的には以前のTodoアプリをベースに localStorage
関連の実装を indexedDB
を使う実装に変更していくアプローチで進めます。
しばし、お付き合いのほどお願いします。
出来上がりはこんな感じです!
見た目は localStorage
版と何にも変わりはありません。(すみません)
今回のコードはこちらになります。
GitHub - Shikataramuno/vue-ts-Indexed-DB: Vue.js + Typescript で Indexed Databaseを使ってみる
IndexedDB って何?
まずは、ここから
indexedDB
とは、ブラウザ上でJavaScriptを使って操作する高機能なデータベースのことです。
以前に紹介した localStorage
もブラウザ上でデータの永続化を実現できますが、検索や更新などの機能が乏しく、どちらかというとフラットなファイルを直接読み書きするような使い方でした。
これに比べ、indexedDB
はRDBMSの様な使い方ができるのが魅力です。
同一オリジンポリシーに則っていて同一ドメイン内でのみアクセス可能。キーでインデックス付けされたオブジェクトの保存が可能といった特徴があります。
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
もブラウザ上で中身を確認できます。
コンポーネント&クラス構成
localStorage
版のクラス構成とほぼ同じです。ただし、Todosクラスには indexedDB
に対応するため修正しています。
Singleton
パターンでアプリケーション内で唯一のインスタンスとしている点は localStorage
版と同じです。
主な変更点は、
constructorで localStorage
にアクセスしTodoリストを取得していましたが、indexedDB
は非同期呼び出しなのでconstructorとは別のメソッドを定義して初期化します。別に定義するメソッドで indexedDB
と接続、indexedDB
からtodoリスト読み込みとprivate変数への格納を行う様にします。getTodos
メソッドではprivate変数への参照を外部に公開するのみとします。
その他、CRUDのC,U,DのAPIとしてaddTodo
、updateTodo
、deleteTodo
のメソッドを定義し、これらをFacade
としてDB更新を行うとともに、リスト表示も自動的に更新されるようにtodosの再読み込みを同期して呼び出す構成とします。
さぁ 作ってみましょう
プロジェクトの定義
プロジェクト名を vue-ts-indexed-db
としてvue create
コマンドでプロジェクトを作成します。
作成時のオプションや可視性(publicやらprivateやら)を省略するための設定、 デバッグ文を出力できるようにするための設定などは以下の記事を参考にしてください。
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
操作の実装です。add
、delete
とも基本は同じです。
16行目で ObjectStore
の put
メソッドで key
で指定したオブジェクトに更新をかけています。CRUDでは onsuccess
の代わりにトランザクションの完了イベントとして oncomplete
を受け取ります。(13行目)
出来上がり!
npm run serve
で動かしてみましょう。
localStorage
版と同じ様にTodoリストが永続化できましたでしょうか?
実際にデベロッパーツールで確認してみて、Chromeで再読み込みしてみてください。
todoが永続化できていればOKです。
謝辞
今回は localStorage
との対比として indexedDB
を使ってみました。
Todoアプリでは、あえてデータベースが必要なわけではないので indexedDB
の恩恵は感じられなかったかもしれませんが、
も少しフィルタリングや検索が必要なアプリでは威力を発揮するかもしれませんね。
長々と書きましたが、最後まで読んで下さりありがとうございました。
何かのお役に立てれば幸いです。