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

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

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

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

作成するもの

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

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

学習する内容

必要なもの

このコードラボではプログレッシブ ウェブアプリに絞って説明するため、いくつかの概念については説明を省略しています。また、単にコピーして貼り付けるだけのコードブロック(スタイルや関連性のない JavaScript)を用意している箇所もあります。

コードをダウンロードする

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

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

ダウンロードした zip ファイルを解凍すると、ルートフォルダ(your-first-pwapp-master)が展開されます。このルートフォルダに、このコードラボの各ステップに対応するそれぞれのフォルダと、必要なすべてのリソースが含まれています。

step-NN フォルダには、このコードラボの各ステップを終えた時点の望ましい状態を示す参考データが含まれています。コーディングの作業はすべて、work という新しいディレクトリで行います。

作業データを準備する

できるだけわかりやすく作業を進められるように、まず新しく index.html というファイルを作成します。また、your-first-pwapp-master プロジェクト フォルダ内に work という新しいディレクトリを作成します(このコードラボでの作業はすべてこのディレクトリで行います)。次に、step-02 の中身をこの新しいフォルダにコピーします。Linux または Mac OS X の場合は、次のコマンドでこの作業を行うことができます。

$ cd your-first-pwapp-master
$ mkdir work
$ cp -r step-02/* work
$ cd work

ウェブサーバーをインストールして確認を行う

ご自分で選んだウェブサーバーを使用することもできますが、このコードラボは Web Server for Chrome で最適に動作するように設計されているため、Web Server for Chrome の使用をおすすめします。このアプリをまだインストールしていない場合は、Chrome ウェブストアからインストールできます。

Web Server for Chrome をインストール

Web Server for Chrome アプリをインストールしたら、Chrome アプリ ランチャーを開きます。

Chrome アプリ ランチャー ウィンドウで Web Server アイコンをクリックします。

次のダイアログが表示されたら、ローカルのウェブサーバーを設定します。

[CHOOSE FOLDER(フォルダを選択)] をクリックし、先ほど作成した work フォルダを選択します。そうすると、上記のダイアログの [Web Server URL(s)(ウェブサーバー URL)] セクションに表示されている URL から作成中のアプリの確認が行えるようになります。

[Options(オプション)] の下で、[Automatically show index.html(index.html を自動的に表示する)] の横のチェックボックスをオンにします。

サーバーを停止して再起動するには、「Web Server: STARTED(ウェブサーバー: 開始済み)」と書かれている切り替えボタンを左に動かし、その後右に戻します。

ではウェブブラウザで作業サイトにアクセスしましょう([Web Server URL(s)(ウェブサーバー URL)])に表示されている URL をクリックします)。次のようなページが表示されます。

この時点ではまだ、アプリで特別なことは行われません。ウェブサーバーの機能を確認するために使用できる最小限の骨組みができているだけです。以降のステップで、機能とユーザー インターフェースを追加していきます。

App Shell とは

App Shell とは、プログレッシブ ウェブアプリのユーザー インターフェースが機能するための最小限の HTML、CSS、JavaScript であり、高いパフォーマンスを発揮するために必要な要素の 1 つです。最初の読み込みは高速で行われ、読み込み後すぐにキャッシュされます。それ以降、毎回の読み込みは行われず、必要なコンテンツだけが取得されます。

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

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

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

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

App Shell を設計する

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

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

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

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

より複雑なアプリを設計する場合は、最初に読み込む必要のないコンテンツは後でリクエストするようにできます。読み込んだコンテンツは、今後の使用に備えてキャッシュします。たとえば、[New City(都市を追加)] ダイアログの読み込みを、初回エクスペリエンスの表示が終わるまで遅らせるとともに、アイドル時の処理を組み込みます。

.

どのようなプロジェクトでも開始にはいくつかの方法がありますが、通常は 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" hidden>
    <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 ブートコードを追加する

ここまでで、ユーザー インターフェースの大半が揃いました。次はすべてが動作するようにコードを組み合わせます。App Shell の他の部分と同様に、中心的なエクスペリエンスを実現するのに重要なコードがどれで、後で読み込むことのできるコードがどれかを意識して作業してください。

今回のブートコードには次の要素が含まれています。

JavaScript コードを追加する

  1. work ディレクトリに scripts フォルダを作成します。
  2. resources/step4 ディレクトリの app.jsscripts フォルダにコピーします。
  3. index.html ファイルに、新たに作成した app.js へのリンクを追加します。具体的には、<!-- Insert link to app.js here -->, の部分を次に置き換えます。
<script src="scripts/app.js" async></script>

テスト

基本の HTML、スタイル、JavaScript が揃ったので、アプリをテストしましょう。この時点で行われる動作は限定的ですが、コンソールにエラーが書き込まれないことを確認してください。

架空の天気データがどのように表示されるかを確認するには、scripts/app.js ファイルの末尾にある次の行のコメントを外します。

// app.updateForecastCard(fakeForecast);

結果として、次のような(架空の)予報カードが表示されるはずです。スピナーは無効になっています。

試す

期待通りに動くことが確認できたら fakeForecast データと app.updateForecastCard(fakeForecast); を削除してください。これらは動作確認のみを目的としたものです。次のステップでは実際のデータで試してみましょう。

.

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

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

このコードラボでは天気予報の静的データをあらかじめ指定します。ただし本番のアプリでは、ユーザーの IP アドレスから判定できる地域情報に基づいて、最新の天気予報データをサーバーから挿入することになります。

scripts/app.js の、即時呼び出しの関数式の内部(上の方にある 'use strict'; 行の後)に、次の JavaScript コードを追加します。

  var initialWeatherForecast = {
    key: 'newyork',
    label: 'New York, NY',
    currently: {
      time: 1453489481,
      summary: 'Clear',
      icon: 'partly-cloudy-day',
      temperature: 52.74,
      apparentTemperature: 74.34,
      precipProbability: 0.20,
      humidity: 0.77,
      windBearing: 125,
      windSpeed: 1.52
    },
    daily: {
      data: [
        {icon: 'clear-day', temperatureMax: 55, temperatureMin: 34},
        {icon: 'rain', temperatureMax: 55, temperatureMin: 34},
        {icon: 'snow', temperatureMax: 55, temperatureMin: 34},
        {icon: 'sleet', temperatureMax: 55, temperatureMin: 34},
        {icon: 'fog', temperatureMax: 55, temperatureMin: 34},
        {icon: 'wind', temperatureMax: 55, temperatureMin: 34},
        {icon: 'partly-cloudy-day', temperatureMax: 55, temperatureMin: 34}
      ]
    }
  };

初回実行時と区別する

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

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

まず、scripts/app.js 内の即時呼び出しの関数式の最後(ファイル末尾にある })(); 行の前)に、ユーザー設定の保存に必要なコードを追加します。

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

次に、スタートアップ コードを追加します。このコードでは、ユーザーが登録している都市があるか確認してその都市を読み込むか、サーバーからのデータを使用します。scripts/app.js ファイル内の、先程追加したコードの後に次のコードを追加しましょう。

  /************************************************************************
   *
   * 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 {
    app.updateForecastCard(initialWeatherForecast);
    app.selectedCities = [
      {key: initialWeatherForecast.key, label: initialWeatherForecast.label}
    ];
    app.saveSelectedCities();
  }

選択した都市を保存する

最後に、ユーザーが新しい都市を追加したときには必ず都市のリストを保存するようにします。それには、butAddCity ボタンのイベント ハンドラ(app.toggleAddDialog(false); 行の直前)に app.saveSelectedCities(); を追加します。

テスト

試す

.

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

Service Worker をよくご存知ない場合は、Service Worker の概要記事をご覧ください。この記事では、Service Worker でできることや、Service Worker のライフサイクルなど、基本事項を説明しています。

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

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

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

登録の手順は次のとおりです。

  1. Service Worker のコードを提供する JavaScript ファイルを作成します。
  2. 作成した JavaScript ファイルを Service Worker として登録するようブラウザに指定します。

まず、アプリケーションのルートフォルダ(your-first-pwapp-master/work)に service-worker.js という空のファイルを作成します。このファイルはアプリケーションのルートに置く必要があります。このファイルが置かれているディレクトリによってService Worker のスコープが定義されるためです。

次に、ブラウザで Service Worker がサポートされているかどうかを確認し、サポートされている場合は Service Worker を登録します。方法は、scripts/app.js ファイルの末尾にある })(); 行の前に、次のコードを追加します。

  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 ファイル(現時点で中身は空です)の末尾に次のコードを追加してください。

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 つでも取得できないものがあると、キャッシュのステップそのものが失敗に終わります。

Service Worker に変更を加えるときには必ず cacheName を変更し、キャッシュから最新版のファイルが取得されるようにします。使わないコンテンツやデータのキャッシュは定期的に削除することが重要です。イベント リスナーを追加して、すべてのキャッシュキーの取得と使われていないキャッシュキーの削除を行う activate イベントを待機します。方法は、service-worker.js ファイルの末尾に次のコードを追加します。

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

最後に、App Shell に必要なファイルのリストを更新しましょう。画像、JavaScript、スタイルシートなど、アプリに必要なすべてのファイルを配列に含めます。それには、service-worker.js ファイルの末尾に次のコードを追加します。

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 を返します。

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

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

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

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

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

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

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

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

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

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

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

こうした特殊なケースを回避するには、sw-precache のようなライブラリを使用します。こうしたライブラリを使用するとデータの有効期限を適切に管理することができます。また、リクエストがネットワークに直接送信されるようになるとともに、あらゆる煩雑な作業から解放されます。

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

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

ヒント:

作業に役立つページ: chrome://serviceworker-internals

Chrome の Service Worker ページ(chrome://serviceworker-internals)を利用すると、既存の Service Worker を停止し、登録を解除して、新たに開始するという一連の操作を簡単に行うことができます。このページでは、Service Worker からデベロッパー ツールを起動して、Service Worker のコンソールにアクセスすることもできます。

テスト

試す

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

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

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

以上の理由から、非同期リクエストを 2 回(キャッシュに 1 回、ネットワークに 1 回)行う必要があります。通常は、キャッシュ データはほぼ瞬時に返され、最近のデータとしてアプリで利用可能になります。そしてネットワークのリクエストが返されると、ネットワークからの最新データを基にアプリが更新されます。

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

Service Worker に対し、Weather API へのリクエストを傍受するように、また後のアクセスを容易にするためその応答を Cache に格納するように変更を加えます。キャッシュ、ネットワークの順にデータを取得する戦略では、ネットワークの応答を「確実な情報源」として想定し、常に最新の情報を提供するものとして位置づけます。ネットワークからデータを取得できない場合は、アプリで最新のキャッシュ データを取得しているので、ネットワークで失敗しても問題はないということになります。

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

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

var dataCacheName = 'weatherData-v1';

次に、fetch イベント ハンドラに変更を加え、データ API へのリクエストを他のリクエストと別に処理できるようにする必要があります。

self.addEventListener('fetch', function(e) {
  console.log('[ServiceWorker] Fetch', e.request.url);
  var dataUrl = 'https://publicdata-weather.firebaseio.com/';
  if (e.request.url.indexOf(dataUrl) === 0) {
    // Put data handler code here
  } else {
    e.respondWith(
      caches.match(e.request).then(function(response) {
        return response || fetch(e.request);
      })
    );
  }
});

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

次に、コードの // Put data handler code here の部分を以下のコードに置き換えます。

    e.respondWith(
      fetch(e.request)
        .then(function(response) {
          return caches.open(dataCacheName).then(function(cache) {
            cache.put(e.request.url, response.clone());
            console.log('[ServiceWorker] Fetched&Cached Data');
            return response;
          });
        })
    );

このアプリはまだオフラインでは動作しません。App Shell のデータのキャッシュと取得を実装しましたが、データをキャッシュできてもまだネットワークに依存している状態です。

リクエストを行う

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

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

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

待機中のリクエストを把握する

まれに、キャッシュよりも先に XHR が応答することがあります。このような場合にキャッシュによってアプリが更新されないように、まずフラグを追加しましょう。scripts/app.js ファイルで、app オブジェクトの定義の先頭に hasRequestPending: false, を追加します(最後のカンマも必ず含めてください)。

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

次に、caches オブジェクトが存在するかどうかを確認し、存在する場合はそこから最新のデータをリクエストします。方法は、app.getForecast, の // Make the XHR to get the data, then update the card というコメントの前に次のコードを追加します。

    if ('caches' in window) {
      caches.match(url).then(function(response) {
        if (response) {
          response.json().then(function(json) {
            // Only update if the XHR is still pending, otherwise the XHR
            // has already returned and provided the latest data.
            if (app.hasRequestPending) {
              console.log('updated from cache');
              json.key = key;
              json.label = label;
              app.updateForecastCard(json);
            }
          });
        }
      });
    }

hasRequestPending フラグを更新する

最後に、app.hasRequestPending フラグを更新します。それには、XHR の作成に関するコメントの直後に app.hasRequestPending = true; を追加し、XHR の応答ハンドラで app.updateForecastCard(response), の直前に app.hasRequestPending = false; と設定します。

これで、お天気アプリでは、キャッシュから 1 回、XHR を介して 1 回、合計 2 回の非同期リクエストが行われるようになりました。キャッシュにデータが存在する場合はそのデータが返され、XHR からの応答がなければキャッシュ データが高速(10s/ms)に表示されてカードが更新されます。その後、XHR から応答があると、Weather API から直接取得した最新のデータを使ってカードが更新されます。

何らかの理由でキャッシュより早く XHR から応答があった場合は、hasRequestPending フラグにより、ネットワークの最新データにキャッシュ データが上書きされる事態が回避されます。

テスト

試す

.

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

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

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

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

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/touch/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }, {
      "src": "images/touch/icon-256x256.png",
      "sizes": "256x256",
      "type": "image/png"
    }],
  "start_url": "/index.html",
  "display": "standalone",
  "background_color": "#3E4EB8",
  "theme_color": "#2F3BA2"
}

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

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

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

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

おすすめの方法

その他の参考資料:

Using app install banners

[ホーム画面に追加] 要素(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">

テスト

試す

.

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

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

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

すべてを埋め込んだ初期リクエストがどれだけ小さいかご確認ください。

その他の参考資料: PageSpeed Insights のルール

Firebase に展開する

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

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

アカウントを作成してログインしたら、いよいよ展開です。

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

その他の参考資料: Firebase Hosting Guide

テスト

試す

.