はじめに

Webkit の ServiceWorker (以下 SW) in develop 事件以来、SW にも希望が持てるようになってきました。PWA という言葉もちょっとバズワードっぽくなっているのでここで PWA というワードもさり気なく入れてみます。
Nagisa でも マンガZERO というサービスの Web 版で Workbox を使って SW を使ったクライアントキャッシュを導入してみたので、Workbox の使い方など紹介します。

環境

マンガZERO Web の環境はざっくりこんな感じです。

  • Vue.js 2.0
  • Vuex
  • SPA
  • Webpack でビルド
  • Express で SSR
  • UA 判定でのデバイス切り替え (PC・SP)

Webpack などビルドツールを使っているのであれば、バンドルされたファイルを自動的にキャッシュ対象にしてくれるプラグインがあるので便利です。
いい感じに使えそうなのはこの辺の3つ。

offline-plugin は圧倒的にスターが多いですが設定の柔軟性と Webpack への依存度の面でマイナスです。
sw-precache-webpack-plugin は Google 製の sw-precachesw-toolbox のラッパーになっていてスターは少ないけど実はよくできています。
3つめ目の Workbox も Google が中心になって開発しているライブラリで、sw-precache と sw-toolbox の機能を含んだ発展版になっています。sw- は Deprecated にはなっていないものの、開発は Workbox に移行しているので新しく導入するなら Workbox がおすすめと言われています。
キャッシュだけなら sw-precache が扱いやすいですが、将来のことを考え今回は Workbox を導入することにしました。

設定

扱えるキャッシュはざっくり Pre cache と Runtime cache の2種類あって、それぞれ名前の通り Pre cache は SW インストール時にキャッシュし、Runtime cache はフェッチイベント時にキャッシュされます。
Webpack で Workbox を扱う時に generateSW モードと injectManifest モードがあります。
なるべくなら generateSW モードを使ったほうが手間がかなり少ないのでできれば前者を使えるとハッピーです。それぞれの紹介は後ほど。

全ての Workbox オプションは こちら

まずはとりあえずインストール。

$ npm install workbox-webpack-plugin --save-dev

generateSW (ベーシックな使い方)

Webpack の設定ファイルで以下の様な感じに設定します。plugins の中で一番後に書きましょう。また Webpack の dev server や hot reload と基本的には相容れないので、production ビルド時のみ有効にしておくとよいです。
Webpack でバンドルされるファイルは globPatterns に従って Pre cache 対象になるので特に何も意識することなく使えます。
さらに、Runtime cache としてトップページ、api (json) と一部の画像をキャッシュします。
サンプルの様に runtimeCaching に突っ込んでいけばよいです。CloudFront など異ドメインのファイルをキャッシュさせたい場合はドメイン等含むフルパスで指定する必要があります。ドメイン等含まないルールの場合、キャッシュ対象は同一ドメインのファイルのみに絞られます。
handler にはキャッシュの Strategies (キャッシュとネットワークの優先度ルール) を指定します。Workbox のドキュメントと Google の The Offline Cookbook を見ると分かりやすいです。

// webpack.config.client.js
const WorkboxBuildWebpackPlugin = require('workbox-webpack-plugin');

module.exports = {
...
plugins: [
...
new WorkboxBuildWebpackPlugin({
cacheId: 'manga-zero',
globPatterns: ['**/*.{js,css}'],
globIgnores: ['**/*.pc*'],
swDest: './dist/js/service-worker.js',
skipWaiting: true,
clientsClaim: false,
runtimeCaching: [
{
urlPattern: '/',
handler: 'networkFirst',
options: {
cacheName: 'page',
cacheExpiration: {
maxAgeSeconds: 60 * 60 * 24
}
}
},
{
urlPattern: /\/api\/.+/,
handler: 'networkFirst',
options: {
cacheName: 'api',
cacheExpiration: {
maxAgeSeconds: 60 * 60 * 24
}
}
},
{
urlPattern: /\.(png|svg|woff|ttf|eot)/,
handler: 'cacheFirst',
options: {
cacheName: 'assets',
cacheExpiration: {
maxAgeSeconds: 60 * 60 * 24 * 14
}
}
},
{
urlPattern: /^https:\/\/d1vtw97f103n93\.cloudfront\.net.*\/.*\.(jpeg|jpg)/,
handler: 'cacheFirst',
options: {
cacheName: 'image-thumbnail',
cacheExpiration: {
maxEntries: 80,
maxAgeSeconds: 60 * 60 * 24
}
}
},
{
urlPattern: /^https:\/\/d1kqbs072erszl\.cloudfront\.net.*\/.*\.(jpeg|jpg)/,
handler: 'cacheFirst',
options: {
cacheName: 'image-banner',
cacheExpiration: {
maxEntries: 20,
maxAgeSeconds: 60 * 60 * 24 * 7
}
}
}
]
})
]
};

injectManifest (Advanced 的な使い方)

もう一つが injectManifest モードです。
Pre cache 以外は自分で書かないといけないのでできれば generateSW を使いたいところですが、例えば Change dest folder for the Lib-file のように、何か generateSW モードで対処できない部分は基本的に injectManifest モードの利用をおすすめされます。

swSrc が設定されていると自動的に injectManifest モードに切り替わります。ソースになる service-worker.js には予め自分で Workbox の設定を書いておく必要があります (ここのイケてなさは議論されているぽいので未来に期待)。
生成される service-worker.jsworkboxSW.precache([]); の部分に Webpack でバンドルしたファイルがハッシュやバージョン情報含めた状態でセットされます。

// webpack.config.client.js
const WorkboxBuildWebpackPlugin = require('workbox-webpack-plugin');

module.exports = {
...
plugins: [
...
new WorkboxBuildWebpackPlugin({
globPatterns: ['**/*.{js,css}'],
globIgnores: ['**/*.pc*'],
swSrc: 'src/service-worker.js',
swDest: './dist/js/service-worker.js'
})
]
};
// src/service-worker.js
importScripts('https://unpkg.com/workbox-sw@2.0.3/build/importScripts/workbox-sw.prod.v2.0.3.js');

const workboxSW = new self.WorkboxSW({
"cacheId": "manga-zero",
"skipWaiting": true
});

// ↓ ここに globPattern にマッチしたファイルがセットされる
workboxSW.precache([]);

workboxSW.router.registerRoute('/', workboxSW.strategies.networkFirst({
"cacheName": "page",
"cacheExpiration": {
"maxAgeSeconds": 86400
}
}), 'GET');
workboxSW.router.registerRoute(/\/api\/.+/, workboxSW.strategies.networkFirst({
"cacheName": "api",
"cacheExpiration": {
"maxAgeSeconds": 86400
}
}), 'GET');
workboxSW.router.registerRoute(/\.(png|svg|woff|ttf|eot)/, workboxSW.strategies.cacheFirst({
"cacheName": "assets",
"cacheExpiration": {
"maxAgeSeconds": 1209600
}
}), 'GET');
workboxSW.router.registerRoute(/^https:\/\/d1vtw97f103n93\.cloudfront\.net.*\/.*\.(jpeg|jpg)/, workboxSW.strategies.cacheFirst({
"cacheName": "image-thumbnail",
"cacheExpiration": {
"maxEntries": 80,
"maxAgeSeconds": 86400
}
}), 'GET');
workboxSW.router.registerRoute(/^https:\/\/d1kqbs072erszl\.cloudfront\.net.*\/.*\.(jpeg|jpg)/, workboxSW.strategies.cacheFirst({
"cacheName": "image-banner",
"cacheExpiration": {
"maxEntries": 20,
"maxAgeSeconds": 604800
}
}), 'GET');

SW 読み込み

最後に生成した SW をブラウザ側の JS から登録すれば完了です。
同一スコープに登録できる SW は1つまでなので、既に運用している SW がある場合には importScripts で読み込むなどする必要があります。同一スコープに複数 SW を登録してしまうと後勝ちで先に登録されている SW が unregister されてしまうので注意です。

if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js');
}

結果

確認方法

Chrome dev tool の Application タブと Network タブで確認することができます。
初めてのアクセスでキャッシュされるのは Pre cache のみ、Runtime cache は2回目以降のアクセスです。
どちらも次に紹介する方法でキャッシュされているのを確認することができます。

1. Application タブで確認
Aupplication -> Cache -> Cache Storage から確認する方法です。画像では Webpack でバンドルした js ファイル (Pre cache) の例を載せてみました。

2. Network タブで確認
Network -> 対象のファイル -> Headers から確認する方法です。
Canary build の Chrome でないと絞り込みができないのでツライかもしれません。Status Code のところに from ServiceWorker と表示されているのが確認できます。

キャッシュの寿命

気分が良くなってどんどんキャッシュしていくとあっという間にキャッシュストレージを使い果たしてしまいます (今回の検証環境では Mac Chrome で 8093MB、 Android で 1668MB でした)。

こうなってしまうと SW は Uncaught (in promise) DOMException: Quota exceeded. を吐き続けるだけの存在になりメンタルがとても削られます。ので、キャッシュが消えるタイミングなどの挙動を確認してみました。

Pre cache の場合
Pre cache は SW の activate イベントでキャッシュのリスト (workboxSW.precache([]) で渡した引数の中身) と、SW のキャッシュ対象を照らし合わせて SW の Pre cache 対象でないキャッシュがあったら削除する処理が Workbox に挟まっています。
SW の Pre cache 設定が新しくなると SW アップデート時に activate が走るので常に必要なキャッシュだけが残るようになっています。

Runtime cache の場合
こっちはちょっと厄介です。
キャッシュリストを持っていないので1つ1つ削除していくのも現実的ではなく、仮に SW からいい感じに消せたとしてもユーザがその SW を踏むかは分からず、ユーザがブラウザ側からキャッシュを削除しない限り残ってしまいます (6.2. Understanding Cache Lifetimes)。
maxAgeSecond を設定しておけば fetch イベント毎に期限切れのキャッシュが削除されるので、maxEntries と併せて活用していきたいところです。cacheExpiration には cacheName が必要なので忘れず付けておきましょ。

終わりに

先日 Workbox@2.0.3 がリリースされ、開発は引き続き活発に進む雰囲気です。Webpack に関しては今のところ この Issue で議論されそうなのでこれからに期待ですね。
キャッシュだけではオフライン対応としては不十分ですが、まずは通信環境が不安定なときでも安定して見れるようにとか、ギガの節約とかに貢献しそうです。
Workbox では GA のオフライン化などキャッシュ以外の オフライン対応も含まれているため、Webkit の SW を夢見ながらコツコツ進めていこうと思っています。

最後に、Nagisa では一緒に戦うフロントエンドエンジニアを募集しておりません! 今のところ。業務委託であったりとか他の職種はかなり募集しているので、興味を持って頂けたり話を聞きたいなという方は是非遊びに来てください。
それでは皆様よい PWA ライフを。

参考