PWA対応で実装したことまとめ (実物あり)

# まえがき
こんにちは！PWAご存知ですか？とてもとても作ってみたかった。
検証兼ねてPWA対応サイト(オンラインツール)を作ってみたので、やったことをまとめました。

実際にリリースしてあるので、参考ついでに是非ご活用頂ければ幸いです！
[画像圧縮のあっしゅくま - https://imguma.com/](https://imguma.com/)

# まずPWAとは
「PWA」とは、「Progressive Web Apps」の略。
ウェブサイトを **アプリのように** 使えるようにしたものです。
この **「アプリのように」** というのが肝。

## PWAの機能、 アプリのようにとは
ざっくり以下のような機能があります。

- **通信しない画面遷移** - AppShell, (SPA)
- **アドレスバー非表示** - Web App Manifest
- **プッシュ通知機能**
- **インストール可能** - Web App Manifest
- **オフライン動作** - Service Worker, Cache storage

## 混同されがちなものの比較
AMPとかWebViewと混同されがちですが別物なので念の為違いは以下。

- **PWA** - アプリのような機能を持たせたWebサイト
- **AMP** - 高速に表示できるようにしたWebページ
- **WebView** - アプリ内にWebを表示させる枠、内部用ブラウザ

# 実装するもの

1. **Web App Manifestの設置** - manifest.jsonを設置する
2. **Service Workerの設置** - sw.jsを作成して設置する
3. **Service Workerの登録** - Webサイトのメインスレッドから/sw.jsを登録する
4. **AppShellとキャッシュの設計** - sw.jsにprecacheとその他キャッシュのロジック作成
5. **アップデート検出** - precacheしたものなどが古いか確認し、更新orお知らせする

※  今回はプッシュ通知は実装してません

# 1. Web App Manifestの設置

Web App Manifest　とは下記のようなjsonファイルです。
これをルートの直下/manifest.jsonに設置します。

```json:manifest.json
{
  "name": "画像圧縮 あっしゅくま",
  "short_name": "あっしゅくま",
  "start_url": "/?utm_source=web_app_manifest",
  "display": "standalone",
  "orientation": "any",
  "background_color": "#fff",
  "theme_color": "#0aa574",
  "icons": [
    {
      "src": "https://xxxx.com/xxxx/launcher-512x512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ]
}

```

| フィールド | 説明 | 
|:---|:--|
| name | 長いアプリ名 |
| short_name | 短いアプリ名 iOSとかAndroidでホームに置くときはこちらが利用される |
| start_url | 起動時のURL GAで計測する用にutm_sourceを付けても良さそう |
| display | fullscreen, standalone, minimal-ui, browserが利用可能。アドレスバーが不要な場合にstandalone、そうでない場合にbrowserで良いかと |
| orientation | landscape, portrait, anyなどが利用可能。縦横気にしない場合はany |
| background_color | 背景色。スプラッシュとかに利用される場合がある |
| theme_color | テーマ色。Androidでタスク切替時画面のアイコンがこの色で囲まれてたりした |
| icons | ホームに追加するときのアプリアイコン |

iconsでホームに追加するときのアプリアイコンとかをカスタマイズ可能。
大きさを様々に指定可能。
スプラッシュでも利用することもできるようで、512px以上のものが1つ以上あったほうが良い様子。

そして、上記ファイルが配置されていることをhtmlのheadに記述します。
ついでに、theme-colorも指定しておくとAndroidでChromeのアドレスバーの色を変えられて一体感が出ます。

```html
<head>
    <!--/* pwa */-->
    <link rel="manifest" href="/manifest.json">
    <meta name="theme-color" content="#0aa574">
</head>
```

# 2. Service Workerの設置

Service Workerである/sw.jsを作成して設置する。
sw.jsの名前はservice-worker.jsとか決まってるわけではなく何でもOK。
Service Workerは、メインスレッドの裏側でキャッシュやリビジョンの管理をするために利用されます。

今回は、簡単にService Workerを作ってくれるツールがありまして、それを利用します。
Googleが開発しているWorkboxというものです。
https://developers.google.com/web/tools/workbox/

Webpackはライブラリとして利用できますし、Webpackプラグインとしても利用できます。
Webpackプラグインを利用すると自動でService Workerのjavascriptファイルを生成してくれます。

workboxのインストール

```
npm install --save-dev workbox-sw
npm install --save-dev workbox-webpack-plugin
```

Webpackプラグインを利用する場合

```javascript
const WorkboxPlugin = require('workbox-webpack-plugin');
const cacheId = 'imguma';
module.exports = [
   {
       plugins: [
            new WorkboxPlugin.GenerateSW({
                cacheId: cacheId,
                swDest: path.join(OUTPUT.rootStaticAbsolutePath, 'sw.js'),
                clientsClaim: true,
                skipWaiting: true,
                offlineGoogleAnalytics: true,
                directoryIndex: '/',
                cleanupOutdatedCaches: true,
            }),
        ]
    }
]
```

もしくは、自分で直接書く場合

```javascript
const le = true;
if (le) console.log('Service Worker Start');

if (le) console.log('import workbox');
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.2.0/workbox-sw.js");

if (le) console.log('skipWaiting');
workbox.core.skipWaiting();

if (le) console.log('clientsClaim');
workbox.core.clientsClaim();

self.addEventListener('install', function(event) {
    if (le) console.log('Service Worker Install', event);
});

self.addEventListener('activate', function(event) {
    if (le) console.log('Service Worker Activate', event);
});

if (le) console.log('Service Worker End');

```

以上を/sw.jsに配置します。

skipWaitingとかclientsClaimといたService Worker独自の単語がでてきます。
skipWaitingは、Service Workerのjsが変更された場合に、既存のService Workerが止まるのを待たずに入れ替える設定です。
clientsClaimは、メインスレッドのリクエストをService Workerですぐに仲介させる設定です。
（↑これだけだとちょっと語弊を含みます）
その他はキャッシュとAppShell用のprecacheの設定です。

# 3. Service Workerの登録

Service Workerのjsファイルを配置しただけでは何も起こりません。
Webサイトのメインのjsの方から、以下のように/sw.jsを登録します。

```javascript
if ('serviceWorker' in navigator) {
    console.log('Found serviceWorker');
    navigator.serviceWorker.register('/sw.js')
        .then((reg) => {
            console.log('Service Worker Registered', reg);
        });
} else {
    console.log('Not Found serviceWorker');
}
```

/sw.jsはmanifest.jsonと同じ位置（対象配下)でないといけないようです。

PWAはhttpsが必須条件です。
サイトのhostが、httpやipadreess直指定の場合はserviceWorkerがいなくなります。
localhostの場合は、httpでもよい。


# 4. AppShellとキャッシュの設計

ここの部分はオフライン動作やパフォーマンスが必要ない場合は必須ではないです。

precachingを利用して、AppShell(htmlやjs,css)などのprecacheと
routingを利用してその他動作中に発生するリクエスト(Web APIとか)をキャッシュさせることができます。

Workboxプラグインを利用する場合は自動でprecacheするリストを作ってくれます。
その他動作中に発生するリクエストはruntimeCachingで設定します。

```javascript
const WorkboxPlugin = require('workbox-webpack-plugin');
const cacheId = 'imguma';
module.exports = [
   {
       plugins: [
            new WorkboxPlugin.GenerateSW({
                cacheId: cacheId,
                swDest: path.join(OUTPUT.rootStaticAbsolutePath, 'sw.js'),
                clientsClaim: true,
                skipWaiting: true,
                offlineGoogleAnalytics: true,
                directoryIndex: '/',
                cleanupOutdatedCaches: true,
                runtimeCaching: [
                    {
                        urlPattern: new RegExp('^' + escapeRegExp('https://storage.googleapis.com/xxxx.appspot.com/static/') + '.*'),
                        handler: 'CacheFirst',
                        options: {
                            cacheName: cacheId + '-cdn-static',
                            expiration: {
                                maxEntries: 255,
                                // maxAgeSeconds: 7 * 24 * 60 * 60
                            },
                            cacheableResponse: { statuses: [0, 200] },
                        }
                    },
                    {
                        urlPattern: new RegExp('^(?:' + ([
                                escapeRegExp('https://fonts.googleapis.com/') + '.*',
                                escapeRegExp('https://fonts.gstatic.com/') + '.*',
                            ].join('|')) + ')$'),
                        handler: 'CacheFirst',
                        options: {
                            cacheName: cacheId + '-google-fonts',
                            expiration: {
                                maxEntries: 255,
                                // maxAgeSeconds: 7 * 24 * 60 * 60
                            },
                            cacheableResponse: { statuses: [0, 200] },
                        }
                    },
                ]
            }),
        ]
    }
]
```

自分で書く場合は、

```javascript

const le = false;
if (le) console.log('Service Worker Start');

if (le) console.log('import workbox');
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.2.0/workbox-sw.js");

/**
 *
 * @param {string} string
 * @return {*}
 */
function escapeRegExp(string) {
    return string.replace(/[.*+?^=!:${}()|[\]\/\\]/g, '\\$&');
}

var cacheId = 'imguma';
var rOrigin = new RegExp('^' + escapeRegExp(location.origin) + '(?:/[^.]+)(?:\\?.*)?$');
var rOriginStatic = new RegExp('^' + escapeRegExp(location.origin) + '(?:/static/.+)(?:\\?.*)?$');
var rCdnStatic = new RegExp('^' + escapeRegExp('https://storage.googleapis.com/xxxx.appspot.com/static/') + '.*');
var rGoogleFonts = new RegExp('^(?:' + ([
    escapeRegExp('https://fonts.googleapis.com/') + '.*',
    escapeRegExp('https://fonts.gstatic.com/') + '.*',
].join('|')) + ')$');

// workbox.navigationPreload.enable();
workbox.core.setCacheNameDetails({prefix: cacheId});

if (le) console.log('skipWaiting');
workbox.core.skipWaiting();

if (le) console.log('clientsClaim');
workbox.core.clientsClaim();

/**
 * The workboxSW.precacheAndRoute() method efficiently caches and responds to
 * requests for URLs in the manifest.
 * See https://goo.gl/S9QRab
 */
self.__precacheManifest = (self.__precacheManifest || []).concat([
    {
        "url": "/",
        "revision": "22e0323b1442c8a76a45bc1180eb5d7f",
    },
    {
        "url": "/static/bundle.css?hash=aaff28c36147f34058261e0847ea0c1d",
    },
    {
        "url": "/static/bundle.js?hash=a5cbbb8a6763bed83b7d7f0a08bc9e12",
    },
]);
if (le) console.log('precache manifest', self.__precacheManifest);

if (le) console.log('precacheAndRoute');
workbox.precaching.precacheAndRoute(self.__precacheManifest, {
    directoryIndex: '/',
    cleanUrls: false,
    ignoreURLParametersMatching: [/^utm_/],
});

if (le) console.log('cleanupOutdatedCaches');
workbox.precaching.cleanupOutdatedCaches();
//workbox.routing.registerNavigationRoute(workbox.precaching.getCacheKeyForURL("/"));

if (le) console.log('registerRoute');
workbox.routing.registerRoute(
    rOrigin,
    new workbox.strategies.NetworkFirst({
        cacheName: cacheId + "-origin",
        plugins: []
    }),
    'GET'
);
workbox.routing.registerRoute(
    rOriginStatic,
    new workbox.strategies.CacheFirst({
        cacheName: cacheId + "-origin-static",
        plugins: []
    }),
    'GET'
);
workbox.routing.registerRoute(
    rCdnStatic,
    new workbox.strategies.CacheFirst({
        cacheName: cacheId + "-cdn-static",
        plugins: [
            new workbox.expiration.Plugin({
                maxEntries: 255,
                purgeOnQuotaError: false
            }),
            new workbox.cacheableResponse.Plugin({
                statuses: [ 0, 200 ]
            })
        ]
    }),
    'GET'
);
workbox.routing.registerRoute(
    rGoogleFonts,
    new workbox.strategies.CacheFirst({
        cacheName: cacheId + "-google-fonts",
        plugins: [
            new workbox.expiration.Plugin({
                maxEntries: 255,
                purgeOnQuotaError: false
            }),
            new workbox.cacheableResponse.Plugin({
                statuses: [ 0, 200 ]
            })
        ]
    }),
    'GET'
);

if (le) console.log('googleAnalytics.initialize');
workbox.googleAnalytics.initialize({});

self.addEventListener('install', function(event) {
    if (le) console.log('Service Worker Install', event);
});

self.addEventListener('activate', function(event) {
    if (le) console.log('Service Worker Activate', event);
});

if (le) console.log('Service Worker End');

```


## Precache (AppShell)
AppShellと呼ばれる、コンテンツとは別で固定表示可能なアプリの枠をキャッシュします。
例えば、トップのバーだったり、ナビゲーションドロワーだったりのhtmlやjs,cssなどは毎回読み込む必要はないですね。
そういったものを予めキャッシュしておいて、それ以降そこから利用します。

Workboxでは、precacheという機能でそれを実装できます。
さきほどのsw.jsでは、self.__precacheManifestの部分に使用するファイルを列挙しておくと、precacheできます。
precacheしたものは、キャッシュ後はそれ以降キャッシュしたままで変更されません。
urlにhashを付けてをユニーク性を担保するか、revisionを指定することで、更新があった際に取替がされるようにする必要があります。
Webpackのプラグインを利用すると、自動でファイル一覧を出力して、revisionも付けてくれたりします。

```javascript
self.__precacheManifest = (self.__precacheManifest || []).concat([
    {
        "url": "/",
        "revision": "22e0323b1442c8a76a45bc1180eb5d7f",
    },
]);
workbox.precaching.precacheAndRoute(self.__precacheManifest, {
    directoryIndex: '/',
    cleanUrls: false,
    ignoreURLParametersMatching: [/^utm_/],
});
```

## その他動作中のリクエストのキャッシュ

例えば、Web APIのレスポンスをキャッシュしたり、コンテンツで使用された画像をキャッシュしたりできます。
WebpackプラグインだとruntimeCaching、自分で書く場合はregisterRouteで設定します。
上記の例では、googleフォントやCloud storageに置いたファイルをキャッシュさせてます。
上記の例のものは、runtimeCachingで書きましたが、どちらかというとprecacheの方が良いです。

```javascript
workbox.routing.registerRoute(
    // ...
)
```

```javascript
runtimeCaching: [
    // ...
]
```


# 5.アップデート検出

Service Workerは、バックグラウンドで自動で更新される仕組み(Background Sync)があります。
ただ、常にjsやcssが最新でないと致命的なアプリでは問題が発生する場合があります。

例えば、次の場合に最新の状態ではなくなります。

## 古いバージョンが読み込まれるパターン
1. PWAの起動が開始(/index.html & /static/bundle.js?hash=1)
2. 既存のServiceWorkerが起動される
3. 既存の/index.htmlと/static/bundle.js?hash=1がServiceWorkerのキャッシュから読み込まれる
4. 新しいServiceWorkerが登録され更新される(serviceWorker.register)
5. 新しいServiceWorkerが最新の/index.htmlと/static/bundle.js?hash=2をprecacheする

これで開かれた/index.htmlと/static/bundle.jsは、まだ古いものです。
次回開かれた際には、新しいものになりますが、今回は古いものです。

この場合に、アップデートがあることをユーザーにお知らせするか、もしくは自動でリロードさせるべきです。

## 検出方法
アップデートの検出は、例えばhtmlに現在のバージョンを記述したり、jsの中にバージョンを記述しておき、

```html
<meta name="data-app-version" content="1.0.0" />
```

```javascript
const appVersion = '1.0.0';
```

/version.json などに最新のバージョンを記述して

```json
{
    "version": "1.0.0"
}
```

/index.htmlが読み込まれた後に、遅延してjsで/version.jsonを読み込み、バージョンが一致しているかを確認します。
バージョンが一致していない場合は、Snackbarなりで「アップデートがあります。再読込みしてください」などと表示して、再読込みボタンを置いておくと良いかなと思います。


# 実装完了

ここまで実装するとどうなるか早速確認

## インストールする

「ホーム画面に追加」や「インストール」といった項目が表示されれば実装成功です。

PCのChromeだと、右上のメニューに「インストールという項目」が表示されます。
インストールすることで、他のアプリケーションと同じようにアイコンがデスクトップやアプリケーション一覧に表示されるようになります。

![pc-chrome.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70150/831d8b6c-fce1-9295-f8db-7a6c80c42095.png)

Android Chromeの場合は、画面下にホーム画面に追加ボタンが表示されます。
（インストールに数十秒くらいかかるので待っていると遅れてホーム画面に表示されます）

![android-chrome.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70150/e95bfd70-c2ac-0ed2-715a-7d0545e3a852.png)

iOS Safariの場合は、PWAに限りませんが、共有ボタンから「ホーム画面に追加」を押すと、PWAとしてホーム画面に追加されます。

![ios-safari.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70150/b2349391-9d33-9bcd-454b-e81e6c24a35e.png)

## 見た目がアプリみたいに

インストールしたあと、ホームやデスクトップにあるアイコンを押して起動すると、アドレスバーが表示されず単独で起動したような振る舞いをします。
そのまま自然に動いてくれるので特にデバッグすることもないかなと思います。

![mac-chrome-standalone.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70150/1643285a-9f56-6258-914e-bcb88781aeb8.png)


## オフライン動作

一度起動して、一旦終了して、wifiを切ってもう一度起動してみてください。
ネットワークに繋いでいなくても、立ち上がり使用することができます。
（ただし、コンテンツが動的なアプリは一度表示したもにのみに限ります）

Chromeのデベロッパーツールの[Application]→[Service Workers]→[Offline]にチェックを入れると擬似的にオフライン状況を作れて、オフラインでのデバッグができます。

## 


# まとめ

やったことは、

* Web App Manifest
* Service Workerの配置と登録
* Service Workerでのキャッシュ制御
* アップデート検出

Service Workerの実装は、Workboxを利用。
Reactで書いていたので、SPAは前提でしたが、SPAでなくても大丈夫です。

↓参考ついでに是非ご活用頂ければ幸いです！
[画像圧縮のあっしゅくま - https://imguma.com/](https://imguma.com/)