静的サイトに Service Worker を導入しようとして失敗した話

## はじめに

実験的に自分のポートフォリオサイトにService Worker Cache APIを導入しようとして結局断念しました。
この記事は、この失敗の経験の分析と共有のためのものです。

### 想定する読者

この記事は以下の読者を想定しています。

* Service Workerに興味がある
* まだService Workerを使用したことがない

ほとんどService Workerの知識のない初学者を想定しています。
私自身も今回はじめてServiceWorkerに触れました。同じような経験と知識の方を想定しています。

### 前提とする環境

この記事では、以下のような環境を前提として書かれています。
環境の変化によって内容に齟齬が生じますのでご注意ください。

#### 対象とするサイト

* 小規模な静的サイト

今回は[自分のポートフォリオサイト](https://makino.design/)を実験環境に利用しました。
動的な情報取得や処理がない静的ページです。

#### 開発環境

* macOS v10.13.6
* node.js v8.11.4
* gulp v3.9.1
* gulp-rev v8.1.0
* sw-precache v5.2.1

[sw-precache](https://github.com/GoogleChromeLabs/sw-precache)はGoogle製のNodeモジュールです。開発環境で出力されたファイルをチェックし、CacheAPIに特化したServiceWoker.jsファイルを自動的に生成します。ただし更新頻度が低下しているようで、今から利用する場合は後継にあたる[Workbox](https://developers.google.com/web/tools/workbox/)を利用することをオススメします。

gulpはsw-precacheの処理と静的ファイルのrevision管理を統合するために利用しています。

ServiceWorkerの動作にはhttpsが必須です。[私の記事](https://qiita.com/masato_makino/items/086603f08e0eff67fba6)などを参考にして自己証明書を作成し、開発環境を整えてください。

#### 配信サーバー環境

* AWS S3
* AWS CloudFront
* AWS Route 53

これらのAWSサービスを組み合わせた配信環境を利用しています。

## 想像と実際の挙動

Service WorkerのCacheAPIの目的と役割を理解しないまま漠然と、以下のような機能であると想像していました。

### 想像していた挙動

* オフライン対応
* 高速なキャッシュ処理
* 転送量の削減

しかし実際には、Service WorkerのCacheAPIには想像していたものとは別の目的があり、以下のような挙動をしました。

### 実際の挙動

* htmlの更新がかかるとサイトに反映させるのに手順が増える。
* ServiceWorkerの起動自体にそれなりの時間がかかる。
* キャッシュ対象と読み込みタイミングを事前に計画しないと、総転送量が増加する。

これらの挙動はCacheAPIの目的には合致しており、目的にそった実装を行うことで真価を発揮します。

## Service Worker Cache APIの目的と役割

Service Worker Cache APIの目的は[Service Workerのライフサイクル](https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle?hl=ja)から読み取れます。
このAPIは以下の目的にフォーカスしています。

* Webアプリケーションのオフライン動作（＝オフラインファースト）
* Webアプリケーションの自動バックグラウンド更新
* 高速なWebアプリケーションの起動

これらの目的の達成のために、Service Worker Cache APIは前段の挙動をします。

それぞれの挙動を例に、どのような目的を持ったものなのかを考えていきます。

### html更新の挙動

Service Worker Cache APIには複数の[キャッシュ戦略](https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/?hl=ja)があります。オフラインでもページを問題なく表示させるためには、事前にhtmlとcssなどの依存ファイルをまとめて読み込んでおく[プレキャッシュ](https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/?hl=ja#on-install-as-dependency)の方式を取ることになります。

この方式は、砕けた理解をする場合「Chromeの自動更新っぽいやつ」と考えられます。Chromeを例にすると

* まずアプリケーションをインストールする
* 起動されたらとにかくインストール済みのバージョンで動き出す
* 起動後にバックグラウンドで更新の確認を行う
* 更新バージョンがある場合はバックグラウンドでダウンロードとインストール処理を行う
* 完全にシャットダウンして次に起動した時、更新済みのバージョンで起動する

となります。まずは起動と動作をすることを優先し、更新確認はバックグラウンドで実行されます。誤って複数のバージョンのアプリケーションが並走してしまい、データを破壊することがないように、更新の実行はシャットダウンを確認してから行われます。

sw-precacheで生成されたServiceWorkerとプレキャッシュ対象ファイルもこれによく似た挙動をします。

* 初回アクセス時はまずネットワーク上からリソースを取ってくる
* ServiceWorker.jsをブラウザ内にインストールする
* 2回目以降の訪問時には、まずServiceWorker.jsが動き出す
* とにかくServiceWorkerが抱えているキャッシュでページを起動、表示させる
* 表示後にオンラインならバックグラウンドで更新済みのリソースをダウンロードする
* 更新されたリソースは、ServiceWorkerが完全に終了して次に起動するときに適用される

したがってプレキャッシュの場合、初回インストール後キャッシュ対象に指定しているhtmlを更新すると、それが適用されるのは3回目の訪問時です。2回目の訪問時は初回訪問時のキャッシュでページをとにかくページを表示し、バックグラウンドでキャッシュの更新をしています。

この挙動はオフラインファーストという目標に合致したものです。

### ServiceWorkerの起動

ServiceWorkerは[独立したスレッドで実行](https://developer.mozilla.org/ja/docs/Web/API/ServiceWorker_API/Using_Service_Workers#Basic_architecture)されます。メインスレッドとは独立しており、必要に応じて起動、待機、応答します。
インストール済みのServiceWorkerは、対象となるサイトの読み込みが始まると事前に起動します。この起動の処理には時間がかかります。手元の環境では100ms前後この処理にかかっているようでした。

### 転送量のコントロール

sw-precacheの[staticFileGlobsオプション](https://github.com/GoogleChromeLabs/sw-precache#staticfileglobs-arraystring)で指定されたファイルは、モジュールの名の通り、プレキャッシュを行います。これは表示されるか否かに関係なく、とにかくサーバーから指定されたリソースを事前にバックグラウンドで読み込むという動作です。この挙動はオフライン動作に必須のファイルを読み込むためには優れた方法です。

しかし、無秩序にファイル指定をしてしまうと転送量は増え続けます。実際には表示されないコンテンツもプレキャッシュ対象に指定されれば読み込まれていますので、その分の転送量は増加していることになります。
ユーザーの操作シナリオを想定して、クリックなどの操作を行わないと読み込まれないはずの大きな画像などはプレキャッシュの対象から除外することを検討する必要があります。[ユーザー操作時](https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/?hl=ja#on-user-interaction)や[ネットワーク応答時](https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/?hl=ja#on-network-response)にキャッシュできるかもしれませんし、そもそもキャッシュする必要がないかもしれません。

## 失敗の分析

Cache APIの目的と挙動が理解できてきたところで、現状の問題と、事前に設計を必要とする点が見えてきました。

* 複数の[キャッシュ戦略](https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/?hl=ja)があることを理解する必要がある。
* オフライン対応をする場合、すべてのファイルはオフライン起動に必須か否かの分類をしなくてはいけない。
* オフライン起動に必須でないファイルは、利用と更新の頻度に合わせてキャッシュ戦略を分ける必要がある。
* ブラウザキャッシュとの併用を考慮する必要がある。一時的で永続化する必要のないファイルは従来通りのブラウザキャッシュに任せる。
* 静的サイトのhtmlは各種リソースのトリガーとしての役割と、コンテンツの役割を両方持っている。これらは更新頻度も重要度も異なるものなので、htmlファイル自体とコンテンツは分離するべきである。

## 理想的なService Workerの適用先

結果的に今回の実験的な導入は、転送量が大きく、起動に時間がかかり、htmlの更新確認が遅くなるという散々な結果に終わりました。しかし、この結果からService Worker Cache APIを積極的に導入すべきサイトの形が見えてきました。

### SPA

SPAとService Workerの相性は非常に良いです。もともとコンテンツとhtmlが分離されており、キャッシュ戦略が立てやすい関係にあります。また、オフラインで動作することにも大きな価値があります。

### 機能中心のWebアプリ

コンテンツ中心ではなく、機能中心のWebアプリはService Workerの恩恵を大きく受けられます。ローカルのjavascriptとローカルストレージ内のデータで大半の処理が完結できる場合、Webアプリというよりはたまに更新確認をするローカルのアプリケーションという感じになります。

### 再訪問率が大きい / サイト内巡回の頻度が高いWebアプリ

再訪問率が高く、またサイト内巡回時間が長いサイトほどキャッシュの恩恵を受けられます。
[Pinterest PWAのパフォーマンス改善事例](https://medium.com/@takeshiamano/pinterest-pwa%E3%81%AE%E3%83%91%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%B3%E3%82%B9%E6%94%B9%E5%96%84%E4%BA%8B%E4%BE%8B-8e487d32ac20)
Pinterestの例の場合、各記事はユニークURLを発行していますが、ページ遷移は発生せずSPAであることがわかります。また、サービスの性質上ネットワークから最新のコンテンツを取得し続けるアプリなので、オフライン対応はしていません。Service Workerを永続し制御が可能なブラウザキャッシュとして利用しています。

## TODO
今後の課題として[Workbox](https://developers.google.com/web/tools/workbox/)をまだ利用していないので、今後はこのモジュールでキャッシュ戦略とコンテンツの分類が可能かどうかに挑戦します。[Gulpとの協調動作](https://developers.google.com/web/tools/workbox/guides/generate-service-worker/workbox-build#using_with_gulp)も可能なので、gulp-revをそのままにsw-precacheの置き換えを狙います。

これに懲りずどうにか問題を解決し、別のアプローチ、もしくは別のターゲットでの導入を目指します。

以上、ありがとうございました。