プログレッシブ ウェブアプリは、ウェブとアプリの両方の利点を兼ね備えたアプリです。ブラウザのタブで表示してすぐにアクセスでき、インストールの必要はありません。使い続けてユーザーとの関係性が構築されていくにつれ、より強力なアプリとなります。不安定なネットワークでも高速に読み込み、関連性の高いプッシュ通知を送信することができます。また、ホーム画面にアイコンを表示することができ、トップレベルの全画面表示で読み込むことができます。

プログレッシブ ウェブアプリとは

プログレッシブ ウェブアプリには以下の特徴があります。

このコードラボでは、独自のプログレッシブ ウェブアプリを作成する方法について、設計時の考慮事項や実装の詳細など、順を追って説明します。この説明に沿って作業することで、プログレッシブ ウェブアプリの基本原則に則ったアプリを作成することができます。

作成するもの

このコードラボでは、プログレッシブ ウェブアプリの技法を使って天気情報ウェブアプリを作成します。プログレッシブ ウェブアプリの特徴を考えてみましょう。

  • 段階的 - 徐々に機能が強化されていくようにします。
  • レスポンシブ - あらゆるフォーム ファクタに適合するようにします。
  • ネットワーク接続に依存しない - Service Worker で App Shell をキャッシュします。
  • アプリ感覚 - アプリと同様の操作で、都市の追加やデータの更新を行えるようにします。
  • 常に最新 - Service Worker で最新のデータをキャッシュします。
  • 安全 - HTTPS 対応のホストにアプリをデプロイします。
  • 発見しやすく、インストール可能 - マニフェストを指定し、検索エンジンでアプリを簡単に見つけられるようにします。
  • リンク可能 - ウェブページとして活用できるようにします。

学習内容

必要なもの

このコードラボでは、プログレッシブ ウェブアプリに重点を置いて説明します。関連性のない概念やコードブロックについては説明を省略しています。また、単にコピーして貼り付けられるコードブロックを用意している箇所もあります。

コードのダウンロード

このコードラボのすべてのコードをダウンロードするには、次のボタンをクリックします。

ソースコードのダウンロード

ダウンロードした zip ファイルを解凍します。これにより、ルートフォルダ(your-first-pwapp-master)が解凍されます。これには、このコードラボのステップごとに 1 つのフォルダと必要なすべてのリソースが含まれます。

step-NN フォルダには、このコードラボの各ステップの目標となる最終状態が含まれています。これは参照用に用意されています。すべてのコーディング作業は work というディレクトリで行います。

ウェブサーバーのインストールと確認

自分のウェブサーバーを使用できますが、このコードラボは、Chrome Web Server で問題なく動作するように設計されています。このアプリをまだインストールしていない場合は、Chrome ウェブストアからインストールできます。

Web Server for Chrome のインストール

Web Server for Chrome アプリをインストールしたら、ブックマーク バーの [Apps] ショートカットをクリックします。

次のウィンドウで、ウェブサーバー アイコンをクリックします。

次にこのダイアログが表示され、ローカル ウェブサーバーを構成できます。

[choose folder] ボタンをクリックし、work フォルダを選択します。これにより、ウェブサーバー ダイアログ([Web Server URL(s)] セクション)でハイライト表示された URL から進行中の作業を表示できます。

オプションで、以下に示すように [Automatically show index.html] の横のボックスをオンにします。

次に、[Web Server: STARTED] というトグルを左右にスライドしてサーバーを停止して再開します。

ここで、ご利用のウェブブラウザで(ハイライト表示されたウェブサーバー URL をクリックして)作業サイトにアクセスすると、次のようなページが表示されます。

明らかにこのアプリに変わったところはありません。今のところはウェブサーバー機能の確認に使用しているスピナーが表示されているだけです。以降のステップで、機能や UI 機能を追加します。

App Shell とは

App Shell とは、プログレッシブ ウェブアプリのユーザー インターフェースが機能するための最小限の HTML、CSS、JavaScript であり、高いパフォーマンスを発揮するために必要な要素の 1 つです。最初の読み込みは高速で行われ、すぐにキャッシュされます。「キャッシュされる」とは、shell ファイルがネットワークを介して 1 回読み込まれ、ローカル端末に保存されることを意味します。それ以降ユーザーがアプリを開くたびに shell ファイルがローカル端末のキャッシュから読み込まれるため、高速で起動します。

App Shell のアーキテクチャでは、アプリケーションの核となるインフラストラクチャと UI を、データから切り離して扱います。プログレッシブ ウェブアプリの UI とインフラストラクチャはすべて Service Worker によりローカルにキャッシュされます。そのため、以降の読み込み時には、すべてを読み込まなくても必要なデータだけを取得すればよいことになります。

App Shell は、ネイティブ アプリの作成時にアプリストアに公開するコード一式のようなもの、と言うこともできます。これはアプリを起動するために必要な主要構成要素ですが、多くの場合データは含まれません。

App Shell アーキテクチャを使用する理由

App Shell アーキテクチャを採用すると、スピードを追求でき、プログレッシブ ウェブアプリにネイティブ アプリのような特性を持たせることができます。つまり、アプリストアを一切介することなく、瞬時の読み込みや定期的な更新が可能です。

App Shell の設計

最初のステップでは、中心となる構成要素に細分化して設計を検討します。

次のことを考えてみてください。

これから最初のプログレッシブ ウェブアプリとして、天気情報アプリを作ります。主要な構成要素は次のとおりです。

  • タイトル ヘッダー、追加 / 更新ボタン
  • 予報カードのコンテナ
  • 予報カードのテンプレート
  • 都市の追加用ダイアログ ボックス
  • 読み込みインジケーター

さらに複雑なアプリを設計する場合は、最初に読み込む必要のないコンテンツは後でリクエストし、後で使えるようにキャッシュすることもできます。たとえば、新しく都市を追加するダイアログは、アプリの最初の画面を表示した後、アイドル状態になってから読み込むようにすることができます。

プロジェクトの開始にはいくつかの方法がありますが、通常は Web Starter Kit の利用をおすすめしています。ただし今回は、プロジェクトをできるだけ簡単なものにしてプログレッシブ ウェブアプリに集中できるように、必要なリソースをすべてご用意しました。

App Shell の HTML を作成する

App Shell の構築で取り上げた主要な構成要素を追加していきましょう。

今回の構成要素をもう一度確認します。

work ディレクトリにすでにある index.html ファイルは次のような内容です(これは実際のコンテンツのサブセットであるため、このコードをファイルにコピーしないでください)。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Weather PWA</title>
  <link rel="stylesheet" type="text/css" href="styles/inline.css">
</head>
<body>
  <header class="header">
    <h1 class="header__title">Weather PWA</h1>
    <button id="butRefresh" class="headerButton"></button>
    <button id="butAdd" class="headerButton"></button>
  </header>

  <main class="main">
    <div class="card cardTemplate weather-forecast" hidden>
    . . .
    </div>
  </main>

  <div class="dialog-container">
  . . .
  </div>

  <div class="loader">
    <svg viewBox="0 0 32 32" width="32" height="32">
      <circle id="spinner" cx="16" cy="16" r="14" fill="none"></circle>
    </svg>
  </div>

  <!-- Insert link to app.js here -->
</body>
</html>

デフォルトでローダーが表示されることに注目してください。ページが読み込まれるとすぐにローダーがユーザーの目に入り、これからコンテンツが読み込まれることがはっきりわかるようになっています。

時間を節約するために、すぐに使えるスタイルシートも用意しています。

主要な JavaScript アプリコードの確認

ここまでで UI の大半が完成しました。次に、すべてが動作するようにコードを組み合わせます。App Shell の他の部分と同じように、重要な操作を提供するコードはどれで、後で読み込んでも構わないコードはどれであるかを意識して作業してください。

作業ディレクトリには、次のようなアプリコード(scripts/app.js)もすでに含まれています。

テストの実行

これで中心となる HTML、スタイル、JavaScript の追加が終わりました。ここでアプリをテストしてみましょう。

架空の天気データがどのようにレンダリングされるかを確認するには、index.html ファイルの下部にある次の行のコメントを解除します。

<!--<script src="scripts/app.js" async></script>-->

次に、app.js ファイルの下部にある次の行のコメントを解除します。

// app.updateForecastCard(initialWeatherForecast);

アプリを再読み込みします。そうすると、スピナーが無効化され、きれいに整形された予報カード(架空のデータ)が表示されます。

サンプルを見る

実際に試してみて想定どおりに動作していることを確認したら、再度架空データを含む app.updateForecastCard の呼び出しを削除できます。必要なことは、すべてが想定どおりに動作していることを確認するだけです。

プログレッシブ ウェブアプリは、高速に起動してすぐに使えるものでなければなりません。現在の状態では、天気情報アプリは高速に起動しますが、すぐに使えるものにはなっていません。データが読み込まれていないためです。AJAX リクエストを使ってデータを取得することもできますが、それではリクエストを余分に行うことになり、最初の読み込みに時間がかかってしまいます。そこで、最初の読み込みでは実際のデータを指定します。

天気予報データを挿入する

このコードラボでは、サーバーから天気予報が JavaScript に直接挿入されることをシミュレートします。ただし本番のアプリでは、ユーザーの IP アドレスから判定できる位置情報に基づいて、最新の天気予報データをサーバーから挿入することになります。

コードには、挿入するデータがすでに含まれています。それは前のステップで使用した initialWeatherForecast です。

初回実行時との処理を分ける

しかし、この情報を表示するタイミングはどのように判断するのでしょうか。今後、天気情報アプリがキャッシュから取得されて読み込まれるとき、この情報の関連性は失われているかもしれません。ユーザーが次にアプリを読み込むときには都市が変わっている可能性もあります。そのため、これまでに確認された都市に限らず、該当する都市の情報を読み込む必要があります。

ユーザーが登録した都市のリストのようなユーザー設定は、IndexedDB などの高速なストレージ システムを利用してローカルに保存しておく必要があります。できるだけこのコードラボを簡単にするために localStorage を使用しましたが、これは本番のアプリには適していません。localStorage にはブロックを伴う同期の仕組みが使われており、端末によっては著しくスピードが低下する可能性があるためです。

まず、ユーザー設定の保存に必要なコードを追加します。コード内の次の TODO コメントを見つけてください。

  // TODO add saveSelectedCities function here

このコメントの下に次のコードを追加します。

  // Save list of cities to localStorage.
  app.saveSelectedCities = function() {
    var selectedCities = JSON.stringify(app.selectedCities);
    localStorage.selectedCities = selectedCities;
  };

次に、スタートアップ コードを追加します。このコードは、ユーザーが保存している都市があるかどうかを確認し、その都市をレンダリングするか、挿入されたデータを使用します。次のコメントを見つけてください。

  // TODO add startup code here

このコメントの下に次のコードを追加します。

/************************************************************************
   *
   * Code required to start the app
   *
   * NOTE:To simplify this codelab, we've used localStorage.
   *   localStorage is a synchronous API and has serious performance
   *   implications.It should not be used in production applications!
   *   Instead, check out IDB (https://www.npmjs.com/package/idb) or
   *   SimpleDB (https://gist.github.com/inexorabletash/c8069c042b734519680c)
   ************************************************************************/

  app.selectedCities = localStorage.selectedCities;
  if (app.selectedCities) {
    app.selectedCities = JSON.parse(app.selectedCities);
    app.selectedCities.forEach(function(city) {
      app.getForecast(city.key, city.label);
    });
  } else {
    /* The user is using the app for the first time, or the user has not
     * saved any cities, so show the user some fake data.A real app in this
     * scenario could guess the user's location via IP lookup and then inject
     * that data into the page.
     */
    app.updateForecastCard(initialWeatherForecast);
    app.selectedCities = [
      {key: initialWeatherForecast.key, label: initialWeatherForecast.label}
    ];
    app.saveSelectedCities();
  }

スタートアップ コードは、ローカル ストレージに保存されている都市があるかどうかを確認します。ある場合は、ローカル ストレージ データを解析し、保存されている各都市の予報カードを表示します。ない場合は、スタートアップ コードは架空の予報データを使用して、それをデフォルトの都市として保存します。

選択した都市を保存する

最後に、[add city] ボタンハンドラを変更して、選択した都市をローカル ストレージに保存する必要があります。

次のコードに一致するように butAddCity クリック ハンドラをアップデートします。

document.getElementById('butAddCity').addEventListener('click', function() {
    // Add the newly selected city
    var select = document.getElementById('selectCityToAdd');
    var selected = select.options[select.selectedIndex];
    var key = selected.value;
    var label = selected.textContent;
    if (!app.selectedCities) {
      app.selectedCities = [];
    }
    app.getForecast(key, label);
    app.selectedCities.push({key: key, label: label});
    app.saveSelectedCities();
    app.toggleAddDialog(false);
  });

新たに追加されたのは、app.selectedCities の初期化(存在しない場合)、app.selectedCities.push()app.saveSelectedCities() の呼び出しです。

テストの実行

サンプルを見る

プログレッシブ ウェブアプリは、高速に動作し、かつインストール可能でなければなりません。つまり、オンラインでも、オフラインでも、接続が不安定な場所や遅い場所でも動作することが求められます。これを実現するには、Service Worker を使用して App Shell をキャッシュし、常にすばやく利用できる状態を維持する必要があります。

Service Worker をよくご存知ない場合は、Service Worker の概要をご覧ください。この記事では、Service Worker でできることや、Service Worker のライフサイクルがどのように機能するかなど基本事項を説明しています。このコードラボを完了したら、Service Worker のコードラボのデバッグを確認し、Service Worker の操作方法の詳細についてご覧ください。

Service Worker を介して提供する機能は、プログレッシブ エンハンスメントの 1 つとして考えるべきです。こうした機能は、サポートされているブラウザでのみ追加する必要があります。たとえば、Service Worker を使って App Shell とアプリのデータをキャッシュしておけば、オフラインでも利用可能になります。しかし、Service Worker がサポートされていない場合は、オフラインのコードは呼び出さず、最小限のユーザー エクスペリエンスのみを提供します。段階的な機能向上を提供するための機能検出に伴うオーバーヘッドはわずかです。機能をサポートしていない古いブラウザで問題が起こることはありません。

Service Worker が利用可能な場合に登録する

オフラインでもアプリを動作させるために、まず Service Worker を登録します。Service Worker は、ウェブページを開いていなくても、またはユーザーの操作がなくても、バックグラウンドで処理を進めることのできるスクリプトです。

登録は、次の 2 段階の手順で簡単に行うことができます。

  1. JavaScript ファイルを Service Worker として登録するようブラウザに指定します。
  2. Service Worker を含む JavaScript ファイルを作成します。

まず、ブラウザで Service Worker がサポートされているかどうかを確認し、サポートされている場合は Service Worker を登録する必要があります。app.js ファイルに次のコードを追加します。(// TODO add service worker code here コメントの後)。

  if ('serviceWorker' in navigator) {
    navigator.serviceWorker
             .register('./service-worker.js')
             .then(function() { console.log('Service Worker Registered'); });
  }

サイトのアセットをキャッシュする

Service Worker が登録されると、ユーザーがページに初めてアクセスしたときにインストール イベントがトリガーされます。このイベント ハンドラで、アプリケーションに必要なすべてのアセットをキャッシュします。

Service Worker が呼び出されると、caches オブジェクトが開かれ、App Shell の読み込みに必要なアセットが挿入されます。アプリケーションのルートフォルダに service-worker.js というファイルを作成します(your-first-pwapp-master/work ディレクトリに配置)。このファイルはアプリケーションのルートに置く必要があります。Service Worker のスコープはファイルが置かれているディレクトリによって定義されるためです。新しい service-worker.js ファイルに次のコードを追加してください。

var cacheName = 'weatherPWA-step-6-1';
var filesToCache = [];

self.addEventListener('install', function(e) {
  console.log('[ServiceWorker] Install');
  e.waitUntil(
    caches.open(cacheName).then(function(cache) {
      console.log('[ServiceWorker] Caching app shell');
      return cache.addAll(filesToCache);
    })
  );
});

まず、caches.open() でキャッシュを開き、キャッシュに名前を付けます。キャッシュに名前を付けることでファイルのバージョン管理が可能になります。また、データと App Shell を切り離し、お互いに影響を与えることなく個別に更新できるようになります。

キャッシュが開いたら、cache.addAll() を呼び出します。これは URL のリストを受け取り、該当のファイルをサーバーから取得して応答をキャッシュに追加します。残念ながら、cache.addAll() はアトミックな操作であるため、ファイルのうち 1 つでも取得できないものがあると、キャッシュのステップそのものが失敗に終わります。

では、DevTools を使用して Service Worker の基本やデバッグを行う方法について学んでいきましょう。ページを再読み込みする前に DevTools を開き、[Application] パネルの [Service Worker] ペインに移動します。次のように表示されます。

このように空のページが表示される場合は、現在開いているページに登録されている Service Worker がないことを意味します。

ページを再読み込みします。[Service Worker] ペインは次のようになります。

次のような情報が表示された場合は、ページで Service Worker が実行されていることを意味します。

ここで少し脱線して Service Worker の開発時に発生する可能性のある問題点について説明します。説明するために、service-worker.js ファイルの install イベント リスナーの下に activate イベント リスナーを追加します。

self.addEventListener('activate', function(e) {
  console.log('[ServiceWorker] Activate');
});

Service Worker が起動すると activate イベントが発行されます。

DevTools の [Console] を開いてページを再読み込みし、[Application] パネルの [Service Worker] ペインに切り替えて、アクティベートされている Service Worker で [inspect] をクリックします。コンソールに [ServiceWorker] Activate メッセージが出力されることを想定していましたが、何も起こりませんでした。[Service Worker] ペインを確認すると、「待機」状態の新しい Service Worker(activate イベント リスナーを含む)が表示されていることがわかります。

基本的にページに開いたタブがある場合は、古い Service Worker がページの制御を続行しています。ここでページを閉じて再度開くか、[skipWaiting] ボタンを押すことができますが、長期的な解決法は、DevTools の [Service Worker] ペインにある [Update on Reload] チェックボックスを有効にすることです。このチェックボックスが有効になっている場合は、ページが再読み込みされるたびに Service Worker が強制的にアップデートされます。

[Update on Reload] チェックボックスを有効にしてページを再読み込みし、新しい Service Worker がアクティベートされていることを確認します。

注: [Application] パネルの [Service Worker] ペインに以下のようなエラーが表示されることがありますが、問題ないためこのエラーは無視してください。

DevTools での Service Worker の調査とデバッグに関しては以上です。その他のコツについては後で説明します。アプリの作成に戻りましょう。

activate イベント リスナーを展開し、キャッシュをアップデートするいくつかのロジックを含めます。以下のコードに一致するようにコードをアップデートします。

self.addEventListener('activate', function(e) {
  console.log('[ServiceWorker] Activate');
  e.waitUntil(
    caches.keys().then(function(keyList) {
      return Promise.all(keyList.map(function(key) {
        if (key !== cacheName) {
          console.log('[ServiceWorker] Removing old cache', key);
          return caches.delete(key);
        }
      }));
    })
  );
  return self.clients.claim();
});

このコードにより、App Shell ファイルのいずれかが変更されると、Service Worker が常にそのキャッシュをアップデートするようになります。これを行うには、Service Worker ファイルの上部の cacheName 変数を増やす必要があります。

最後の文は、以下の(オプションの)情報ボックスで確認できる特別なケースを修正します。

最後に、App Shell に必要なファイルのリストを更新しましょう。イメージ、JavaScript、スタイルシートなど、アプリに必要なすべてのファイルを配列に含めます。service-worker.js ファイルの上の方にある var filesToCache = []; を以下のコードで置き換えます。

var filesToCache = [
  '/',
  '/index.html',
  '/scripts/app.js',
  '/styles/inline.css',
  '/images/clear.png',
  '/images/cloudy-scattered-showers.png',
  '/images/cloudy.png',
  '/images/fog.png',
  '/images/ic_add_white_24px.svg',
  '/images/ic_refresh_white_24px.svg',
  '/images/partly-cloudy.png',
  '/images/rain.png',
  '/images/scattered-showers.png',
  '/images/sleet.png',
  '/images/snow.png',
  '/images/thunderstorm.png',
  '/images/wind.png'
];

まだアプリはオフラインで動作しません。App Shell の構成要素のキャッシュはできましたが、ローカル キャッシュから読み込む部分を作成する必要があります。

キャッシュから App Shell を配信する

Service Worker を使うと、プログレッシブ ウェブアプリから送信されたリクエストを傍受して Service Worker 内部で処理することができます。つまり、リクエストの処理方法を決めることができ、キャッシュした応答を配信することも可能です。

次に例を示します。

self.addEventListener('fetch', function(event) {
  // Do something interesting with the fetch here
});

では、キャッシュから App Shell を配信してみましょう。service-worker.js ファイルの下部に次のコードを追加します。

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

内側から外側に向かって説明します。まず caches.match() を使用して、fetch イベントをトリガーしたウェブ リクエストを評価し、キャッシュからのデータが利用可能かどうかを確認します。次に、キャッシュ データで応答するか、fetch を使用してネットワークからコピーを取得します。そして、e.respondWith() を使って response をウェブページに返しています。

テストの実行

オフラインでアプリが動作するようになりました。試してみましょう。

ページを再読み込みし、DevTools の [Application] パネルの [Cache Storage] ペインに移動します。このセクションを展開すると、左側に App Shell キャッシュの名前が表示されます。App Shell キャッシュをクリックすると、現在キャッシュされているすべてのリソースを確認できます。

オフライン モードをテストしてみましょう。DevTools の [Service Worker] ペインに戻り、[Offline] チェックボックスを有効にします。有効にすると、[Network] パネルタブの横に黄色の小さな警告アイコンが表示されます。これは、オフラインであることを示しています。

ページを再読み込みすると、動作しています。しかし、まだ完全ではありません。初期(架空)の天気データがどのように読み込まれるかに注目してください。

app.getForecast()else 句を確認し、アプリが架空のデータを読み込むことができる理由を理解します。

次のステップでは、天気データをキャッシュできるようにアプリと Service Worker ロジックを変更し、アプリがオフラインのときにキャッシュから最新データを返すようにします。

ヒント: 保存されているすべてのデータ(localStoarge、indexedDB データ、キャッシュされているファイル)の更新と消去や、Service Worker の削除を開始するには、[Application] タブの [Clear storage] ペインを使用します。

サンプルを見る

特殊なケースに関する注意

繰り返しになりますが、このコードは本番環境では使用しないでください。このコードは、多くの特殊ケースには対応していません。

変更のたびにキャッシュキーの更新が必要

たとえば、このキャッシュ方法では、コンテンツを変更するたびにキャッシュキーを更新する必要があります。そうしないとキャッシュは更新されず、古いコンテンツが配信されることになります。そのため、プロジェクトでの作業中は、変更を行うたびにキャッシュキーを変更するようにしてください。

変更のたびにキャッシュ全体の再ダウンロードが必要

もう 1 つの注意点は、ファイルを変更するとキャッシュ全体が無効になるため、再ダウンロードが必要になるということです。つまり、1 文字のスペルミスを修正しただけでも、キャッシュが無効になり、もう一度全体をダウンロードしなければならなくなります。これはあまり効率的とは言えません。

ブラウザ キャッシュによって Service Worker のキャッシュ更新が妨害される

さらにもう 1 つの注意点があります。インストール処理中に行う HTTPS リクエストはネットワークに直接送信し、ブラウザのキャッシュから応答が返されないようにしなければなりません。そうしないと、キャッシュされた古い応答がブラウザから返され、その結果、Service Worker のキャッシュが更新されなくなります。

本番環境での「キャッシュ優先」戦略の使用

今回のアプリでは「キャッシュ優先」の戦略を使用します。つまり、キャッシュされたコンテンツのコピーがあれば、ネットワークに問い合わせを行わずにキャッシュのコピーを返します。「キャッシュ優先」の戦略は簡単に実装できる一方で、後からさまざまな課題を生む原因になることがあります。ホストページと Service Worker の登録内容のコピーがキャッシュされると、Service Worker の設定を変更することは極めて困難です(設定は定義された場所に依存するため)。また、実装したサイトの更新も非常に複雑になります。

特殊なケースを回避するには

では、どうすればこのような特殊なケースを回避できるでしょうか。sw-precache のようなライブラリを使用します。こうしたライブラリはデータの有効期限を適切に管理し、ネットワークに対して直接リクエストを行ってくれるので、面倒な作業はすべて任せることができます。

運用中の Service Worker をテストする際のヒント

Service Worker のデバッグは困難な場合があります。さらに、キャッシュを使用する場合に想定どおりにキャッシュが更新されないと、さらに解決に苦労することになります。典型的な Service Worker のライフサイクルとコードのバグに挟まれて、行き詰ってしまうでしょう。しかし、解決策はあります。こうした作業を容易にしてくれるツールがあります。

やり直し

キャッシュされたデータを読み込んだり、想定どおりに更新されない場合もあります。保存されているすべてのデータ(localStoarge、indexedDB データ、キャッシュされているファイル)の消去や、Service Worker の削除を行うには、[Application] タブの [Clear storage] ペインを使用します。

その他のヒント:

データに正しいキャッシュ戦略を選択することは重要です。このキャッシュ戦略は、アプリで提供するデータの種類によって決まります。たとえば、天気情報や株価など、時間の経過とともに変動するデータはできるだけ最新のものでなければなりませんが、アバターのイメージや記事のコンテンツなどは更新の頻度が比較的少なくても問題はないと考えられます。

今回のアプリに適しているのは、まずキャッシュ、次にネットワークという優先順でデータを取得する戦略です。この戦略では、画面にとにかく早くデータを表示し、その後ネットワークから最新のデータが返された時点でデータの更新を行います。まずネットワーク、次にキャッシュとした場合、fetch がタイムアウトになってからキャッシュ データが取得されることになり、待ち時間が発生してしまいます。キャッシュ優先の場合はこうした待ち時間がなくなります。

まずキャッシュ、次にネットワークの場合、非同期リクエストをキャッシュに 1 回、ネットワークに 1 回の合計 2 回送信する必要があります。アプリのネットワーク リクエストにはそれほど変更を加える必要はありませんが、Service Worker は応答を返す前にキャッシュを行うよう変更する必要があります。

通常は、キャッシュ データはほぼ瞬時に返され、最近のデータとしてアプリで利用可能になります。そしてネットワークのリクエストが返されると、ネットワークからの最新データを基にアプリが更新されます。

ネットワーク リクエストを傍受して応答をキャッシュする

weather API へのリクエストを傍受するように Service Worker を変更する必要があります。また、応答をキャッシュに格納して後のアクセスを容易にする必要もあります。まずキャッシュ、次にネットワークという戦略でデータを取得する場合、ネットワークの応答を「確実な情報源」と想定し、常に最新の情報を提供するものとして位置づけます。アプリは最新のキャッシュ データを取得済みなので、ネットワークからデータを取得できなくても問題はありません。

Service Worker に dataCacheName を追加し、アプリケーションのデータと App Shell を切り離せるようにしましょう。こうすると、App Shell が更新されて古いキャッシュが消去されても、データは変更されず高速な読み込みに対応できます。なお、将来データ形式が変わった場合は、App Shell とコンテンツの同期を確保しつつ新しい形式に対応する方法が必要になります。

service-worker.js ファイルの先頭に次の行を追加します。

var dataCacheName = 'weatherData-v1';

次に、activate イベント ハンドラをアップデートし、App Shell キャッシュをクリーンアップするときにデータ キャッシュを削除しないようにします。

if (key !== cacheName && key !== dataCacheName) {

最後に、fetch イベント ハンドラをアップデートし、データ API へのリクエストを他のリクエストと別に処理できるようにします。

self.addEventListener('fetch', function(e) {
  console.log('[Service Worker] Fetch', e.request.url);
  var dataUrl = 'https://query.yahooapis.com/v1/public/yql';
  if (e.request.url.indexOf(dataUrl) > -1) {
    /*
     * When the request URL contains dataUrl, the app is asking for fresh
     * weather data.In this case, the service worker always goes to the
     * network and then caches the response.This is called the "Cache then
     * network" strategy:
     * https://jakearchibald.com/2014/offline-cookbook/#cache-then-network
     */
    e.respondWith(
      caches.open(dataCacheName).then(function(cache) {
        return fetch(e.request).then(function(response){
          cache.put(e.request.url, response.clone());
          return response;
        });
      })
    );
  } else {
    /*
     * The app is asking for app shell files.In this scenario the app uses the
     * "Cache, falling back to the network" offline strategy:
     * https://jakearchibald.com/2014/offline-cookbook/#cache-falling-back-to-network
     */
    e.respondWith(
      caches.match(e.request).then(function(response) {
        return response || fetch(e.request);
      })
    );
  }
});

このコードは、リクエストを傍受して URL の先頭が weather API のアドレスかどうかを確認しています。この条件に該当する場合、fetch を使用してリクエストを行います。応答が返されたらキャッシュを開き、応答をコピーして格納した後、リクエストの送信元に応答を返します。

まだアプリはオフラインで動作しません。App Shell のデータのキャッシュと取得を実装しましたが、データをキャッシュできても、アプリでは天気データがあるかどうかキャッシュを確認しません。

リクエストの実行

前に説明したとおり、アプリではキャッシュに 1 回、ネットワークに 1 回、合計 2 回の非同期リクエストを送信する必要があります。アプリでは window で利用可能な caches オブジェクトを使ってキャッシュにアクセスし、最新のデータを取得します。これはプログレッシブ エンハンスメントを実装する場合の良い例です。caches オブジェクトはすべてのブラウザで利用できるとは限りません。このオブジェクトが利用できないときは、依然としてネットワーク リクエストを行う必要があります。

必要な手順は次のとおりです。

  1. グローバルな window オブジェクトにおいて、caches オブジェクトが利用可能かどうかを確認します。
  2. キャッシュにデータをリクエストします。
  1. サーバーにデータをリクエストします。

キャッシュからデータを取得する

次に、caches オブジェクトが存在するかどうかを確認し、存在する場合はそこから最新のデータをリクエストします。app.getForecast() 内の TODO add cache logic here コメントを見つけ、そのコメントの下に以下のコードを追加します。

    if ('caches' in window) {
      /*
       * Check if the service worker has already cached this city's weather
       * data.If the service worker has the data, then display the cached
       * data while the app fetches the latest data.
       */
      caches.match(url).then(function(response) {
        if (response) {
          response.json().then(function updateFromCache(json) {
            var results = json.query.results;
            results.key = key;
            results.label = label;
            results.created = json.query.created;
            app.updateForecastCard(results);
          });
        }
      });
    }

これで、天気情報アプリでは、cache から 1 回、XHR を介して 1 回、合計 2 回の非同期リクエストが行われるようになりました。キャッシュにデータが存在する場合はそのデータが返され、XHR からの応答がなければキャッシュ データが高速(数十ミリ秒)でレンダリングされてカードがアップデートされます。その後、XHR から応答があると、weather API から直接取得した最新のデータを使ってカードがアップデートされます。

キャッシュ リクエストと XHR リクエストの両方が予報カードをアップデートする呼び出しでどのように終了するかに注目してください。アプリは最新データが表示されているかどうかをどのように判断しますか。これは app.updateForecastCard からの次のコードで処理されます。

    var cardLastUpdatedElem = card.querySelector('.card-last-updated');
    var cardLastUpdated = cardLastUpdatedElem.textContent;
    if (cardLastUpdated) {
      cardLastUpdated = new Date(cardLastUpdated);
      // Bail if the card has more recent data then the data
      if (dataLastUpdated.getTime() < cardLastUpdated.getTime()) {
        return;
      }
    }

カードがアップデートされるたびに、アプリはカードに非表示の属性でデータのタイムスタンプを保存します。カードにすでに存在するタイムスタンプが関数に渡されたデータより新しい場合は、アプリは破棄します。

テストの実行

アプリはオフラインで完全に動作するようになりました。数個の都市を保存し、最新の天気データを取得するためにアプリの更新ボタンを押してからオフラインにし、ページを再読み込みします。

次に、DevTools の [Application] パネルの [Cache Storage] ペインに移動します。このセクションを展開すると、左側に App Shell とデータ キャッシュの名前が表示されます。データ キャッシュを開くと、それぞれの都市に対して保存されているデータが表示されます。

サンプルを見る

モバイルのキーボードで長い URL を入力する操作は、必要ないのであれば誰もやりたくないはずです。ホーム画面に追加する機能があれば、ユーザーはアプリストアからネイティブ アプリをインストールしたときと同じようなショートカット リンクを端末に追加でき、しかもその際の手間は大幅に少なくて済みます。

ウェブアプリのインストール バナーとホーム画面への追加(Android 向け Chrome)

ウェブアプリのインストール バナーを用意しておくと、ユーザーはホーム画面にウェブアプリをすばやく追加でき、アプリを容易に起動したり再開したりできるようになります。アプリのインストール バナーの追加は簡単で、煩雑な作業はほとんど Chrome が処理してくれます。必要なのは、アプリの詳細を含むウェブアプリのマニフェスト ファイルを指定することだけです。

Chrome では、Service Worker の使用状況、SSL ステータス、アクセス頻度のヒューリスティックといった一連の条件に基づいて、バナー表示のタイミングが自動的に判定されます。これに加えて、ユーザーは、Chrome の [Add to Home Screen] メニューボタンを使用してリンクを手動で追加できます。

manifest.json ファイルでアプリのマニフェストを宣言する

ウェブアプリのマニフェストはシンプルな JSON 形式のファイルです。デベロッパーはこのファイルを使用して、想定される表示場所(モバイルのホーム画面など)でのアプリの表示方法と、ユーザーが起動できる項目、そして起動の方法を指定できます。

ウェブアプリ マニフェストを使用すると、ウェブアプリでは以下のことが可能になります。

work フォルダに manifest.json というファイルを作成し、次の内容をコピーして貼り付けます。

{
  "name":"Weather",
  "short_name":"Weather",
  "icons": [{
    "src": "images/icons/icon-128x128.png",
      "sizes":"128x128",
      "type": "image/png"
    }, {
      "src": "images/icons/icon-144x144.png",
      "sizes":"144x144",
      "type": "image/png"
    }, {
      "src": "images/icons/icon-152x152.png",
      "sizes":"152x152",
      "type": "image/png"
    }, {
      "src": "images/icons/icon-192x192.png",
      "sizes":"192x192",
      "type": "image/png"
    }, {
      "src": "images/icons/icon-256x256.png",
      "sizes":"256x256",
      "type": "image/png"
    }],
  "start_url": "/index.html",
  "display": "standalone",
  "background_color": "#3E4EB8",
  "theme_color": "#2F3BA2"
}

マニフェストは、さまざまな画面サイズでアイコンの配列をサポートします。この記事の執筆時には、Chrome、Opera モバイル、ウェブアプリのマニフェストをサポートするブラウザのみが 192 ピクセルより小さいものを使用していません。

アプリの起動方法を調べる簡単な方法は、start_url パラメータにクエリ文字列を追加し、解析スイートを使ってこのクエリ文字列を追跡することです。この方法を使う場合は、App Shell によってキャッシュされたファイルのリストを必ずアップデートして、クエリ文字列を含むファイルがキャッシュされるようにします。

ブラウザにマニフェスト ファイルを指定する

index.html ファイルの <head> 要素の下部に次の行を追加します。

<link rel="manifest" href="/manifest.json">

ベストプラクティス

参考資料:

アプリのインストール バナーの使用

ホーム画面への追加(iOS 向け Safari)

index.html<head> 要素の下部に次のコードを追加します。

  <!-- Add to home screen for Safari on iOS -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
  <meta name="apple-mobile-web-app-title" content="Weather PWA">
  <link rel="apple-touch-icon" href="images/icons/icon-152x152.png">

タイルアイコン(Windows)

index.html<head> 要素の下部に次のコードを追加します。

  <meta name="msapplication-TileImage" content="images/icons/icon-144x144.png">
  <meta name="msapplication-TileColor" content="#2F3BA2">

テストの実行

このセクションでは、ウェブアプリのマニフェストをテストするいくつかの方法を紹介します。

1 つ目の方法は DevTools です。[Application] パネルの [Manifest] ペインを開きます。マニフェスト情報を正確に追加した場合は、このペインに人間が理解できる形式で解析および表示されていることがわかります。

また、このペインからホーム画面に追加する機能もテストできます。[Add to homescreen] ボタンをクリックします。以下のスクリーンショットのように、URL バーの下に「add this site to your shelf」というメッセージが表示されます。

これは、モバイルのホーム画面に追加する機能に相当するデスクトップ用のものです。PC でのこのプロンプトのトリガーに成功すると、モバイル ユーザーは自分の端末にアプリを追加できるようになります。

2 つ目の方法は、Web Server for Chrome を介してテストすることです。この方法では、他のコンピュータに自分のローカルの開発サーバー(デスクトップまたはノートパソコン)を公開し、実際のモバイル端末からプログレッシブ ウェブアプリにアクセスします。

[Web Server for Chrome] 構成ダイアログで、[Accessible on local network] オプションを選択します。

ウェブサーバーを [STOPPED] に切り替えてから [STARTED] に切り替えます。リモートからのアプリのアクセスに使用できる新しい URL が表示されます。

新しい URL を使用してモバイル端末からサイトにアクセスします。

この方法でテストするとコンソールに Service Worker のエラーが表示されます。これは、Service Worker は HTTPS を介して提供されていないからです。

Android 端末から Chrome を使用して、アプリをホーム画面に追加する操作を試し、起動画面やアイコンが正しく表示されることを確認します。

Safari や Internet Explorer では、アプリを手動でホーム画面に追加することもできます。

サンプルを見る

最後のステップとして、天気情報アプリを HTTPS 対応のサーバーにデプロイします。このようなサーバーがない場合、一番簡単でコストがかからないのは、静的コンテンツとして Firebase にホストする方法です。Firebase は、簡単に使えて、HTTPS 経由のコンテンツ配信に対応しており、さらに、グローバルな CDN がバックエンドとなっています。

補習: CSS を最小化して埋め込む

もう 1 点、主なスタイルを最小限のコードで指定し、index.html に直接埋め込むことを検討しましょう。Page Speed Insights では、リクエストの最初の 15k バイトで、スクロールせずに見える範囲にコンテンツを配信することを推奨しています。

すべてを埋め込んだ初期リクエストをどのくらい小さくできるか、試してみてください。

参考資料: PageSpeed Insight ルール

Firebase にデプロイする

Firebase を初めて使用する場合は、まずアカウントを作成し、いくつかのツールをインストールする必要があります。

  1. https://firebase.google.com/console/ で Firebase のアカウントを作成します。
  2. 次の npm コマンドを使って、Firebase のツールをインストールします。npm install -g firebase-tools

アカウントを作成してログインしたら、いよいよデプロイします。

  1. https://firebase.google.com/console/ で新しいアプリを作成します。
  2. Firebase のツールに最近ログインしていない場合は、次のコマンドでログインして認証情報をアップデートします。firebase login
  3. 次のコマンドを使って、アプリを初期化するとともに完成版のアプリが置かれているディレクトリ(work など)を指定します。firebase init
  4. 最後に、次のコマンドを使ってアプリを Firebase にデプロイします。firebase deploy
  5. これで完了です。次のドメインにアプリをデプロイできました。https://YOUR-FIREBASE-APP.firebaseapp.com

参考資料: Firebase Hosting Guide

テストの実行

サンプルを見る