ブログを PWA (Progressive Web Apps) 化した

このブログを PWA (Progressive Web Apps) 化した。
PWA にしてインストール可能にする・Push 通知を送信できるようにするのは以前仕事でやったことがあるのだが、キャッシュを使ってオフラインで閲覧できるようにしたことがなかったので今回はそれをやった。

開発環境

  • PC 動作確認:Microsoft Edge 90.0.818.66
  • Android 動作確認:Android 10; Chrome 90.0.4430.210

本文

PWA とは何? というのは省略する。
プログレッシブウェブアプリ という現在は草案のページに色々書いてある。

PWA をインストール可能にする

まずインストール可能にするのはとても簡単なので、そっちから開始する。

PWA をインストール可能にするには にある通り

  • 正しくフィールドが入力されたウェブマニフェスト
  • 安全な (HTTPS) ドメインから提供されるウェブサイト(脚注:localhost か https ドメインでしか動かない)
  • 端末上のアプリを表すアイコン
  • アプリをオフラインで動作させるために登録されたservice worker (現時点では Android の Chrome にのみ必要です)

を揃えればいい。

詳しい仕様は ウェブアプリマニフェスト にあり、

少なくとも name と、1つ以上のアイコンが定義された icons フィールドがなければなりません。アイコンには、少なくとも src, size, and type がなければなりません。それ以外はすべてオプションですが、description, short_name, start_url フィールドは推奨されます。

とのこと。
また theme_color, background_color を指定することでインストール後に起動時スプラッシュスクリーンが表示されるようになる。

とのこと。
下記が本ブログで作成したウェブマニフェストである。

{
    "name": "daybreak",
    "short_name": "daybreak",
    "description": "個人の技術ブログ",
    "theme_color": "#84b9cb",
    "background_color": "#84b9cb",
    "display": "minimal-ui",
    "scope": "/",
    "start_url": "/",
    "icons": [
        {
          "src": "icons/icon-512x512.png",
          "sizes": "512x512",
          "type": "image/png"
        },
        {
          "src": "icons/icon-256x256.png",
          "sizes": "256x256",
          "type": "image/png"
        },
        {
          "src": "icons/icon-192x192.png",
          "sizes": "192x192",
          "type": "image/png",
          "purpose": "any maskable"
        },
        {
          "src": "icons/icon-144x144.png",
          "sizes": "144x144",
          "type": "image/png"
        },
        {
          "src": "icons/icon-96x96.png",
          "sizes": "96x96",
          "type": "image/png"
        },
        {
          "src": "icons/icon-72x72.png",
          "sizes": "72x72",
          "type": "image/png"
        },
        {
          "src": "icons/icon-48x48.png",
          "sizes": "48x48",
          "type": "image/png"
        },
        {
          "src": "icons/icon-36x36.png",
          "sizes": "36x36",
          "type": "image/png"
        }
    ]
}

注意深く選択しないといけないのは

display: アプリの表示方法 — fullscreen (全画面), standalone (スタンドアロン), minimal-ui , browser (ブラウザー) のいずれかです。

で、アプリっぽくしたいなら standalone なのだが、ブログの場合はブラウザっぽいUIがあるほうがいいので minimal-ui にした。まあ試してお好みで選ぶといいと思う。

ウェブマニフェストは manifest.json という名前でウェブサイトのルートに保存し、ウェブページのヘッダ内で読み込む。

<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#84b9cb">

ここで theme-color を指定しておくと、Android の Chrome でライトテーマを使用している場合はブラウザのアドレスバーの色が変わって統一感が出る。

一応 PC ならこれだけでインストール可能になりそうなのだが、どうせ後で使うのでサービスワーカーも設置する。

空ファイルを sw.js という名前でウェブサイトのルートに保存し、ウェブページ内のどこかで下記のように読み込む。(既に読み込んでいる js ファイルがあるならその中に書いてもOK)

<script>
  if ("serviceWorker" in navigator) {
    window.addEventListener("load", function () {
      navigator.serviceWorker.register("/sw.js").then(
        function (registration) {
          // Registration was successful
          console.log(
            "ServiceWorker registration successful with scope: ",
            registration.scope
          );
        },
        function (err) {
          // registration failed :(
          console.log("ServiceWorker registration failed: ", err);
        }
      );
    });
  }
</script>

これで対応ブラウザならインストール可能になるはず。

オフライン対応

まず参考記事。

オフライン対応自体は上記記事を読んでサービスワーカーを作成すればいい。
考えるべきなのはキャッシュ優先でレスポンスを返すべきなのはどれかである。

いつでもキャッシュ優先のほうが閲覧は早くなるが、今回の対象サイトはブログである。
たとえば記事一覧をキャッシュ優先で返してしまうと、いくら新しい記事を追加しても古いキャッシュが表示され続けるので閲覧者に届かない。
そのためブログの場合、どっちかというと基本はネットワークレスポンスが優先で、特定のレスポンスだけキャッシュ優先にしたほうがいい。

今回は以下のようなキャッシュ戦略にしてみた。(今後変えるかもしれない)

  • css, js, 画像はキャッシュ優先で、キャッシュが無い場合はネットワークレスポンスをキャッシュに追加してから返す
  • その他はネットワークレスポンス優先で、キャッシュに追加してから返す。ネットワークレスポンスが得られなかった場合はキャッシュから返す。

各記事をキャッシュ優先にするかどうかは迷ったのだが、誤字訂正することもよくあるのでやめておいた。
記事内の画像を差し替えることもたまにあるが、そもそも画像を貼ること自体あまり無いのでキャッシュ優先でいいことにする。

ということで下記の内容をさっき作った sw.js に書き込む。

const CACHE_NAME = "cache_v2";

self.addEventListener("fetch", (event) => {
  const requestURL = new URL(event.request.url);

  if (requestURL.origin == location.origin) {
    
    if (/^\/css\/|^\/favicon\/|^\/icons\/|^\/images\/|^\/js\//.test(requestURL.pathname)) {
      event.respondWith(
        (async function () {
          const cache = await caches.open(CACHE_NAME);
          const cachedResponse = await cache.match(event.request);
          if (cachedResponse) return cachedResponse;
          const networkResponse = await fetch(event.request);
          event.waitUntil(cache.put(event.request, networkResponse.clone()));
          return networkResponse;
        })()
      );
      return;
    }
  }
  
  event.respondWith(
    (async function () {
      try {
        const cache = await caches.open(CACHE_NAME);
        const networkResponse = await fetch(event.request);
        event.waitUntil(cache.put(event.request, networkResponse.clone()));
        return networkResponse;
      } catch (err) {
        return caches.match(event.request);
      }
    })()
  );
});

self.addEventListener("activate", function (event) {
  var cacheAllowlist = [CACHE_NAME];

  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames.map(function (cacheName) {
          if (cacheAllowlist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

activate イベントに書いてあるのは、古いバージョンのキャッシュを削除する内容である。
今は CACHE_NAME が “cache_v2” だが、サイトのデザインを大きく変えたとか、ヤバイ記事を消したのでユーザーのキャッシュを破棄したいとかいうときは “cache_v3” というようにキャッシュの名前を変えてやれば、閲覧者が次にサイトを見たときに “cache_v2” の中身を丸っと削除するというわけである。
サービスワーカー自体はサイトを読み込んだとき自動的に差分判定し、差分があれば新しいサービスワーカーを読み込んでくれるとのこと。

しばらくこれで様子を見て、駄目な部分があったら都度変えていこうと思う。

今回はこれで終了。