IndexedDB を使用した UI 要素のデータ バインディング

はじめに

IndexedDB は、クライアントサイドにデータを保存するための強力な方法です。このトピックに関する MDN チュートリアルをまだご覧になっていない場合は、ぜひお読みください。この記事では、API と機能に関する基本的な知識があることを前提としています。IndexedDB を初めて見る方でも、この記事のデモで、IndexedDB でできることを把握していただけると思います。

このデモは、企業向けのシンプルな概念実証のイントラネット アプリケーションです。このアプリでは、社員が他の社員を検索できます。より迅速でスムーズなエクスペリエンスを提供するため、従業員データベースはクライアントのマシンにコピーされ、IndexedDB を使用して保存されます。このデモでは、予測入力形式で単一の従業員レコードを検索して表示するだけですが、このデモの優れている点は、このデータがクライアントで利用できると、他のさまざまな方法でも使用できることです。アプリケーションで行う必要がある処理の概要は次のとおりです。

  1. IndexedDB のインスタンスを設定して初期化する必要があります。ほとんどの場合、これは簡単ですが、Chrome と Firefox の両方で動作させるのは少し難しいです。
  2. データがあるかどうかを確認し、ない場合はダウンロードする必要があります。通常、これは AJAX 呼び出しで実行されます。このデモでは、偽のデータをすばやく生成するためのシンプルなユーティリティ クラスを作成しました。アプリケーションはこのデータを作成しているタイミングを認識し、それまでユーザーがデータを使用できないようにする必要があります。この作業を行うのは 1 回限りです。ユーザーが次回アプリを実行するときに、このプロセスを実行する必要はありません。より高度なデモではクライアントとサーバー間の同期オペレーションを処理しますが、このデモでは UI 面に重点を置いています。
  3. アプリケーションの準備ができたら、jQuery UI のオートコンプリート コントロールを使用して IndexedDB と同期できます。自動入力コントロールでは基本的なリストとデータの配列を使用できますが、任意のデータソースを許可する API も用意されています。これを使用して IndexedDB データに接続する方法を紹介します。

スタートガイド

このデモには複数の部分があるため、まずは HTML 部分を見てみましょう。

<form>
  <p>
    <label for="name">Name:</label> <input id="name" disabled> <span id="status"></span>
    </p>
</form>

<div id="displayEmployee"></div>

そんなに多くないよね。この UI には、Google が重視している主な要素が 3 つあります。1 つ目は、自動入力に使用されるフィールド「name」です。読み込み時に無効になり、後で JavaScript によって有効になります。隣接するスパンは、最初のシード時にユーザーに更新情報を提供するために使用されます。最後に、自動補完から従業員を選択するときに、id が displayEmployee の div が使用されます。

次に、JavaScript について見ていきましょう。内容は盛りだくさんです。順を追って説明します。コード全体は最後に記載されているので、そちらで確認してください。

まず、IndexedDB をサポートするブラウザで、プレフィックスに関する問題がいくつかあります。以下は、Mozilla のドキュメントのコードです。アプリケーションに必要なコア IndexedDB コンポーネントに簡単なエイリアスを指定するように変更されています。

window.indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB;
var IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction;
var IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange;

次に、デモで使用するいくつかのグローバル変数を定義します。

var db;
var template;

次に、jQuery document ready ブロックから開始します。

$(document).ready(function() {
  console.log("Startup...");
  ...
});

このデモでは、Handlebars.js を使用して従業員の詳細を表示します。これは後で使用しますが、今すぐテンプレートをコンパイルして、邪魔にならないようにしておきましょう。スクリプト ブロックが Handlebars で認識されるタイプとして設定されています。あまり凝った方法ではありませんが、動的 HTML を簡単に表示できます。

<h2>, </h2>
Department: <br/>
Email: <a href='mailto:'></a>

次のように、JavaScript で再コンパイルされます。

//Create our template
var source = $("#employeeTemplate").html();
template = Handlebars.compile(source);

それでは、IndexedDB の使用を始めましょう。まず、開きます。

var openRequest = indexedDB.open("employees", 1);

IndexedDB への接続を開くと、データの読み取りと書き込みにアクセスできるようになりますが、その前に、objectStore があることを確認する必要があります。objectStore はデータベース テーブルに似ています。1 つの IndexedDB には複数の objectStore があり、それぞれが関連するオブジェクトのコレクションを保持します。このデモはシンプルで、必要な objectStore は「employee」という 1 つだけです。indexedDB が初めて開かれたとき、またはコードのバージョンを変更したときに、onupgradeneeded イベントが実行されます。これを使用して、objectStore を設定できます。

// Handle setup.
openRequest.onupgradeneeded = function(e) {

  console.log("running onupgradeneeded");
  var thisDb = e.target.result;

  // Create Employee
  if(!thisDb.objectStoreNames.contains("employee")) {
    console.log("I need to make the employee objectstore");
    var objectStore = thisDb.createObjectStore("employee", {keyPath: "id", autoIncrement: true});
    objectStore.createIndex("searchkey", "searchkey", {unique: false});
  }

};

openRequest.onsuccess = function(e) {
  db = e.target.result;

  db.onerror = function(e) {
    alert("Sorry, an unforseen error was thrown.");
    console.log("***ERROR***");
    console.dir(e.target);
  };

  handleSeed();
};

onupgradeneeded イベント ハンドラ ブロックでは、オブジェクト ストアの配列である objectStoreNames に employee が含まれているかどうかを確認します。そうでない場合は、単純にそのようにしています。createIndex の呼び出しが重要です。IndexedDB に、データの取得に使用するメソッド(鍵以外)を指定する必要があります。「searchkey」を使用します詳しくは後述します。

onungradeneeded イベントは、スクリプトを初めて実行したときに自動的に実行されます。実行された後、または今後の実行でスキップされた後、onsuccess ハンドラが実行されます。シンプルで(醜い)エラーハンドラを定義し、handleSeed を呼び出します。

先に進む前に、ここで何が行われているか簡単に確認しておきましょう。データベースを開きます。オブジェクト ストアが存在するかどうかを確認します。存在しない場合は作成します。最後に、handleSeed という名前の関数を呼び出します。次に、このデモのデータ シード部分に目を向けましょう。

データを見せて!

この記事の冒頭で説明したように、このデモでは、既知のすべての社員のコピーを保存する必要がある、イントラネット スタイルのアプリケーションを再作成します。通常、これは、従業員の数を返すことができ、レコードのバッチを取得する方法を提供できるサーバーベースの API を作成することを意味します。開始数をサポートし、一度に 100 人のユーザーを返す単純なサービスがあるとします。ユーザーが他の作業をしている間に、バックグラウンドで非同期的に実行できます。

デモでは、簡単な処理を行います。IndexedDB にオブジェクトがいくつあるか(ある場合)を確認します。一定数を下回る場合は、偽のユーザーを作成します。そうでない場合は、シード部分の処理が完了したと見なされ、デモの自動入力部分を有効にできます。handleSeed を見てみましょう。

function handleSeed() {
  // This is how we handle the initial data seed. Normally this would be via AJAX.

  db.transaction(["employee"], "readonly").objectStore("employee").count().onsuccess = function(e) {
    var count = e.target.result;
    if (count == 0) {
      console.log("Need to generate fake data - stand by please...");
      $("#status").text("Please stand by, loading in our initial data.");
      var done = 0;
      var employees = db.transaction(["employee"], "readwrite").objectStore("employee");
      // Generate 1k people
      for (var i = 0; i < 1000; i++) {
         var person = generateFakePerson();
         // Modify our data to add a searchable field
         person.searchkey = person.lastname.toLowerCase();
         resp = employees.add(person);
         resp.onsuccess = function(e) {
           done++;
           if (done == 1000) {
             $("#name").removeAttr("disabled");
             $("#status").text("");
             setupAutoComplete();
           } else if (done % 100 == 0) {
             $("#status").text("Approximately "+Math.floor(done/10) +"% done.");
           }
         }
      }
    } else {
      $("#name").removeAttr("disabled");
      setupAutoComplete();
    }
  };
}

最初の行は、複数のオペレーションが連結されているため、少し複雑です。以下に分解して説明します。

db.transaction(["employee"], "readonly");

これにより、新しい読み取り専用トランザクションが作成されます。IndexedDB を使用したすべてのデータ オペレーションで、なんらかのトランザクションが必要です。

objectStore("employee");

従業員オブジェクト ストアを取得します。

count()

count API を実行します。これは、推測どおり、カウントを実行します。

onsuccess = function(e) {

完了したら、このコールバックを実行します。コールバック内で、オブジェクトの数である結果値を取得できます。カウントがゼロの場合は、シードプロセスを開始します。

前述のステータス div を使用して、データの取得を開始することをユーザーに伝えます。IndexedDB は非同期であるため、追加をトラッキングする単純な変数 done を設定しています。ループを回して偽の人物を挿入します。この関数のソースはダウンロードで入手できますが、次のようなオブジェクトを返します。

{
  firstname: "Random Name",
  lastname: "Some Random Last Name",
  department: "One of 8 random departments",
  email: "first letter of firstname+lastname@fakecorp.com"
}

これだけで、人物を定義できます。しかし、データを検索できるようにするには、特別な要件があります。IndexedDB では、大文字と小文字を区別せずにアイテムを検索する方法はありません。そのため、lastname フィールドのコピーを新しいプロパティ searchkey に作成します。これは、データのインデックスとして作成する必要があるキーです。

// Modify our data to add a searchable field
person.searchkey = person.lastname.toLowerCase();

これはクライアント固有の変更であるため、バックエンド サーバー(この場合は架空のバックエンド サーバー)ではなく、ここで行います。

データベースの追加を効率良く実行するには、すべてのバッチ書き込みでトランザクションを再利用する必要があります。書き込みのたびに新しいトランザクションを作成すると、ブラウザはトランザクションごとにディスク書き込みを発生させ、多くのアイテムを追加するときにパフォーマンスが低下する可能性があります(「1,000 個のオブジェクトを書き込むのに 1 分かかる」と考えると、最悪です)。

シードの実行が完了すると、アプリケーションの次の部分である setupAutoComplete が実行されます。

Autocomplete を作成する

次に、jQuery UI の Autocomplete プラグインを接続します。ほとんどの jQuery UI と同様に、基本的な HTML 要素から始めて、そのコンストラクタ メソッドを呼び出して拡張します。このプロセス全体を抽象化して、setupAutoComplete という関数にまとめています。では、そのコードを見てみましょう。

function setupAutoComplete() {

  //Create the autocomplete
  $("#name").autocomplete({
    source: function(request, response) {

      console.log("Going to look for "+request.term);

      $("#displayEmployee").hide();

      var transaction = db.transaction(["employee"], "readonly");
      var result = [];

      transaction.oncomplete = function(event) {
        response(result);
      };

      // TODO: Handle the error and return to it jQuery UI
      var objectStore = transaction.objectStore("employee");

      // Credit: http://stackoverflow.com/a/8961462/52160
      var range = IDBKeyRange.bound(request.term.toLowerCase(), request.term.toLowerCase() + "z");
      var index = objectStore.index("searchkey");

      index.openCursor(range).onsuccess = function(event) {
        var cursor = event.target.result;
        if(cursor) {
          result.push({
            value: cursor.value.lastname + ", " + cursor.value.firstname,
            person: cursor.value
          });
          cursor.continue();
        }
      };
    },
    minLength: 2,
    select: function(event, ui) {
      $("#displayEmployee").show().html(template(ui.item.person));
    }
  });

}

このコードで最も複雑な部分は、ソース プロパティの作成です。jQuery UI の Autocomplete コントロールを使用すると、ソース プロパティを定義できます。ソース プロパティは、どのようなニーズ(IndexedDB データも含む)に応じてカスタマイズできます。API は、リクエスト(基本的にはフォーム フィールドに入力された内容)とレスポンス コールバックを提供します。そのコールバックに結果の配列を返す必要があります。

まず、displayEmployee div を非表示にします。これは、個々の社員を表示するために使用され、以前に読み込まれた社員が存在する場合は、その社員を消去するために使用されます。それでは検索を始めましょう

まず、読み取り専用トランザクション、result という配列、結果をオートコンプリート コントロールに渡すだけの oncomplete ハンドラを作成します。

入力に一致するアイテムを見つけるには、StackOverflow ユーザーの Fong-Wan Chau によるヒントを活用しましょう。入力に基づくインデックス範囲を下限として、入力に文字 z を追加した範囲を上限として使用します。また、入力したデータに合わせて、キーワードは小文字に変換されます。

完了したら、カーソルを開き(データベース クエリを実行するようなものだと考えて)、結果を反復処理できます。jQuery UI の予測入力コントロールを使用すると、あらゆる種類のデータを返すことができますが、少なくとも value キーが必要です。値を適切な形式の名前に設定します。また、その人の全身を返します。その理由は後で説明します。まず、オートコンプリートが動作している様子を示すスクリーンショットをご紹介します。jQuery UI には Vader テーマを使用しています。

これだけで、IndexedDB の一致結果をオートコンプリートに返すことができます。ただし、一致が選択された場合に、その一致の詳細ビューを表示することもサポートしたいと考えています。前述の Handlebars テンプレートを使用した自動入力を作成するときに、選択ハンドラを指定しました。