AngularServiceWorkerでユーザーアップロード画像やXHRのキャッシュを行う

この記事は、[「3分クッキング ServiceWorkerで阿部寛さんを超える」](https://qiita.com/studioTeaTwo/items/157e1c380ca63b372dc0)の続きです。

ng buildでServiceWorkerのリソースキャッシュが簡単にできることを説明しましたが、実際のキャッシュ戦略を考えるといろいろ不足があります。

- ユーザーアップロード画像
- CDNに配置しているコンテンツ
- XHRのレスポンス内容

これらはng buildでは生成されないものです。そのための設定方法を紹介します。

# ngsw-config.json

@angular/service-workerでは、ServiceWorkerのスクリプト自体は`ngsw-worker.js`になります。Angularのアプリ開発者はjsonとして用意されているインターフェース`ng-config.json`を設定する形になります。

ng-config.jsonの設定項目の詳細は、Angularドキュメント（日本語版）の[「Service Workerの設定」](https://angular.jp/guide/service-worker-config)も参照ください。

テンプレートは以下です。ng buildの成果物にフォーカスした内容です。

```json:ngsw-config.json
{
  "index": "/index.html",
  "assetGroups": [{
    "name": "app",
    "installMode": "prefetch",
    "resources": {
      "files": [
        "/favicon.ico",
        "/index.html"
      ],
      "versionedFiles": [
        "/*.bundle.css",
        "/*.bundle.js",
        "/*.chunk.js"
      ]
    }
  }, {
    "name": "assets",
    "installMode": "lazy",
    "updateMode": "prefetch",
    "resources": {
      "files": [
        "/assets/**"
      ]
    }
  }]
}
```


# URLでAssetを設定する

index.htmlのlinkタグやscriptタグで設定しているCDNや他の外部URLからロードされるサードパーティのリソースは以下の設定ができます。

Angularでよく使われているであろうMaterialDesignのアイコンフォントファイルを例にします。

```json:ngsw-config.json
{
  "index": "/index.html",
  "assetGroups": [{
    "name": "app",
    "installMode": "prefetch",
    "resources": {
      "files": [
        "/favicon.ico",
        "/index.html"
      ],
      "versionedFiles": [
        "/*.bundle.css",
        "/*.bundle.js",
        "/*.chunk.js"
      ],
      // ここを追加する
      "urls": [
        "https://fonts.googleapis.com/icon?family=Material+Icons"
      ]
    }
  }, {
    "name": "assets",
    "installMode": "lazy",
    "updateMode": "prefetch",
    "resources": {
      "files": [
        "/assets/**"
      ]
    }
  }]
}
```

このURLパターンのものは、ホスト先のHTTPヘッダーにしたがってキャッシュされます。

# 動的なコンテンツのキャッシュはDataGroupで設定する

都度変更されうるXHRのレスポンスやアバター画像のフォルダなどは、AssetGroupのバージョン管理と切り離してキャッシュする仕組みが用意されています。

以下のように`dataGroups`というキーを用意します。ここのセクションは、個々にキャッシュパラメータを定義して、基本的に手動で管理する前提です。

```json:ngsw-config.json
{
  "index": "/index.html",
  "assetGroups": [{
    "name": "app",
    "installMode": "prefetch",
    "resources": {
      "files": [
        "/favicon.ico",
        "/index.html"
      ],
      "versionedFiles": [
        "/*.bundle.css",
        "/*.bundle.js",
        "/*.chunk.js"
      ],
      "urls": [
        "https://fonts.googleapis.com/icon?family=Material+Icons"
      ]
    }
  }, {
    "name": "assets",
    "installMode": "lazy",
    "updateMode": "prefetch",
    "resources": {
      "files": [
        "/assets/**"
      ]
    }
  }],
  // ここを追加する
  "dataGroups": []
}
```

## ユーザーアップロード画像をキャッシュする

アバター画像をAWS S3にアップして、一意に名前が振られたファイルをCloudFrontで配信するケースを例にします。

```json:sw-config.json
  "dataGroups": [{
    "name": "avatar",
    "urls": ["http://hoge.cloudfront.net/*.jpg"],
    "cacheConfig": {
      "maxSize": 1,
      "maxAge": "1d",
      "timeout": "5s",
      "strategy": "performance"
    }
  }]
```

nameは一意になっていればよいです。
cacheConfigが肝になっていて、`maxSize`と`maxAge`が必須です。残り2つは任意であり、現在はこの4種類のパラメータが定義されています。

* `maxSize`は保有する世代数を指定します。例では1つだけを持ちます。
* `maxAge`はキャッシュの有効期間です。例では1日経ったらアプリが要求するとネットワークから取得します。
* `timeout`はネットワークが応答しないときにキャッシュを使うと判断する時間です。例では５秒にしています。次のstrategyと合わせるとこれはmaxAgeが切れた時に有効になるものです。
* `strategy`はキャッシュ戦略です。例ではパフォーマンスを優先してmaxAgeが過ぎるまでは原則キャッシュを利用します。

以上の構成で、アプリからリクエストが発生した時に、初回はネットワークから取得し、以後はオンライン/オフラインに限らずキャッシュを使い、maxAgeが過ぎたら再度ネットワークへフォワードするという定義になります。

## XHRのレスポンスをキャッシュする

users一覧ページのXHRレスポンスを例にします。

```json:sw-config.json
  "dataGroups": [{
    "name": "xhr",
    "urls": [
      "/users",
    ],
    "cacheConfig": {
      "maxSize": 10,
      "maxAge": "1d",
      "timeout": "10s",
      "strategy": "freshness"
    }
  }]
```

今回は`strategy`を`freshness`にしています。freshnessは、基本的にネットワークへ流します。オフラインなどタイムアウトが発生した時のみ、キャッシュを使用します。キャッシュへの切り替え判断は、`timeout`に設定されている10秒です。

## dev.toや日経新聞で話題になった事前キャッシュ（先読み）

dev.toや日経新聞で話題になった事前キャッシュ（先読み）は、XHRのレスポンスだけならアプリ側でhttpリクエストをトリガーしておけばServiceWorkerにキャッシュが入るので可能です。

AMPhtmlファイルをオフラインキャッシュしたいとなると、AMPhtmlをDataGroupに設定してアプリ側はiframeで出力するなどの対応が必要になるかと思います。正直なところ、実装したことないのでどこまでできるか不明です。（ご存知の方がいたら教えてください:bow:）[^1]

# AngularServiceWorkerの注意点

ServiceWorkerは同一スコープ[^2]で一つしかスクリプトを設定できません。つまり、自前のServiceWorkerを作ったり、他のサービス（Firebaseなど）のServiceWorkerと同居することが難しいです。この解決手段として、ServiceWorker内でimportScriptsする手法[^3]がありますが、@angular/service-workerではServieWorker自体にコードを記述することはできません。CLIビルドの度にnode_modulesからngsw-worker.jsをコピーする仕様です。

[^1]: HEADタグまで含めてどうすんだという感じもありますが、shadowDOMでやるという話もあるようです。そうなるとAngularの新レンダリングエンジンivyの機能も絡んでくるかもしれません。
[^2]: スコープとはLocationであり、'/users'にServiceWorkerを登録すると'/users'とその下層のLocationに適用されます。
[^3]: https://github.com/w3c/ServiceWorker/issues/921