既存サイトのPWA化とPush通知基盤を作る（1） - PWA化とService Worker + Workboxでキャッシュをコントロールする

# はじめに
既存サイトをPWA化しServiceWorkerでキャッシュのコントロールとプッシュ通知を受け取れるプロトタイプを作ったのでそれについてまとめました。
Firebaseを利用したプッシュ通知の配信基盤も作ったのでそれについても後日書きます。

追記：
書きました。
[既存サイトのPWA化とPush通知基盤を作る（2） - Firebase Cloud Messaging(FCM) + Cloud Functions + Cloud Firestoreで配信基盤を作る](https://qiita.com/dnrsm/items/a61be461b009201ad971)

# 前提
NuxtやGatsbyなどのフレームワークを使っていないプロジェクトへの導入です。

## 目標
以下の実装をゴールとします。
この記事では上の３つまでをゴールとして進めていきます。

- 既存サイトを**PWA化**する
- **Workbox**を使ってキャッシュをコントロールする
- オフラインで閲覧できるようにする
- プッシュ通知を受信できるようにする
- プッシュ通知の配信基盤をFirebaseで構築する

# PWA化する
まず既存サイトをPWA化していきます。

## PWAとは
- 「Progressive Web Apps」の略
- ネイティブアプリのようなUXを提供する技術
- ホーム画面へ追加するとプッシュ通知を受け取れる
- オフラインでも動作する
- スプラッシュスクリーンを設定したりフルスクリーンで表示などが可能
- ストアを介さずにインストール可能

詳しくは以下を参照してください。
[はじめてのプログレッシブウェブアプリ  |  Web Fundamentals  |  Google Developers](https://developers.google.com/web/fundamentals/codelabs/your-first-pwapp)

## 前提としてやること
- `manifest.json`を用意してhead内で読み込む
- Service Workerを設定・登録する
 - Service Workerは段階的に機能を追加していくことが可能
- SSL対応
 - Service WorkerはHTTPSを介して提供されるページでしか登録できない
- アイコン用画像の用意
 - アイコン画像が読み込まれない状態だとService Workerが有効にならないので注意

## manifest.json
アプリケーションの情報（名前、説明、アイコン）やカラーや初期表示ページのURL等を記述し設定するjsonファイルです。
詳しくは以下を参照してください。
[ウェブアプリマニフェスト | MDN](https://developer.mozilla.org/ja/docs/Web/Manifest)

### 導入する
サイトのルートディレクトリに`manifest.json`を作成します。

```manifest.json
{
  "name": "pwa_prototype",
  "short_name": "pwa_prototype",
  "description": "PWAのプロトタイプサイト",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#000000",
  "theme_color": "#4DBA87",
  "icons": [
    {
      "src": "/img/icons/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/img/icons/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "gcm_sender_id": "103953800507"
}
```

設定した属性の内容は次の通りです。

属性|説明
---|---
name|アプリケーションの名前
short_name|短縮表示用のアプリケーションの名前
description|アプリケーションの説明
start_url  |アプリケーションを起動したときに読み込むURL
display  |表示モードを定義
background_color  |スプラッシュ画面の背景色を指定
theme_color  |アプリケーションの既定のテーマ色
icons  |アプリケーションのアイコンとして機能する画像ファイルの配列を指定
gcm_sender_id| FCMを利用する場合、値は`103953800507`で固定

Google Analytics用のパラメータを埋め込みたい場合は、`start_url`で指定する。
また、Firebase Cloud Messaging(FCM)を利用する場合は、`gcm_sender_id`の値は`103953800507`で固定になるので注意。
この他にも設定できる項目があるので、詳しくは以下を参照してください。
[ウェブアプリマニフェスト | MDN](https://developer.mozilla.org/ja/docs/Web/Manifest)

`manifest.json`を作成したらheadタグで以下のように読み込みます。

```html
<link rel="manifest" href="manifest.json">
```

手で書くのがめんどくさかったら[こんな便利なもの](https://app-manifest.firebaseapp.com/)もある。

### 参考
[ウェブアプリマニフェスト | MDN](https://developer.mozilla.org/ja/docs/Web/Manifest)
[Webアプリマニフェスト|  Webの基礎|  Google Developers](https://developers.google.com/web/fundamentals/web-app-manifest/)
[PWAのmanifest.jsonとiconsの各サイズのアイコン画像を自動生成してくれるApp Manifest Generatorの紹介](https://qiita.com/shisama/items/d4d0b24980beaea57231)

##  「ホーム画面に追加」を表示するには
- 訪問回数が2回以上あり、訪問の間隔が5分以上ある
- 適切に設定された（`short_name`または`name`、`icons`、`start_url`、`display`を含む）`manifest.json`が読み込まれている
- Service Workerに`fetch`イベントを記述する（中身は空でも良い）

```sw.js
self.addEventListener('fetch', function(e) {
  // 中身は空でもOK
})
```

この程度の単純な作業で済みます。デメリットも特になさそうなので、とりあえず実装しておきます。

### 参考
[Add to Home Screen  |  Web Fundamentals  |  Google Developers](https://developers.google.com/web/fundamentals/app-install-banners/)

# Service Workerの準備をする
以下を実現するために準備をしていきます。

- **Workbox**を使ってキャッシュをコントロールする
- オフラインで閲覧できるようにする

## Service Workerとは
- Webページとは別にバックグラウンドで実行するJavaScript
- キャッシュ管理、プッシュ通知、バックグラウンド同期などを行えるようにする基盤技術
- `HTTPS`を介して提供されるページか`localhost`でしか登録できない

詳しくは以下を参照してください。
[Service Worker の紹介  |  Web Fundamentals  |  Google Developers](https://developers.google.com/web/fundamentals/primers/service-workers?hl=ja)

## Service Workerの登録
サイトのルートディレクトリに`sw.js`を作成します。
作成したら下記のようにService Workerとして登録します。

```index.html
<script>
if ('serviceWorker' in navigator) {
  window.addEventListener('load', function () {
    navigator.serviceWorker.register('./sw.js', { scope: './' })
      .then(function (registration) {
        console.log('SW.js registration successful with scope: ', registration.scope);
      }, function (err) {
        console.log('Sw.js registration failed: ', err);
      });
  });
}
</script>
```

## Workboxによるキャッシュのコントロール
それでは実際に`sw.js`にキャッシュのコントロールの処理を実装していきます。

```sw.js
// workboxを読み込む
importScripts('https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js');

// Service Workerライフサイクルをスキップする
workbox.core.skipWaiting();
workbox.core.clientsClaim();

// preload
workbox.navigationPreload.enable();

// Pre cache - Service Workerのインストール時に指定したファイルをキャッシュする
// https://developers.google.com/web/tools/workbox/modules/workbox-precaching
workbox.precaching.precacheAndRoute([
  "/css/style.css",
  "/js/main.js",
  "/js/libs/slick.min.js",
  // リビジョンをつけることも可能
  { url: '/index.html', revision: 'abcd1234' },
]);

// Runtime cache - アクセスしたタイミングで取得したファイルをキャッシュする
// https://developers.google.com/web/tools/workbox/modules/workbox-routing
// font
workbox.routing.registerRoute(
  ({event}) => event.request.destination === 'font',
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: 'font',
    plugins: [
      new workbox.expiration.Plugin({
        maxEntries: 50,
        maxAgeSeconds: 7 * 24 * 60 * 60,
      }),
    ],
  })
);

// image
workbox.routing.registerRoute(
  ({event}) => event.request.destination === 'image',
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: 'image',
    plugins: [
      new workbox.expiration.Plugin({
        maxEntries: 50,
        maxAgeSeconds: 7 * 24 * 60 * 60,
      }),
    ],
  })
);

// js
workbox.routing.registerRoute(
  ({event}) => event.request.destination === 'script',
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: 'js',
    plugins: [
      new workbox.expiration.Plugin({
        maxEntries: 50,
        maxAgeSeconds: 7 * 24 * 60 * 60,
      }),
    ],
  })
);

// css
workbox.routing.registerRoute(
  ({event}) => event.request.destination === 'style',
  new workbox.strategies.StaleWhileRevalidate({
    cacheName: 'css',
    plugins: [
      new workbox.expiration.Plugin({
        maxEntries: 50,
        maxAgeSeconds: 7 * 24 * 60 * 60,
      }),
    ],
  })
);

// ページごと
workbox.routing.registerRoute(
  new RegExp('/item/'),
  new workbox.strategies.StaleWhileRevalidate({
      networkTimeoutSeconds: 3,
      cacheName: 'itempage',
      plugins: [
        new workbox.expiration.Plugin({
          maxEntries: 50,
        }),
      ],
  })
);

self.addEventListener('install', function (event) {
  // empty
});
self.addEventListener('activate', function (event) {
  // empty
});
// インストールバナーを表示する
self.addEventListener('fetch', function (event) {
  // empty
})
```

### キャッシュ戦略
キャッシュ戦略（サービスワーカーのレスポンスの返し方）を設定するために、`Workbox Strategies`を使います。
以下のようなパターンがあるので、それぞれ用途によって使い分ける必要があります。

- Stale-While-Revalidate
 - キャッシュを優先して返す。キャッシュがなければネットワークから返す。さらにネットワークへリクエストしてキャッシュを更新する。
- Cache First
 - キャッシュを優先して返す。キャッシュがあればネットワークへリクエストしない。キャッシュはない場合、ネットワークから返す。
- Network First
 - ネットワークから優先して返す。失敗した場合はキャッシュを返す。
- Network Only
 - ネットワークからのみ返す。
- Cache Only
 - キャッシュのみ返す。

今回の場合は、基本的に`StaleWhileRevalidate`で設定しています。

詳しくは以下を参照。
[Workbox Strategies  |  Google Developers](https://developers.google.com/web/tools/workbox/modules/workbox-strategies)
[PWA: Workbox Strategies 和訳](https://qiita.com/stoneshower/items/4995f2257bde2ba0bba6)

### キャッシュができているか確認する
chromeのデベロッパーツールのApplicationタブのCache Storageを確認。
ちゃんとキャッシュできていることが確認できます。
`cacheName`で指定した名前にファイルが格納されています。
![cash_devtool.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243815/930895d4-7351-9dc7-cfb9-9e08a9bc946d.png)

リロードしてNetworkタブを開くと、Service Workerからキャッシュを返せていることが確認できます。
![cache_network.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243815/8b067071-cabe-adfc-0459-668418925909.png)

ネットワークをoffline状態にしてリロードをしても、Service Workerからキャッシュを返せています。
![workbox02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243815/08ac47e2-dc98-c207-77b7-69afbf134d81.png)

# ここまでのまとめ
これでPWA化とServiceWorkerを登録してWorkboxでキャッシュのコントロールをすることまでが実現できました。
この記事では基本的なキャッシュの処理しかしていませんが、要件によっては処理が複雑になることもあると思います。
とりあえず既存サイトをPWA化させることができました。

# 続
[既存サイトのPWA化とPush通知基盤を作る（2） - Firebase Cloud Messaging(FCM) + Cloud Functions + Cloud Firestoreで配信基盤を作る](https://qiita.com/dnrsm/items/a61be461b009201ad971)