この記事は翻訳作業中です。

モダン・ウェブブラウザーは、ユーザーの許可のもとにウェブサイトがユーザーのコンピューター上にデータを保存して必要なときにそのデータを取得するための、いくつもの方法をサポートしています。このことにより、長期記憶のためにデータを存続させること、オフライン利用のためにサイトまたは文書を保存すること、サイトについてのユーザー独自の設定を保持すること、などなどが可能になります。本記事では、これらがどのようにして機能するのかについてのごく基本的な点を説明します。

前提知識: JavaScript の基本 (JavaScript の第一歩JavaScript の構成要素JavaScript オブジェクト入門 を参照)、ウェブ APIの紹介
学習目標: アプリケーション・データを保存するためのクライアント側のストレージ API の使い方を学ぶこと

クライアント側での保存って?

MDN 学習エリアの他の箇所で、静的なサイト動的なサイト の違いについて述べました。ほとんどの主要なモダン・ウェブサイトは動的です。つまり、ある種のデータベース (サーバー側のストレージ) を使ってデータをサーバー上に記憶し、必要なデータを取得するためにサーバー側 のコードを実行し、そのデータを静的なページ雛型に挿入し、結果として出来上がった HTML をクライアントに提供して、それがユーザーのブラウザーによって表示されるようにします。

クライアント側での保存は類似の原理に基づいて機能しますが、これにはいくつかの異なる使い道があります。クライアント側での保存は、クライアント上に (つまりユーザーのマシン上に) データを保存して必要なときにそのデータを取得できるようにしてくれる、いくつかの JavaScript API から構成されています。クライアント側での保存には、たとえば以下のように多くの異なる用途があります。

  • サイトの環境設定を個人に合わせる (たとえば、カスタム・ウィジェット、カラースキーム、またはフォントサイズについて、ユーザーが選択したものを表示する、など)
  • 以前のサイト上の行動を存続させる (たとえば、前回のセッションからの買い物かごの中身を記憶しておく、ユーザーが以前ログインしたかどうかを憶えておく、など)
  • サイトをより速く (かつ、潜在的にはより費用をかけずに) ダウンロードできるように、または、ネットワーク接続なしでサイトが利用可能となるように、データと資産をローカルに保存する
  • ウェブ・アプリケーションが生成した文書を、オフラインで利用するために、ローカルに保存する

クライアント側での保存とサーバ側での保存は、しばしば共に使われます。たとえば、ウェブゲームまたは音楽プレーヤー・アプリが使う複数の音楽ファイルを一回分データベースからダウンロードし、それらの音楽ファイルをクライアント側のデータベース内に保存し、必要に応じて再生する、といったことが可能でしょう。ユーザーは、それらの音楽ファイルをただ一度ダウンロードするだけで済むでしょう。その後の訪問では、音楽ファイルは、ダウンロードされる代わりにデータベースから取得されるでしょう。

: クライアント側のストレージ API を使って保存できるデータの量には、上限があります (もしかすると、個別の API ごとの上限と、累積的な上限の双方があるかもしれません)。正確な上限は、ブラウザーごとに異なりますし、もしかすると、ユーザーの設定によることもあるかもしれません。より詳しくは、ブラウザーのストレージ制限と削除基準 を参照。

旧式な方法: クッキー

クライアント側での保存という考え方には、長い歴史があります。ウェブの初期から、ウェブサイト上でのユーザー体験を個別化するための情報を記憶するべく、サイトは クッキー を使ってきました。そうしたクッキーは、ウェブ上で一般的に使われるクライアント側での保存の、最初期の形式です。

その古さゆえに、クッキーには、技術的にもユーザー体験の観点からも厄介ごとをもたらす、多くの問題があります。これらの問題は十分に重大です。というのも、初回のサイト訪問時に、欧州在住者は、自分についてのデータを保存するためのクッキーを自分が使うことになるかどうかを知らせてくるメッセージを見せられる羽目になるのです。これは、EU クッキー指令 として知られる欧州連合の法令によるものです。

こうした理由から、本記事ではクッキーの使い方をお教えいたしません。クッキーがもう時代遅れであることや、クッキーの 各種のセキュリティ問題 や、クッキーでは複雑なデータを保存できないことなどから、今では、さらに多種多様なデータをユーザーのコンピューター上に保存するための、より良く、よりモダンな方法があるのです。

クッキーにある唯一の利点は、極めて古いブラウザーでもサポートされているということです。そのため、すでに廃れたブラウザー (Internet Explorer 8 またはそれ以前のバージョンなど) をサポートすることをプロジェクトが要求している場合には、依然としてクッキーが有用かもしれません。しかし、ほとんどのプロジェクトにとっては、クッキーに頼る必要は最早ないはずです。

なぜ今でもまだクッキーを用いて新しいサイトが作成されているのでしょうか?  その理由は、ほとんどのところ、開発者の習慣のせいだったり、まだクッキーを使っている古いライブラリーを使っているせいだったり、データの保存の仕方を学習するための参考資料・研修用資料で時代遅れになってしまったものを提供しているウェブサイトが数多く存在しているせいだったりします。

新方式派: ウェブストレージと IndexedDB

クライアント側のデータを保存するのにクッキーを用いるよりもずっと容易で一層効果的な API が、モダンなブラウザーには備わっています。

  • Web Storage API は、名前とそれに対応する値とからなる小規模なデータ項目を保存したり取り出したりするための、とても簡潔な構文を提供しています。これは、ユーザーの名前、ユーザーがログインしているかどうか、画面の背景にどの色を使うべきか、といったような、何らかの単純なデータを記憶するだけでよい場合に有用です。
  • IndexedDB API は、複雑なデータを保存するための完全なデータベース・システムをブラウザーに提供しています。これは、顧客レコードの完全な集合から、音声ファイルまたは動画ファイルのような複雑なデータ型にいたるまでの、種々の物事に対して使えます。

以下ではこれらの API について学ぶことになります。

将来: キャッシュ API

いくつかのモダン・ブラウザーは、新しい Cache API をサポートしています。この API は、特定の要求に対する HTTP 応答を記憶しておくために設計されています。 また、ネットワーク接続なしに後でサイトを利用できるように、ウェブサイト資産をオフラインに記憶しておく、といったようなことをするうえで非常に有用です。キャッシュは通常、サービスワーカー API と組み合わせて利用します。もっとも、必ずそうしなくてはならないというわけではありません。

キャッシュとサービスワーカーの利用は先進的な話題であり、この記事ではそれほど詳しくは扱いません。とは言うものの、後述の Offline asset storage の節では、簡単な例をお見せします。

単純なデータを保存する——ウェブストレージ

Web Storage API は大変使いやすいものです。(文字列や数などに限定された) データからなる単純な名前/値のペアを保存し、必要なときにその値を取り出します。

基本的構文

以下に方法を示しましょう。

  1. まず、GitHub 上の ウェブストレージの空白テンプレート へ行ってください (新規タブで開いてください)。

  2. ブラウザーのデベロッパー・ツールの JavaScript コンソールを開いてください。

  3. ウェブストレージ・データのすべては、ブラウザー内部の二つのオブジェクト的な構造体の中に含まれます。つまり、sessionStoragelocalStorage の中です。前者は、ブラウザーが開いている限り、データを存続させます (ブラウザーを閉じるとデータは失われます)。後者は、ブラウザーを閉じて、それから再びブラウザーを開いた後でさえも、データを存続させます。一般的には後者の方がより有用なので、本記事では後者を使います。

    Storage.setItem() メソッドによって、ストレージ内にデータ項目を保存できます。このメソッドは二つの引数をとります。すなわち、その項目の名前と、その値です。JavaScript コンソールに以下のように打ち込んでみてください (もしお望みなら、値は御自分のお名前に変更してくださいね!)

    localStorage.setItem('name','Chris');
  4. Storage.getItem() メソッドは一つの引数をとります。つまり、取り出したいデータ項目の名前です。そして、このメソッドは、その項目の値を返します。今度は JavaScript コンソールに以下の行を打ち込んでください。

    var myName = localStorage.getItem('name');
    myName

    2 行目を入力すると、myName という変数が今や name というデータ項目の値を保有していることが分かるはずです。

  5. Storage.removeItem() メソッドは一つの引数をとります。つまり、削除したいデータ項目の名前です。このメソッドは、ウェブストレージからその項目を削除します。JavaScript コンソールに以下の行を打ち込んでください。

    localStorage.removeItem('name');
    var myName = localStorage.getItem('name');
    myName

    3 行目は、今度は null を返すはずです。というのも、もはや name という項目はウェブストレージ内に存在しないからです。

データが存続する!

ウェブストレージの一つの重要な特徴は、ページ・ロードをまたいで (さらに、localStorage の場合には、ブラウザーを終了させてさえも) データが存続する、という点です。この特徴が機能しているところを見てみましょう。

  1. もう一度、ウェブストレージの空白テンプレートを開いてください。ただし今回は、本チュートリアルを開いたのとは別のブラウザーで開いてください! こうすることで、取り扱いがしやすくなるでしょう。

  2. 以下の行をブラウザーの JavaScript コンソールに打ち込んでください。

    localStorage.setItem('name','Chris');
    var myName = localStorage.getItem('name');
    myName

    name という項目が返されるのが分かるはずです。

  3. さてここでブラウザーを終了させてから再び起動して開いてください。

  4. 再び、以下の行を入力してください。

    var myName = localStorage.getItem('name');
    myName

    ブラウザーを終了させてから再び開いたというのに、それでも依然として値が利用可能である、ということが分かるはずです。

ドメインごとに別々のストレージ

ドメインごとに (ブラウザーにロードされた別々のウェブ・アドレスごとに)、別々のデータストアがあります。二つのウェブサイト (たとえば google.com と amazon.com) をロードして、一方のウェブサイトで項目を保存してみると、その項目は他方のウェブサイトでは利用できない、と分かるでしょう。

これには意義があります。もしウェブサイト同士がお互いのデータを見ることが可能であったら起こるであろうセキュリティ問題を想像できますよね!

さらに込み入った例

どのようにウェブストレージを使えるのかについてお教えするために、簡単で基礎的な事例を書くことによって、(ドメインごとのストレージという) この新たに得た知識を応用してみましょう。この事例では、名前を入力できるようにします。その入力の後、個人に合わせた挨拶を表示するべく、ページが更新されます。この状態は、ページ/ブラウザーのリロードをまたいでも存続するでしょう。なぜなら、名前がウェブストレージに記憶されているからです。

この例の HTML を personal-greeting.html で入手できます。これは、ヘッダーとコンテンツとフッターを備えた簡素なウェブサイトと、名前を入力するためのフォームとを含みます。

この例を組み上げましょう。すると、これがどのように機能するのか理解できるでしょう。

  1. まず、御自分のコンピュータ上の新規ディレクトリに、personal-greeting.html というファイルのローカルコピーを作ってください。

  2. 次に、index.js と呼ばれる JavaScript ファイルを、HTML がどのように参照しているのかに注意してください (40 行目を参照)。これ (index.js) を作成して、そこに JavaScript コードを書き込む必要があります。HTML ファイルと同じディレクトリに index.js というファイルを作成してください。

  3. この例で操作する必要のある HTML 項目 (features) のすべてに対する参照を作るところから取り掛かりましょう。それらの参照のすべてを定数として作ります。なぜなら、これらの参照は、アプリのライフサイクル内で変化する必要がないからです。以下の行を JavaScript ファイルに追加してください。

    // 必要な定数を作ります。
    const rememberDiv = document.querySelector('.remember');
    const forgetDiv = document.querySelector('.forget');
    const form = document.querySelector('form');
    const nameInput = document.querySelector('#entername');
    const submitBtn = document.querySelector('#submitname');
    const forgetBtn = document.querySelector('#forgetname');
    
    const h1 = document.querySelector('h1');
    const personalGreeting = document.querySelector('.personal-greeting');
  4. 次に、送信ボタンが押されたときにフォームが実際にこのフォーム自体を送信することをやめさせるための、小規模なイベント・リスナーを含める必要があります。というのも、こうした送信は所望の振る舞いではないからです。以下に示すスニペットを、前のコードに追加してください。

    // ボタンが押されたときにフォームが送信することをやめさせます。
    form.addEventListener('submit', function(e) {
      e.preventDefault();
    });
  5. さてここで、イベント・リスナーを追加せねばなりません。そのイベント・リスナーのハンドラー関数は、"Say hello" ボタンがクリックされたときに実行されます。それぞれの断片が何を行うのかはコメントで詳しく説明してありますが、本質的にここでは、ユーザーがテキスト入力ボックスに入力した名前をとってきて、setItem() を用いてその名前をウェブストレージに保存し、その後、実際のウェブサイト上のテキストの更新を扱う nameDisplayCheck() と呼ばれる関数を実行しています。これをコードの末尾に加えてください。

    // 'Say hello' ボタンがクリックされたら関数を実行します。
    submitBtn.addEventListener('click', function() {
      // 入力された名前をウェブストレージに保存します。
      localStorage.setItem('name', nameInput.value);
      // 個人に合わせた挨拶を表示するとともにフォーム表示を更新する
      // 措置をとるべく、nameDisplayCheck() を実行します。
      nameDisplayCheck();
    });
  6.  この時点で、"Forget" ボタンがクリックされたときに関数を実行するためのイベント・ハンドラーも必要です。"Forget" ボタンは、"Say hello" ボタンがクリックされた後にのみ表示されます (二つのフォーム状態が行ったり来たり切り替わります)。この関数では、removeItem() を用いてウェブストレージから name という項目を削除し、その後、表示を更新するために nameDisplayCheck() を再び実行します。これを末尾に付け加えてください。

    // 'Forget' ボタンがクリックされたら関数を実行します。
    forgetBtn.addEventListener('click', function() {
      // 保存してある名前をウェブストレージから削除します。
      localStorage.removeItem('name');
      // 再び一般的な挨拶を表示するとともにフォーム表示を更新する
      // 措置をとるべく、nameDisplayCheck() を実行します。
      nameDisplayCheck();
    });
  7. さて今や nameDisplayCheck() という関数そのものを定義すべきときです。ここでは、localStorage.getItem('name') を条件テストとして用いることにより、name という項目がウェブストレージに保存済みかどうかを調べます。もし保存済みなら、この呼び出しは true と評価されるでしょう。もし保存済みでなければ、false になるでしょう。もし true なら、個人に合わせた挨拶を表示し、フォームの "forget" の部分を表示し、フォームの "Say hello" の部分を隠します。もし false なら、一般的な挨拶を表示し、逆のことをします (フォームの "forget" の部分を隠し、フォームの "Say hello" の部分を表示します)。またもや末尾に以下のコードを追加してください。

    // nameDisplayCheck() という関数を定義します。
    function nameDisplayCheck() {
      // 'name' というデータ項目がウェブストレージに保存されているかどうかを調べます。
      if(localStorage.getItem('name')) {
        // もし保存されていたら、個人に合わせた挨拶を表示します。
        let name = localStorage.getItem('name');
        h1.textContent = 'Welcome, ' + name;
        personalGreeting.textContent = 'Welcome to our website, ' + name + '! We hope you have fun while you are here.';
        // フォームのうち 'remember' の部分を隠し、'forget' の部分を表示します。
        forgetDiv.style.display = 'block';
        rememberDiv.style.display = 'none';
      } else {
        // もし保存されていなければ、一般的な挨拶を表示します。
        h1.textContent = 'Welcome to our website ';
        personalGreeting.textContent = 'Welcome to our website. We hope you have fun while you are here.';
        // フォームのうち 'forget' の部分を隠し、'remember' の部分を表示します。
        forgetDiv.style.display = 'none';
        rememberDiv.style.display = 'block';
      }
    }
  8. 最後に、ページがロードされるたびに nameDisplayCheck() という関数を実行せねばなりません。もしそうしなければ、個人に合わせた挨拶は、ページのリロードをまたがってまでは持続しなくなってしまうでしょう。以下のものをコードの末尾に追加してください。

    document.body.onload = nameDisplayCheck;

例が完成しました。よくできましたね! 現時点で残っているのは、コードを保存して HTML ページをブラウザーでテストすることだけです。ライブ実行される完成版をここで 見られます。

: ウェブストレージ API の使用 のところには、探究するにはほんの少しだけ更に複雑な別の例もあります。

: 完成版のソースのうち <script src="index.js" defer></script> という行では、defer 属性により、ページをロードし終わるまでは <script> 要素の中身を実行しないように指定しています。

複雑なデータを保存する—— IndexedDB

IndexedDB API  (ときには IDB と省略します) は、ブラウザーで利用可能であり、複雑で関係性のあるデータを保存できる、完全なデータベース・システムです。そしてそのデータの型は、文字列または数値のような単純な値に限定されません。動画や静止画像、そして、その他のものもほとんどすべて、IndexedDB インスタンスに保存できます。

しかし、これは高くつきます。IndexedDB の使用は、ウェブストレージ API の使用よりも遥かに複雑なのです。本節では、IndexedDB ができることのうち本当に表面的なところに触れるだけですが、始めるのに十分なだけのことは、お伝えしましょう。

メモ書きの保存の事例を通して作業します

ここでは、メモ書きをブラウザーに保存して好きなときにそれを見たり消したりできるようにする事例を、見ていただきましょう。その際、その例は御自分で組み立てていただきますが、進行に合わせて、IDB の最も根本的な部分について御説明します。

当該アプリは、以下のような見かけをしています。

メモ書きの各々には題名と何らかの本文があり、題名と本文のそれぞれは別々に編集できます。以下で見てゆく JavaScript コードには、何が起きているのかを理解する手助けとなる詳しいコメントがあります。

始めますよ

  1. まず、index.htmlstyle.cssindex-start.js というファイルのローカルコピーを、ローカルマシンの新規ディレクトリ内に作成してください。
  2. ファイルを見てください。HTML がかなり簡潔なのがお分かりでしょう。これは、ヘッダーとフッターのあるウェブサイトです。また、メモ書きを表示する場所と、データベースに新たなメモ書きを入力するためのフォームとを含む、本文コンテンツ領域もあります。 CSS は、何が起きているのかをより明瞭にするための、ある種の簡素なスタイルづけを提供しています。JavaScript ファイルは、宣言された五つの定数を含んでいます。つまり、 内部にメモ書きを表示することになる <ul> 要素への参照と、題名および本文の <input> 要素への参照と、<form> 自体への参照と、<button> への参照とを含んでいます。
  3. JavaScript ファイルの名前を index.js に変更してください。コードをそこに追加し始める準備がこれで整いました。

データベースの初期設定

では、実際にデータベースを設定するために最初にすべきことを見てみましょう。

  1. 定数の宣言の下に、以下の行を追加してください。

    // 開いたデータベースを記憶しておくためのデータベース・オブジェクトのインスタンスを作成します。
    let db;

    ここでは、db と呼ばれる変数を宣言しています。これは後に、データベースを表すオブジェクトを記憶するのに使われます。この変数を何箇所かで使うつもりなので、物事を容易にするために、ここでこの変数を大域的に宣言しておきました。

  2. 次に、以下のものをコードの末尾に加えてください。

    window.onload = function() {
    
    };

    続きのコードはすべて、この window.onload イベント・ハンドラー関数——ウィンドウの load イベントが発火したときに呼ばれます——の中に書いてゆきます。アプリが完全にロード動作を終えるよりも前には IndexedDB 機能を使おうとはしないよう保証するために、そうしています  (もしそう保証しなかったら、失敗する可能性があります)。

  3. window.onload ハンドラーの中に、以下のものを追加してください。

    // データベースを開きます。データベースは、まだ存在していない場合には
    // 新規作成されます (後述の onupgradeneeded を参照)。
    let request = window.indexedDB.open('notes', 1);

    この行では、notes と呼ばれるデータベースのバージョン 1 を開く request (要求) を作成します。もしそのデータベースがまだ存在しなければ、後述のコードによって新規作成されます。IndexedDB の全体を通じて、この要求パターンが非常に高頻度で使われることが、いずれお分かりになるでしょう。データベース操作には時間がかかります。その結果を待つ間、ブラウザーをハングさせることはお望みでないでしょうから、データベース操作は asynchronous (非同期) となっています。このことが意味するのは、結果は直ちに生じるのではなく、将来のいずれかの時点で生じるだろうということ、および、結果が出たときには通知されるということです。

    こういったことを IndexedDB で扱うために、要求オブジェクト (何とでも好きなように呼んで構いませんが、何を目的としたものなのかが明白になるので、request (要求) と呼んでおきました) を作成します。それから、要求が完了する、失敗する、などの際にコードを実行するために、いくつかのイベント・ハンドラーを使います。この点については、使用されているところを後で見ることになります。

    :  バージョン番号は重要です。(たとえばテーブル構造を変更することによって) データベースをアップグレードしたい場合には、上げたバージョン番号や、onupgradeneeded ハンドラー (下記参照) の内部で指定される別のスキーマなどを使って、コードを再度実行せねばなりません。この簡単なチュートリアルでは、データベースのアップグレードは扱いません。

  4. さて今度は、前に追加した分のすぐ下に、以下のイベント・ハンドラーを追加してください。今度もまた、window.onload ハンドラーの中への追加です。

    // onerror ハンドラーは、データベースがうまく開けなかったことを意味します。
    request.onerror = function() {
      console.log('Database failed to open');
    };
    
    // onsuccess ハンドラーは、データベースがうまく開けたことを意味します。
    request.onsuccess = function() {
      console.log('Database opened successfully');
    
      // 開いたデータベース・オブジェクトを、db という変数に記憶します。この変数は、以下でたくさん使われます。
      db = request.result;
    
      // IDB 内の既存のメモ書きを表示するために、displayData() 関数を実行します。
      displayData();
    };

    要求は失敗した、と伝えつつシステムが戻ってくる場合には、request.onerror というハンドラーが実行されます。これによって、(要求が失敗したという) この問題に対処できるようになります。この簡単な例では、単に JavaScript コンソールにメッセージを印字します。

    他方、request.onsuccess ハンドラーは、要求が成功裡に戻ってくる場合、つまりデータベースをうまく開けた場合に、実行されます。この場合、開いたデータベースを表すオブジェクトが、request.result というプロパティで利用可能となります。それにより、データベースを操作できるようになります。後で使うために、と事前に作っておいた db という変数に、このオブジェクトを保存します。また、displayData() と呼ばれるカスタム関数も実行します。この関数は、データベース内のデータを <ul> 内部に表示します。すでにデータベース内にあるメモ書きが、ページがロードされ次第すぐに表示されるように、ここでこの関数を実行しています。この関数を定義する様子は、後で見ることにしましょう。

  5. 本節の最後では、データベースを設定するためには多分もっとも重要なイベント・ハンドラーを追加しましょう。つまり、request.onupdateneeded です。このハンドラーは、データベースがまだ設定されていなかった場合、あるいは、保存済みの既存のデータベースよりも上のバージョン番号でデータベースが開かれた場合 (アップグレードを行う場合) に、実行されます。前のハンドラーの下に、以下のコードを追加してください。

    // これがまだ実行されていない場合に、データベースのテーブルを設定します。
    request.onupgradeneeded = function(e) {
      // 開いたデータベースに対する参照を求めます。
      let db = e.target.result;
    
      // 自動的にインクリメントするキーを含んでおり、メモ書きを中に保存するための
      // (基本的に一つのテーブルに類似した) objectStore を、作成します。
      let objectStore = db.createObjectStore('notes', { keyPath: 'id', autoIncrement:true });
    
      // objectStore が含むことになるデータ項目を定義します。
      objectStore.createIndex('title', 'title', { unique: false });
      objectStore.createIndex('body', 'body', { unique: false });
    
      console.log('Database setup complete');
    };

    ここは、データベースのスキーマ (構造) ——すなわち、データベースが含む列 (ないしフィールド) の集合——を定義している箇所です。ここではまず、e.target.result (イベント・ターゲットの result というプロパティ) から、既存のデータベースへの参照を求めていますが、これ (e.target というイベント・ターゲット) は、request というオブジェクトです。この行は、onsuccess ハンドラーの中の db = request.result; という行と等価です。しかし、それとは別に、ここでこのようにする必要があります。なぜなら、onupgradeneeded ハンドラーは、(もし必要な場合には) onsuccess ハンドラーよりも前に実行されることになる——つまり、もしここでこのようにしておかなければ、db の値を利用できない——からです。

    それから、IDBDatabase.createObjectStore() を用いて、開いたデータベースの内部に新たなオブジェクト・ストアを作成します。これは、従来のデータベース・システムにおける一つのテーブルと等価です。このオブジェクト・ストアには notes という名前をつけました。また、id と呼ばれる autoIncrement キーのフィールドも指定しました。新規レコードの各々において、このフィールドには、インクリメントされた値が自動的に与えられ、開発者は、このフィールドを明示的に設定する必要がありません。キーであるがゆえに、id フィールドは、たとえばレコードを削除または表示する際に、レコードを一意に識別するのに使われることでしょう。

    IDBObjectStore.createIndex() メソッドを用いて、別の二つのインデックス (フィールド) も作成します。すなわち、title  (それぞれのメモ書きの題名を含むことになります) と、body (そのメモ書きの本文を含むことになります) を作成します。

 以上のようにこの簡素なデータベース・スキーマを設定したので、データベースにレコードを追加し始めれば、それぞれのレコードは、以下の行のようなオブジェクトとして表現されることでしょう。

{
  title: "Buy milk",
  body: "Need both cows milk and soya.",
  id: 8
}

データをデータベースに追加します

それでは、どのようにしたらデータベースにレコードを追加できるか、その方法を見てみましょう。これは、ページ上のフォームを使って行われます。

前のイベント・ハンドラーの下に (ただし、やはり window.onload ハンドラーの内部に)、 以下の行を追加してください。以下の行では、フォームが送信された際に (送信 <button> が押され、成功したフォーム送信、という結果に至ったときに)、addData() と呼ばれる関数を実行する、onsubmit ハンドラーを設定しています。

// フォームが送信されたときに addData() 関数が実行されるように、onsubmit ハンドラーを作成します。
form.onsubmit = addData;

では、addData() 関数を定義しましょう。上記の行の下に、以下のものを追加してください。

// addData() 関数を定義します。
function addData(e) {
  // デフォルト動作を防止します。従来通りの方法でフォームを送信したくはないからです。
  e.preventDefault();

  // フォーム・フィールドに入力された値を求めます。そして、それらの値を、データベースへ挿入すべく準備してあるオブジェクトに保存します。
  let newItem = { title: titleInput.value, body: bodyInput.value };

  // 読み書きのデータベース・トランザクションを開いて、データの追加に備えます。
  let transaction = db.transaction(['notes'], 'readwrite');

  // データベースに追加済みのオブジェクト・ストアを呼び出します。
  let objectStore = transaction.objectStore('notes');

  // newItem というオブジェクトをオブジェクト・ストアに追加するための要求を作ります。
  var request = objectStore.add(newItem);
  request.onsuccess = function() {
    // フォームをクリアして、次のエントリーの追加に備えます。
    titleInput.value = '';
    bodyInput.value = '';
  };

  // すべてが済んだら、完了するトランザクションの成功を報告します。
  transaction.oncomplete = function() {
    console.log('Transaction completed: database modification finished.');

    // displayData() を再度実行することによって、データの表示を更新して、新たに追加した項目を表示します。
    displayData();
  };

  transaction.onerror = function() {
    console.log('Transaction not opened due to error');
  };
}

これは割と複雑ですね。噛み砕くと、以下の通りです。

  • フォームが実際に従来通りの方法で送信してしまうこと (これはページ・リフレッシュを引き起こし、体験をそこなうでしょう) を防ぐために、イベント・オブジェクトに対して Event.preventDefault() を実行します。
  • データベースに入力すべきレコードを表すオブジェクトを作成します。その際、そのオブジェクトには、フォーム入力からの値を埋め込みます。id の値を明示的に含める必要がないことに注意してください。以前説明したとおり、これは自動的に埋め込まれます。
  • IDBDatabase.transaction() メソッドを用いて、notes というオブジェクト・ストアに対するreadwrite (読み書き) トランザクションを開きます。このトランザクション・オブジェクトのおかげでオブジェクト・ストアにアクセスできるようになり、オブジェクト・ストアに対して何か——たとえば新規レコードの追加など——を行えるようになります。
  • IDBTransaction.objectStore()メソッドを用いてオブジェクト・ストアにアクセスし、その結果を objectStore という変数に保存します。
  •  IDBObjectStore.add() を用いて、データベースに新規レコードを追加します。これは、以前見たのと同様の方法で、要求オブジェクトを作り出します。
  • ライフサイクル内での重大な時点 (クリティカル・ポイント) においてコードを実行するために、request (要求) と transaction (トランザクション) に対する一群のイベント・ハンドラーを追加します。要求が成功したら、次のメモ書きの入力に備えてフォーム入力をクリアします。トランザクションが完了したら、ページ上のメモ書きの表示を更新するために、displayData() 関数を再び実行します。

データを表示します

すでにコード内で displayData() を二度も参照したからには、多分これを定義すべきでしょうね。以下のものをコードに (今までの関数定義の下に) 追加してください。

// displayData() 関数を定義します。
function displayData() {
  // ここでは、表示を更新するたびにリスト要素の中身を空にします。
  // もしこのようにしなかったら、新たなメモ書きを追加するたびに複製を列挙する羽目になるでしょう。
  while (list.firstChild) {
    list.removeChild(list.firstChild);
  }

  // オブジェクト・ストアを開き、それから、カーソルを取得します。 
  // カーソルは、ストア内の異なるデータ項目のすべてにわたって反復処理を行うものです。
  let objectStore = db.transaction('notes').objectStore('notes');
  objectStore.openCursor().onsuccess = function(e) {
    // カーソルへの参照を求めます。
    let cursor = e.target.result;

    // 反復処理を行うべき別のデータ項目がまだあれば、このコードを実行し続けます。
    if(cursor) {
      // 各データ項目を表示する際にそのデータ項目を中に入れるための、リスト項目と h3 と p とを作成します。
      // HTML 断片を組み立てて、それをリスト内の最後に追加します。
      let listItem = document.createElement('li');
      let h3 = document.createElement('h3');
      let para = document.createElement('p');

      listItem.appendChild(h3);
      listItem.appendChild(para);
      list.appendChild(listItem);

      // h3 および para の内部に、カーソルからのデータを入れます。
      h3.textContent = cursor.value.title;
      para.textContent = cursor.value.body;

      // listItem の属性内部に、このデータ項目の ID を保存します。こうすると、 
      // listItem がどの項目に対応しているのかがわかります。これは、後で項目を削除したくなったときに有用です。
      listItem.setAttribute('data-note-id', cursor.value.id);

      // ボタンを作成し、それを各 listItem の内部に設置します。
      let deleteBtn = document.createElement('button');
      listItem.appendChild(deleteBtn);
      deleteBtn.textContent = 'Delete';

      // ボタンがクリックされたら deleteItem() 関数が実行されるように、
      // イベント・ハンドラーを設定します。
      deleteBtn.onclick = deleteItem;

      // カーソルにおける次の項目へと反復処理を進めます。
      cursor.continue();
    } else {
      // またもや、リスト項目が空であれば、'No notes stored' (メモ書きは何も保存されていません) というメッセージを表示します。
      if(!list.firstChild) {
        let listItem = document.createElement('li');
        listItem.textContent = 'No notes stored.';
        list.appendChild(listItem);
      }
      // 反復処理をすべきカーソル項目がこれ以上ない場合、そのように示します。
      console.log('Notes all displayed');
    }
  };
}

再びになりますが、これを噛み砕いてみましょう。

  • まず、更新した中身を埋め込む前に、<ul> 要素の中身を空っぽにします。これを行わないと、遂には、更新のたびに追加された複製された中身からなる巨大なリストができあがってしまいます。
  • 次に、IDBDatabase.transaction()IDBTransaction.objectStore() を用いて、addData() で行ったのと同様にして (ただしここではこれらを繋いで 1 行にまとめている点が異なりますが)、notes というオブジェクト・ストアへの参照を求めます。
  • 次の段階は、IDBObjectStore.openCursor() メソッドを使って、カーソルに対する要求を開くことです。カーソルとは、オブジェクト・ストア内の全レコードにわたって反復処理を行うのに使える構造体です。コードをより簡潔にするために、この行の最後に onsuccess ハンドラーを繋げています。カーソルが成功裡に返されると、このハンドラーが実行されます。
  • let cursor = e.target.result  を用いて、カーソル自体 (IDBCursor オブジェクト) に対する参照を求めています。
  • 次に、カーソルがデータストアのレコードを含むか否かを調べます (if(cursor){ ... })。もし含むなら、DOM 断片を作成し、その断片にレコードのデータを埋め込み、ページ内に (<ul> 要素の内部に) その断片を挿入します。また、クリックされたら deleteItem() 関数を実行することによって当該メモ書きを削除するような削除ボタンも含めておきます。この関数は、次の節で見ることにします。
  • if ブロックの最後では、IDBCursor.continue() メソッドを用いてカーソルをデータストア内の次のレコードへと進め、if ブロックの中身を再び実行します。反復処理をすべき別のレコードがある場合には、こうすることにより、その別のレコードがページに挿入されることになり、その後また continue() が実行され、以下同様に続きます。
  • 反復処理をすべき対象のレコードがもうない場合、cursorundefined を返すことになります。よって、if ブロックの代わりに else ブロックが実行されることになります。このブロックでは、<ul> に何らかのメモ書きが挿入されたかどうかを調べます。もし何も挿入されていなければ、何もメモ書きが保存されていなかった旨を述べるメッセージを挿入します。

メモ書きを削除します

上述のとおり、メモ書きの削除ボタンが押されると、そのメモ書きは削除されます。これは、deleteItem() 関数により達成されます。この関数は以下のようなものです。

// deleteItem() 関数を定義します。
function deleteItem(e) {
  // 削除したいタスクの名前 (訳注: ID の間違い?) を取り出します。
  // それを IDB で使おうとする前に、数値に変換する必要があります。
  // IDB のキーの値には、型による区別があるのです。
  let noteId = Number(e.target.parentNode.getAttribute('data-note-id'));

  // データベース・トランザクションを開き、当該タスクを削除します。その際、上記で取得した ID を用いて、当該タスクを見つけます。
  let transaction = db.transaction(['notes'], 'readwrite');
  let objectStore = transaction.objectStore('notes');
  let request = objectStore.delete(noteId);

  // データ項目を削除したことを報告します。
  transaction.oncomplete = function() {
    // ボタンの親——リスト項目——を削除します。
    // すると、それはもはや表示されなくなります。
    e.target.parentNode.parentNode.removeChild(e.target.parentNode);
    console.log('Note ' + noteId + ' deleted.');

    // 再びになりますが、リスト項目が空の場合は、'No notes stored' (メモ書きは何も保存されていません) というメッセージを表示します。
    if(!list.firstChild) {
      let listItem = document.createElement('li');
      listItem.textContent = 'No notes stored.';
      list.appendChild(listItem);
    }
  };
}
  • これの最初の部分は説明を要します。 Number(e.target.parentNode.getAttribute('data-note-id')) を用いて、削除すべきレコードの ID を取り出します。レコードの ID は、最初にその <li> が表示された際にその <li>data-note-id という属性に保存されている、ということを思い出してください。しかし、その属性は、 Number() というグローバル・ビルトイン・オブジェクトを通じて渡す必要があります。なぜなら、属性は今のところ文字列であり、こうしなければデータベースに認識されないからです。
  • それから、以前に見たのと同じパターンを使って、オブジェクト・ストアへの参照を求めます。そして、IDBObjectStore.delete() メソッドを用いて、データベースから当該レコードを削除します。その際、データベースには ID を渡します。
  • データベース・トランザクションが完了したら、当該メモ書きの <li> を DOM から削除します。そして再び、<ul> が現時点で空かどうかを調べ、適宜注記を挿入します。

さあ、これで全部終わりです! あなたの例は今やちゃんと動くはずですよ。

もし問題があれば、気軽に ライブ例と突き合わせてみてください (ソースコード も参照してください)。

IndexedDB を通じて複雑なデータを保存します

上述のとおり、IndexedDB は、単純なテキスト文字列以上のものを保存するのに使えます。望むものはほとんど何でも——動画や静止画像のブロブ (blob) のような、複雑なオブジェクトまで含めて——保存できるのです。しかも、他のどの型のデータと比べても、達成するのがずっと困難だという訳でもないのです。

やり方を実演するために、IndexedDB 動画ストア と呼ばれる別の例を書きました (ここでライブで動いているところも 参照してください)。この例を最初に実行すると、すべての動画をネットワークからダウンロードして IndexedDB データベースに保存し、それから、<video> 要素内部の UI の中に動画を表示します。二度目に実行すると、動画を表示する前に、データベース内の動画を見つけ出し、(ネットワークからダウンロードする) 代わりにそこから動画を取ってきます。こうすることにより、後続のロードは高速化され、帯域幅をあまり食わなくなります。

この例のもっとも興味深い部分を見て回りましょう。すべては見ないことにします。というのも、多くの部分は前の例に類似しており、コードにはちゃんとコメントがつけてありますから。

  1. この単純な例のために、取得すべき動画の名前をオブジェクトの配列の形で保存しておきました。

    const videos = [
      { 'name' : 'crystal' },
      { 'name' : 'elf' },
      { 'name' : 'frog' },
      { 'name' : 'monster' },
      { 'name' : 'pig' },
      { 'name' : 'rabbit' }
    ];
  2. まずはじめに、データベースを成功裡に開くことができたら、init() 関数を実行します。これは、異なる動画の名前をループしてゆきますが、その際、それぞれの名前で識別されるレコードを videos というデータベースからロードしようと試みます。

    各々の動画がデータベース内で見つかったら (これは、request.resulttrue と評価されるかどうかを調べることにより、容易に確認できます。もしレコードが存在しなければ、undefined となります)、その動画ファイル (ブロブとして保存されています) および動画の名前が、UI に配置するために、すぐに displayVideo() 関数へと渡されます。もし動画がデータベース内で見つからなければ、動画の名前が fetchVideoFromNetwork() 関数に渡されます。それが何のためか、見当がついていることでしょうが……そう、その動画をネットワークから取ってくるためです。

    function init() {
      // 動画の名前を一つずつループしてゆきます。
      for(let i = 0; i < videos.length; i++) {
        // トランザクションを開き、オブジェクト・ストアを取得し、名前によって各動画を get() します。
        let objectStore = db.transaction('videos').objectStore('videos');
        let request = objectStore.get(videos[i].name);
        request.onsuccess = function() {
          // もし結果がデータベース内に存在したら (存在しなければ undefined)、
          if(request.result) {
            // displayVideo() を用いて、動画を IDB から取り出して表示します。
            console.log('taking videos from IDB');
            displayVideo(request.result.mp4, request.result.webm, request.result.name);
          } else {
            // 動画をネットワークから取ってきます。
            fetchVideoFromNetwork(videos[i]);
          }
        };
      }
    }
  3. 以下のスニペットは、fetchVideoFromNetwork() の内部から取ったものです。ここでは、二つの別々の WindowOrWorkerGlobalScope.fetch() 要求を用いて、MP4 版の動画と WebM 版の動画を取ってきます。それから、Body.blob() メソッドを用いて、それぞれの応答の本体をブロブとして抽出します。このブロブは、保存して後で表示することの可能な、動画のオブジェクト表現を与えてくれます。

    しかし、ここで問題があります。これらの二つの要求はどちらも非同期的なのですが、双方のプロミスが成立 (fulfill) した場合にだけ動画を表示もしくは保存しようと試みたいのです。幸い、そうした問題を扱うビルトイン・メソッドがあります。すなわち  Promise.all() です。これは一つの引数——成立したかどうかを調べたい個々のプロミスすべてに対する参照を配列に入れたもの——をとり、これ自体がプロミスに基づいています。

    それらのプロミスすべてが成立したら、成立した個々の値すべてを含む配列をともなって、all() プロミスも成立します。all() のブロック内部では、以前 UI に動画を表示するために行ったのと同様にして displayVideo() 関数を呼び出していること、そして、それらの動画をデータベース内に保存するために storeVideo() 関数も呼び出していることが、お分かりでしょう。

    let mp4Blob = fetch('videos/' + video.name + '.mp4').then(response =>
      response.blob()
    );
    let webmBlob = fetch('videos/' + video.name + '.webm').then(response =>
      response.blob()
    );
    
    // 双方のプロミスが成立したときのみ、次のコードを実行します。
    Promise.all([mp4Blob, webmBlob]).then(function(values) {
      // ネットワークから取ってきた動画を、displayVideo() により表示します。
      displayVideo(values[0], values[1], video.name);
      // storeVideo() を用いて、その動画を IDB に保存します。 
      storeVideo(values[0], values[1], video.name);
    });
  4. まず storeVideo() を見ましょう。これは、データベースにデータを追加するための上記の例で見たパターンに、とてもよく似ています。つまり、readwrite (読み書き) トランザクションを開き、videos に対するオブジェクト・ストア参照を求め、データベースに追加すべきレコードを表すオブジェクトを作成し、それから、IDBObjectStore.add() を用いてそのオブジェクトを単純に追加しています。

    function storeVideo(mp4Blob, webmBlob, name) {
      // トランザクションを開き、オブジェクト・ストアを求めます。IDB に書き込めるようにするために、これは読み書きトランザクションにしておきます。
      let objectStore = db.transaction(['videos'], 'readwrite').objectStore('videos');
      // IDB に追加するレコードを作成します。
      let record = {
        mp4 : mp4Blob,
        webm : webmBlob,
        name : name
      }
    
      // add() を使ってレコードを IDB に追加します。
      let request = objectStore.add(record);
    
      ...
    
    };
  5. 最後に、displayVideo() があります。これは、UI に動画を挿入するのに必要な DOM 要素を作成してから、それらの DOM 要素をページに追加します。これの一番面白い部分は、以下に示した箇所です。<video> 要素内に動画ブロブを実際に表示するには、URL.createObjectURL() メソッドを使って、オブジェクト URL (メモリに記憶されている動画ブロブを指し示す内部 URL) を作成せねばならないのです。それが済んだら、オブジェクト URL を <source> 要素の src 属性の値として設定できて、物事がうまく機能します。

    function displayVideo(mp4Blob, webmBlob, title) {
      // ブロブからオブジェクト URL を作成します。
      let mp4URL = URL.createObjectURL(mp4Blob);
      let webmURL = URL.createObjectURL(webmBlob);
    
      ...
    
      let video = document.createElement('video');
      video.controls = true;
      let source1 = document.createElement('source');
      source1.src = mp4URL;
      source1.type = 'video/mp4';
      let source2 = document.createElement('source');
      source2.src = webmURL;
      source2.type = 'video/webm';
    
      ...
    }

Offline asset storage

The above example already shows how to create an app that will store large assets in an IndexedDB database, avoiding the need to download them more than once. This is already a great improvement to the user experience, but there is still one thing missing — the main HTML, CSS, and JavaScript files still need to downloaded each time the site is accessed, meaning that it won't work when there is no network connection.

This is where Service workers and the closely-related Cache API come in.

A service worker is a JavaScript file that, simply put, is registered against a particular origin (web site, or part of a web site at a certain domain) when it is accessed by a browser. When registered, it can control pages available at that origin. It does this by sitting between a loaded page and the network and intercepting network requests aimed at that origin.

When it intercepts a request, it can do anything you wish to it (see use case ideas), but the classic example is saving the network responses offline and then providing those in response to a request instead of the responses from the network. In effect, it allows you to make a web site work completely offline.

The Cache API is a another client-side storage mechanism, with a bit of a difference — it is designed to save HTTP responses, and so works very well with service workers.

Note: Service workers and Cache are supported in most modern browsers now. At the time of writing, Safari was still busy implementing it, but it should be there soon.

A service worker example

Let's look at an example, to give you a bit of an idea of what this might look like. We have created another version of the video store example we saw in the previous section — this functions identically, except that it also saves the HTML, CSS, and JavaScript in the Cache API via a service worker, allowing the example to run offline!

See IndexedDB video store with service worker running live, and also see the source code.

Registering the service worker

The first thing to note is that there's an extra bit of code placed in the main JavaScript file (see index.js). First we do a feature detection test to see if the serviceWorker member is available in the Navigator object. If this returns true, then we know that at least the basics of service workers are supported. Inside here we use the ServiceWorkerContainer.register() method to register a service worker contained in the sw.js file against the origin it resides at, so it can control pages in the same directory as it, or subdirectories. When its promise fulfills, the service worker is deemed registered.

  // Register service worker to control making site work offline

  if('serviceWorker' in navigator) {
    navigator.serviceWorker
             .register('/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/sw.js')
             .then(function() { console.log('Service Worker Registered'); });
  }

Note: The given path to the sw.js file is relative to the site origin, not the JavaScript file that contains the code. The service worker is at https://mdn.github.io/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/sw.js. The origin is https://mdn.github.io, and therefore the given path has to be /learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/sw.js. If you wanted to host this example on your own server, you'd have to change this accordingly. This is rather confusing, but it has to work this way for security reasons.

Installing the service worker

The next time any page under the service worker's control is accessed (e.g. when the example is reloaded), the service worker is installed against that page, meaning that it will start controlling it. When this occurs, an install event is fired against the service worker; you can write code inside the service worker itself that will respond to the installation.

Let's look at an example, in the sw.js file (the service worker). You'll see that the install listener is registered against self. This self keyword is a way to refer to the global scope of the service worker from inside the service worker file.

Inside the install handler we use the ExtendableEvent.waitUntil() method, available on the event object, to signal that the browser shouldn't complete installation of the service worker until after the promise inside it has fulfilled successfully.

Here is where we see the Cache API in action. We use the CacheStorage.open() method to open a new cache object in which responses can be stored (similar to an IndexedDB object store). This promise fulfills with a Cache object representing the video-store cache. We then use the Cache.addAll() method to fetch a series of assets and add their responses to the cache.

self.addEventListener('install', function(e) {
 e.waitUntil(
   caches.open('video-store').then(function(cache) {
     return cache.addAll([
       '/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/',
       '/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/index.html',
       '/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/index.js',
       '/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/style.css'
     ]);
   })
 );
});

That's it for now, installation done.

Responding to further requests

With the service worker registered and installed against our HTML page, and the relevant assets all added to our cache, we are nearly ready to go. There is only one more thing to do, write some code to respond to further network requests.

This is what the second bit of code in sw.js does. We add another listener to the service worker global scope, which runs the handler function when the fetch event is raised. This happens whenever the browser makes a request for an asset in the directory the service worker is registered against.

Inside the handler we first log the URL of the requested asset. We then provide a custom response to the request, using the FetchEvent.respondWith() method.

Inside this block we use CacheStorage.match() to check whether a matching request (i.e. matches the URL) can be found in any cache. This promise fulfills with the matching response if a match is not found, or undefined if it isn't.

If a match is found, we simply return it as the custom response. If not, we fetch() the response from the network and return that instead.

self.addEventListener('fetch', function(e) {
  console.log(e.request.url);
  e.respondWith(
    caches.match(e.request).then(function(response) {
      return response || fetch(e.request);
    })
  );
});

And that is it for our simple service worker. There is a whole load more you can do with them — for a lot more detail, see the service worker cookbook. And thanks to Paul Kinlan for his article Adding a Service Worker and Offline into your Web App, which inspired this simple example.

Testing the example offline

To test our service worker example, you'll need to load it a couple of times to make sure it is installed. Once this is done, you can:

  • Try unplugging your network/turning your Wifi off.
  • Select File > Work Offline if you are using Firefox.
  • Go to the devtools, then choose Application > Service Workers, then check the Offline checkbox if you are using Chrome.

If you refresh your example page again, you should still see it load just fine. Everything is stored offline — the page assets in a cache, and the videos in an IndexedDB database.

Summary

That's it for now. We hope you've found our rundown of client-side storage technologies useful.

関連情報

このモジュール内の文書

ドキュメントのタグと貢献者

このページの貢献者: piyo-ko, mfuji09
最終更新者: piyo-ko,