PWA 対応した話

November 01, 2018 7 min read

プログレッシブウェブアプリ(PWA)を公式のチュートリアルを参考にこのサイトでも導入してみた。 あくまで Next(React) への導入記事。

PWA は、上記サイトには

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

と書いてある。

公式サイトに書いてあるメリットを以下にまとめた。

  • インストール不要
  • パソコン・スマホ・タブレット等のマルチプラットフォーム対応
  • ネットワークに繋がっていなくても動作可能
  • Service Worker の更新で最新の状態が保てる
  • HTTPS 経由のみなのでセキュア
  • プッシュ通知を送信可能
  • ネイティブアプリ同様の全画面 UI
  • GPS などの機能に対応

私は、ブラウザの機能を使うことで、インストールしないまま利用できるアプリと解釈した。 インストールが不要=アプリストアを通さなくてもリリースができる点が個人的には Good 。

導入手順はチュートリアルの通り、

  1. manifest.webmanifest を作成・登録
  2. Service Worker を登録
  3. assets をキャッシュ
  4. キャッシュから App Shell を配信

だ。順番に追っていく。

manifest.webmanifest の作成・登録

manifest.webmanifest は PWA 機能を持ったブラウザが見に行く設定ファイルのこと。

まず、このマニフェストを作る。書き方は MDN のリファレンスを参考に書くといいが、参考までにこのサイトの例を置いておく。

{
  "name": "NShun Homepage",
  "short_name": "Shun N",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#333333",
  "theme_color": "#333333",
  "description": "NShun's Homepage",
  "icons": [
    {
      "src": "/static/assets/images/site-icons/icon.png",
      "sizes": "240x240",
      "type": "image/png"
    }
  ]
}

次に、このファイルを登録する。といっても、 HTML ヘッダーに以下の一行を追加するだけなので問題ない。

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

Service Worker を登録

Service Worker とは、従来ネイティブアプリにしかなかった

  • オフライン対応
  • プッシュ通知
  • 定期的なバックグラウンド通信

などの機能をブラウザに持たせる技術のこと。

まず空の sw.js をルートディレクトリに作る。

次に Top ページなどの React コンポーネントに

componentDidMount () {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker
        .register('/sw.js')
        .then(registration => {
          console.log('service worker registration successful')
        })
        .catch(err => {
          console.warn('service worker registration failed', err.message)
        })
    }
  }

を追記して、ページが読み込まれたときに Service Worker を登録するようにする。

assets をキャッシュ

ここからはルートディレクトリに作った sw.js を書いていく。

よく使うファイル (assets) などは最初のロード時にキャッシュさせる。このサイトでは assets に加えて Top と Blog ページも設定した。長くなってしまうが、参考までにこのサイトの例を置いておく。

importScripts('/static/assets/js/cache-polyfill.js');

const version = '0.0.1';
const cacheName = `nshun-${version}`;
self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open(cacheName).then((cache) => {
      return cache
        .addAll([`/`, `/index.html`, `/posts/`, `/static/assets/css/nshun.min.css`, `/static/assets/css/darcula.css`])
        .then(() => self.skipWaiting());
    })
  );
});

self.addEventListener('activate', (e) => {
  e.waitUntil(
    caches.keys().then((keyList) => {
      return Promise.all(
        keyList.map((key) => {
          if (key !== cacheName) return caches.delete(key);
        })
      );
    })
  );
});

一行目のモジュールは、 Service Worker に対応していないブラウザのためのスクリプトを読み込んでいる。これは自分でサイト内に配置する。もとのファイルはここで手に入れた。

Service Worker に変更を加えるときには必ず cacheName を変更し、キャッシュから 最新版のファイルが取得されるようにします。

と書いてあったので、 version 変数を更新することで対応する。

'install' イベントは Service Worker が最初に登録されたときに発火するので、ここで必要な assets をキャッシュに登録している。

'activate' イベントは定期的に発火するので、ここで不要または古いキャッシュを削除している。

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

こう書くと分かりづらいので(公式を批判しているわけではない)、簡単に説明すると「オフラインでも表示されるように設定する」ということ。

オフライン時の表示は、ページに更新があったら発火されるイベント 'fetch' を用いる。上のコードに足す形で以下を書いた。

self.addEventListener('fetch', (e) => {
  e.respondWith(
    caches
      .open(cacheName)
      .then((cache) =>
        cache.match(e.request, {
          ignoreSearch: true,
        })
      )
      .then((response) => {
        return response || fetch(e.request);
      })
  );
});

チュートリアルとは少し違うが、 cache.match() はフィルターの役目をしている。ignoreSearch を有効にすることで、 https://example.com/?value=bar のような検索クエリを無視した URL のキャッシュを利用可能であれば返している。

ん?キャッシュを返しているだけ…?って思ったので読み進めると、以下のように書いてあった。

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

つまり、サイトの更新の度にキャッシュキー(今回で言う version 変数)を更新する必要があるらしい…

(おまけ)キャッシュが更新・追加されるように変更

↑ の問題を解決するために、 return response || fetch(e.request); の部分を以下に変更した。ついでに最初にキャッシュされなかったページもオフライン時に表示できるようにした。

if (response) return response;

var fetchRequest = e.request.clone();
return fetch(fetchRequest).then((response) => {
  if (!response || response.status !== 200 || response.type !== 'basic') return response;

  var responseToCache = response.clone();
  caches.open(cacheName).then((cache) => cache.put(e.request, responseToCache));

  return response;
});

いちいちリクエストとレスポンスを clone しているのは、どちらも Stream なので、ブラウザの表示用とキャッシュ保存用に分けているため。

肝心のキャッシュの更新・追加は cache.put() で行っている。

最後に

デプロイ後、パソコンやスマホ等でオフラインアクセス・ホーム画面に追加を試してもうまく行けば成功。

ホーム画面に追加後、開くときに通常のブックマークのようにしたい場合はマニフェストをリファレンス通り以下のように変えれば OK 。

- "display": "standalone",
+ "display": "browser",

他にもゲームなどで使われる完全なフルスクリーン化の値もあるので試してみると面白い。


Written by Shun Nishimura.

© Copyright 2020 Shun Nishimura - All Rights Reserved