AMP とは、静的コンテンツから成るページを高速表示するための手法です。一方、プログレッシブ ウェブアプリ(PWA)は、高速で信頼性が高く魅力的なエクスペリエンスを実現するための仕組みです。AMP コンポーネントを使用してウェブサイトで PWA のユーザー エクスペリエンスを提供する方法は複数あります。

作成するもの

このコードラボでは、AMP と PWA を組み合わせて実装する方法を 2 種類紹介します。1 つ目は AMP サイトをプログレッシブ ウェブアプリに変換する方法、2 つ目は App Shell アーキテクチャを使用して AMP サイトを改良する方法です。

学習内容

必要なもの

コードのダウンロード

このコードラボで使うコード一式をダウンロードするには、次のリンクをクリックします。ダウンロード。または Github でプロジェクトのクローンを作成します。

git clone https://github.com/googlecodelabs/amp-pwa.git

この作業ディレクトリに移動します。

Node.js のインストール

このラボでは Node.js(および npm)を使用します。Node.js のウェブサイトで、お使いのオペレーティング システムへのインストール方法をご確認ください。

sw-precache のインストール

このラボでは、Jeffrey Posnick 氏が開発した sw-precache というツールを使用して Service Worker のコードを生成します。sw-precache は次の npm コマンドでインストールできます。

npm install -g sw-precache

localhost にウェブサーバーをインストールする

このコードラボではローカルファイルシステムのコンテンツを提供する必要があるため、serve という Node.js ベースの静的コンテンツ サーバーを使用します。serve は次のコマンドでインストールできます。

npm install -g serve

スターターコードの実行

すべての準備が完了したら、コードラボのソースコードがあるディレクトリに移動して work フォルダ配下でコードを実行します。

cd work
serve

次に Chrome を起動して http://localhost:5000 を開き、AMP のインデックス ページが表示されることを確認します。

Service Worker コードの生成

まず sw-precache を使用して、ロゴ画像などの静的コンテンツをキャッシュする Service Worker を生成します。

work のルートフォルダで sw-precache-config.js という名前のファイルを作成し、そのファイルに次の内容を追加します。

module.exports = {
  staticFileGlobs: [
    'img/**.*'
  ]
};

これにより sw-precache が設定され、images ディレクトリ配下のすべてのファイルをキャッシュする Service Worker が作成されます。

では sw-precache を実行しましょう。

sw-precache --config=sw-precache-config.js

これで、画像をキャッシュするように設定された service-worker.js のスクリプトが生成されます。画像が追加または変更されるたびに、上記のコマンドを実行してスクリプトを再生成する必要があります。

AMP から Service Worker をインストール

service-worker スクリプトを生成しましたが、必要な作業はまだあります。次のステップでは、AMP ページから Service Worker をインストールします。使用するのは、AMP で提供されている amp-install-serviceworker というコンポーネントです。

各 AMP ページの head セクションの一番下に、次の Service Worker JavaScript を追加します。

<script async custom-element="amp-install-serviceworker" 
  src="https://cdn.ampproject.org/v0/amp-install-serviceworker-0.1.js">
</script>

さらに、各 AMP ページの一番下にある </body> タグの直前に、次のコンポーネント設定を追加する必要があります。

<amp-install-serviceworker 
  src="/service-worker.js" 
  layout="nodisplay">
</amp-install-serviceworker>  

AMP キャッシュ用のラッパーを作成

AMP では、JavaScript ファイルを使用して AMP キャッシュから Service Worker をインストールすることができません。これを可能にするには、amp-install-serviceworker コンポーネントに含まれる data-iframe-src という追加属性を利用して、その URL を iframe として読み込む必要があります。

/work/install-service-worker.html というファイルを作成して、次の内容を追加します。

<!doctype html>
<html>
  <head>
    <title>installing service worker</title>
    <script type="text/javascript">
        var swsource = "/service-worker.js";
        if("serviceWorker" in navigator) {
          navigator.serviceWorker.register(swsource)
            .then(function(reg){
              console.log('SW scope: ', reg.scope);
            })
            .catch(function(err) {
              console.log('SW registration failed: ', err);
            });
        };
    </script>
  </head>
  <body>
  </body>
</html>

このページが iframe 内から読み込まれると、このコードは Service Worker の登録を行います。

AMP ファイルの amp-install-serviceworker コンポーネントに戻り、このページへの参照を追加します。

<amp-install-serviceworker 
  src="/service-worker.js" 
  layout="nodisplay"
  data-iframe-src="/install-service-worker.html">
</amp-install-serviceworker>

動作確認

エディタ上ですべてのファイルを保存したら、serve を実行してサーバーを起動します。

Chrome で http://localhost:5000 を開きます。[Chrome Menu] > [More Tools] > [Developer Tools] と選択するか、ショートカット Ctrl+Shift+I(Mac の場合は Cmd+Option+I)を使用して Chrome Developer Tools 起動します。

Chrome Developer Tools 内で、[Applications] タブに移動して [Service Worker] の項目をクリックします。

上のように、Service Worker がインストールされていることを確認できるはずです。

おめでとうございます。

静的コンテンツをキャッシュする Service Worker を作成し、インストールしました。これで、AMP サイトをプログレッシブ ウェブアプリに変換するための第一ステップは完了です。次は、サイトのアクセス時に動的コンテンツ(各 AMP ページなど)をキャッシュできるようにします。

アクセスしたページのキャッシュ

ユーザーがアクセスしたページをキャッシュするには、sw-precache-config.js に別の設定が必要です。次のコードを staticFileGlobs 属性の直後に追加します。

runtimeCaching: [{
  urlPattern: '*',
  handler: (request, values, options) => {
    // If this is NOT a navigate request, such as a request for
    // an image, use the cacheFirst strategy.
    if (request.mode !== 'navigate') {
      return toolbox.cacheFirst(request, values, options);
    }

    // If it's a navigation request, use the networkFirst strategy.
    return toolbox.networkFirst(request, values, options);
  }
}]

runtimeCaching 属性は、ハンドラ構成の配列を受け取ります。この例ではあらゆるリクエストをキャッシュし、request.mode 属性を使用して、それがページのリクエストかリソースのリクエストかを確認し、その結果に応じて異なるキャッシュ方針を採用しています。

カスタムのオフライン ページの追加

現時点では、以前に訪問したページにオフライン状態でアクセスすると、そのページのキャッシュ版が表示されます。一方、それまでに閲覧履歴のないページのリンクをユーザーがクリックした場合は、オフライン ページが表示されます。このオフライン ページをカスタマイズするには、Service Worker を使用します。

offline.html を作成して、次のシンプルなオフライン ページを追加します。

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>The Photo Blog - Offline</title>
  <meta name="viewport"
        content="width=device-width,minimum-scale=1,initial-scale=1">
</head>
<body>
  <h1>You are Offline</h1>
</body>
</html>

sw-precache-config.js を編集します。まずは staticFileGlobs: の静的ファイルのリストにオフラインページを追加して、キャッシュされるようにします。

module.exports = {
  staticFileGlobs: [
    'img/**.*',
    'offline.html'
  ],
...

ハンドラにはすでに、Promise を返すための toolbox.networkFirst 呼び出しがあります。この Promise は、ネットワークが利用できず、キャッシュにファイルが見つからない場合は拒否されます。このようなタイミングで、代わりにオフライン ページを表示するのが最適です。

toolbox.networkFirst メソッド呼び出しのあとに、リクエストの失敗時にオフライン ページを返す catch セクションを追加します。

...
if (request.mode !== 'navigate') {
  return toolbox.cacheFirst(request, values, options);
}

// If it's a request for content, use the networkFirst
// strategy, and send an offline page if both network and
// cache fail.
return toolbox.networkFirst(request, values, options)
  .catch(() => {      
    return caches.match('/offline.html', {ignoreSearch: true});
  });      
...

AMP ランタイムのキャッシュ

オフラインで AMP ページを開こうとすると、コンソール上に大量のエラーが表示されます。

これは、ページとサポート コンテンツはキャッシュ済みですが、AMP ランタイムはキャッシュされていなかったことが原因です。

sw-precache-config.js を編集して runtimeCaching 配列に次の内容を追加します。

{
  urlPattern: /cdn\.ampproject\.org/,
  handler: 'fastest'
}

これにより、AMP ランタイムは fastest 方針でキャッシュされます。この方針では、キャッシュとネットワークの両方に同時にリソースをリクエストして、最初のレスポンス(通常、キャッシュ版が利用できる場合はそちら)を使用します。この場合、より新しいバージョンの AMP ランタイムでキャッシュが更新され、それが次回のリクエスト時に返されます。

完全な sw-precache-config.js は次のようになります。

module.exports = {
  staticFileGlobs: [
    'img/**.*',
    'offline.html'
  ],
  runtimeCaching: [{
    urlPattern: '*',
    handler: (request, values, options) => {
      // If this is NOT a navigate request, such as a request for
      // an image, use the cacheFirst strategy.
      if (request.mode !== 'navigate') {
        return toolbox.cacheFirst(request, values, options);
      }

      // If it's a request for content, use the networkFirst
      // strategy, and send an offline page if both network and
      // cache fail.
      return toolbox.networkFirst(request, values, options)
        .catch(() => {      
          return caches.match('/offline.html', {ignoreSearch: true});
        });      
    }
  }, {
    urlPattern: /cdn\.ampproject\.org/,
    handler: 'fastest'
  }]
};

必ず、次のコマンドで Service Worker を再生成してください。

sw-precache --config=sw-precache-config.js

動作確認

http://localhost:5000 を開いて、ページが読み込まれるまで待機します。

Chrome Developer Tools を起動して、[Applications] タブに移動します。[Cache] のセクションの [Cache Storage] 上で右クリックして、[Refresh] をクリックします。キャッシュをチェックして、そのページに必要なファイルがキャッシュのリストに表示されていることを確認してください。

Service Worker のセクションで [Offline] のチェックボックスをクリックしてページを再読み込みすると、そのページがオフラインでも読み込まれることを確認できます。次に、いずれかの記事へのリンクをクリックします。その記事はまだキャッシュされていないため、カスタムのオフラインページが表示されるはずです。

PWA は、ネイティブ アプリと同様にホーム画面に追加できます。Chrome でこの機能を有効にするには、ウェブ マニフェストを追加します(Safari では <meta> タグを使用、Firefox では独自のマニフェスト フォーマットを使用)。

これを書いている時点では、ホーム画面にアプリを追加可能にするには、以下のマニフェスト属性が必要です。

ウェブ マニフェストの生成

ウェブ マニフェストは手動で記述するとミスが発生しやすいため、オンラインのマニフェスト ジェネレータを使用して作成するとよいでしょう。ここでは https://app-manifest.firebaseapp.com/ にあるものを使ってマニフェストを作成します。

フォームに必要な値を入力します。このコードラボでは次の値を使用します。

次に、このジェネレーターにアイコンをアップロードします。[ICON] ボタンをクリックして work フォルダに移動し、/icons/web_hi_res_512.png アイコンをアップロードします。

[GENERATE ZIP] ボタンをクリックすると、複数サイズのアイコンと全体的なマニフェストを生成してダウンロードできます。

ダウンロードした ZIP ファイルを解凍して自身のプロジェクトにコピーし、manifest.json ファイルが work ディレクトリの直下にくるようにします。

マニフェストのリンク指定

ユーザーが最初にどの AMP ページを見るかはわからないため、各ページからマニフェストにリンクさせる必要があります。

各 AMP ページの head セクションに次のリンクタグを追加します。

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

start_url がキャッシュされていることを確認する

start_url で参照される URL は、オフラインのときでも必ず利用可能でなければなりません。キャッシュを常に最新の状態に保つには、Service Worker のインストール時に URL を事前にキャッシュしておき、networkFirst のキャッシュ方針を採用します。

index.html ページはオフラインページよりも動的な性質を持つため、別のアプローチを使用してページを事前キャッシュする必要があります。そこで sw-precache の拡張性を利用します。

sw-precache-config.js ファイルに、別の設定プロパティを追加します。

importScripts: ['service-worker-import.js']

これにより、生成された Service Worker に対する importScript 呼び出しが追加されます。次に、ルートフォルダに service-worker-import.js ファイルを作成して、次のコードを追加します。

toolbox.precache(['/index.html']);

インポートされたスクリプトが、生成された Service Worker によって呼び出されると、ランタイム キャッシュに index.html が追加されます。これにより、前に作成した既存のハンドラでページを使用できるようになります。

sw-precache を実行して、生成した Service Worker を更新します。

sw-precache --config=sw-precache-config.js

動作確認

Chrome Developer Tools で、[Applications] タブに移動して [Application] セクション配下に [Manifest] の項目があることを確認します。このマニフェストに含まれる情報は、右側に表示されます。

補習:

すでにお気づきかもしれませんが、index.html ページはキャッシュされていますが、記事の画像はキャッシュされていません。コンテンツは事前にキャッシュすることができますが、index ページはその動的な性質により、キャッシュ対象の画像を把握することが困難です。解決策としては、リクエストされた画像が利用できない場合は、フォールバックのオフライン画像を提供する方法があります。具体的な方法については各自で考えてみてください。

AMP で正規版の PWA

おめでとうございます。AMP で正規版の PWA を初めて実装することができました。今回作成したアプリケーションは、オフラインのシナリオにもうまく適応でき、静的アセットと画像のキャッシュによって読み込みのパフォーマンスが最適化されています。さらに、ホーム画面からの起動も可能です。

AMP as PWA で大半のシナリオには対応できると考えられますが、プッシュ通知や Credentials Manager API など、AMP でまだサポートされていない機能が必要な場合もあるでしょう。

このような場合は AMP in PWA のアーキテクチャを採用します。

シェルでリクエストを置き換える

まず App Shell をアプリケーションに追加します。次に Service Worker を変更して、いったん Service Worker をインストールすると、すべてのナビゲーション リクエストを捕捉して、それをシェルで置き換えるようにします。

work フォルダに shell.html というファイルを作成します。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">  
    <title>AMP => PWA Demo</title>
    <style type="text/css">
body{margin:0;padding:0;background:#F5F5F5;font-size:12px;font-weight:300;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif}a{text-decoration:none;color:#000}.header{color:#fff;background:#1976D2;padding:8px 16px;box-shadow:0 2px 5px #999;height:40px;display:flex;align-items:center}.header h1{margin:0 8px 0 0}.header amp-img{margin-right:8px}.header img{margin-right:8px}.header a{color:#fff}.header a:visited{color:#fff} 
    </style>
  </head>
  <body>
    <header class="header">
      <img src="/img/amp_logo_white.svg" width="36" height="36" />
      <h1><a href="/">AMP PWA Codelab - PWA</a></h1>
    </header>    
    <div id="amproot">
      <!-- AMP Content should appear here! -->
    </div>
  </body>
</html>

sw-precache-config.js を更新して、ナビゲート リクエストに対して、作成した shell.html を返すようにします。全体的な設定は次のようになります。

module.exports = {
  staticFileGlobs: [
    'img/**.*',
    'offline.html',
    'shell.html',
    'js/**.js'
  ],
  runtimeCaching: [{
    urlPattern: '*',
    handler: (request, values, options) => {
      // If this is NOT a navigate request, such as a request for
      // an image, use the cacheFirst strategy.
      if (request.mode !== 'navigate') {
        return toolbox.cacheFirst(request, values, options);
      }

      return caches.match('/shell.html', {ignoreSearch: true});  
    }
  }, {
    urlPattern: /cdn\.ampproject\.org/,
    handler: 'fastest'
  }],
  importScripts: ['service-worker-import.js']
};

メイン ハンドラを変更して、すべてのナビゲーション リクエストを App Shell に置き換えるようにしました。

amp-shadow での AMP 読み込み

この手順では、work デレクトリにある JavaScript ファイル js/app.js を使用します。このファイルには、シェルに必要なボイラープレート コードがすでに含まれています。たとえば、バックエンドから AMP ドキュメントを取得するメソッドや、amp-shadow コンポーネントが読み込まれたときに実行されるコードをスケジュールをするための Promise などです。

まずは App Shell の head セクション 内に amp-shadow コンポーネントを追加します。

<!-- Asynchronously load the AMP-Shadow-DOM runtime library. -->
<script async src="https://cdn.ampproject.org/shadow-v0.js"></script>    

シェルの一番下で js/app.js スクリプトをインポートします。

<script src="/js/app.js" type="text/javascript" defer></script>

次に、app.js スクリプトを開いて、AMP コンテンツを追加するために必要な変数を設定するコードを amproot div に追加します。

const ampRoot = document.querySelector('#amproot');
const url = document.location.href;
const amppage = new AmpPage(ampRoot, router);
ampReadyPromise
  .then(() => {
    amppage.loadDocument(url);
  });

Service Worker によって、元々のリクエストが shell.html のコンテンツに置き換えられましたが URL は同じです。この URL からコンテンツを取得して AMP のルートに挿入する必要があります。そのために、ampReadyPromise が解決して URL からコンテンツを読み込むのを待ちます。

数行上にあるプレースホルダを探して loadDocument メソッドを実装しましょう。

loadDocument(url) {
  return this._fetchDocument(url)
    .then(document => {
      router.replaceLinks(document);
      window.AMP.attachShadowDoc(this.rootElement, document, url);            
    });       
}

URL からドキュメントを取得し、それが解決されたら amp-shadow API を呼び出してドキュメントに追加します。

AMP ファイルの内容を操作

App Shell に AMP ページを挿入する場合は、一般的にデベロッパー側で AMP ドキュメントのセクションの追加、削除、変更を行う必要があります。

取得されるドキュメントは通常の DOM ドキュメントなので、必要に応じて操作できます。以下のスニペットでは、すでに App Shell にヘッダーが存在しているため、重複しないように AMP ドキュメントからヘッダー要素を削除しています。

loadDocument(url) {
  return this._fetchDocument(url)
    .then(document => {
      const header = document.querySelector('.header');
      header.remove();
      router.replaceLinks(document);
      window.AMP.attachShadowDoc(this.rootElement, document, url);            
    });       
}

動作確認

Chrome を起動して http://localhost:5000 を開きます。最初のページを開くと、まだ Service Worker がインストールされていないため、ブラウザは AMP ページを表示します。ページを再読み込みすると、App Shell のバージョンが表示されます。

現時点の実装では、Service Worker に非対応のブラウザでサイトを利用するユーザーは、App Shell のエクスペリエンスを享受できません。幸いにも、amp-install-serviceworker コンポーネントにはページ上のリンクを Shell URL に書き換える fallback が備わっています。

AMP ページで、fallback 属性を含めるよう amp-install-serviceworker コンポーネントを更新します。

  <amp-install-serviceworker 
    src="/service-worker.js" 
    layout="nodisplay"
    data-iframe-src="/install-service-worker.html"
    data-no-service-worker-fallback-url-match=".*"
    data-no-service-worker-fallback-shell-url="/shell.html">
  </amp-install-serviceworker>  

これにより、Service Worker に非対応のブラウザを使用しているユーザーが AMP ファイル内のリンクをクリックすると、AMP ランタイムはそのリンクを Shell URL に置き換え、元の URL を #href=<original url> という形式でフラグメントとして追加するようになりました。

つまり、/articles/1.html へのリンクは /shell.html#%2Farticles%2F1.html になります。この変更を App Shell のコードで認識させるために、コンテンツの正しい URL を検出するメソッドを追加しましょう。

...
function getContentUri() {
  const hash = window.location.hash;
  if (hash && hash.indexOf('href=') > -1) {          
    return decodeURIComponent(hash.substr(6));
  }
  return window.location;  
}
...
const ampRoot = document.querySelector('#amproot');
const url = getContentUri();
const amppage = new AmpPage(ampRoot, router);

ユーザーが Service Worker からきている場合は URL フラグメントが存在するため、decodeURIComponent を使用して最終的な URL を抽出できます。URL の内容が Service Worker に置き換えられていた場合は、すでに正しい URL があることになります。url 変数に代入する値は、新しいメソッドへの呼び出しで置き換えられます。

無効な URL の修正

URL 書き換えフォールバックを使用すると、ユーザーが Shell 内のリンクをクリックしたときに、URL バーの URL にフラグメントを付加したシェル URL が表示されるという副作用があります。この問題を回避するには、Page History API を使用します。

ampReadyPromise.then(() => {
  return amppage.loadDocument(url);
})
.then(() => {
  if (window.history) {
    window.history.replaceState({}, '', url);
  }
});

loadDocument Promise が正常に解決されると、シェル URL は読み込んだばかりの URL で置き換えられます。

動作確認

Service Worker に非対応のブラウザを起動します。これを書いている時点では、Windows の Edge や OSX の Safari などが非対応です。http://localhost:5000 を開くと、最初に AMP ページが表示されます。これは、ブラウザでデベロッパー ツールを開いて、ソースコードを見ると確認できます。次に、最初に開いたページのいずれかのリンクをクリックします。URL が書き換えられ、App Shell を使用してページがレンダリングされているはずです。

完了

これで、初めての AMP in PWA の実装は完了です。完成したアプリケーションは AMP で正規版の PWA を実装した場合の利点をすべて備えており、AMP でしか利用できなかった機能も追加可能になっています。

次のステップ

このコードラボでは、AMP を利用したプログレッシブ ウェブアプリの構築によって実現できる内容を少しだけ紹介しました。より高度なエクスペリエンスを提供する場合は、PWA チェックリストに則ってアプリケーションを設計してください。Service Worker の仕組みを詳しく知りたい方は、コードラボのはじめてのプログレッシブ ウェブアプリもご覧ください。