Help us understand the problem. What is going on with this article?

ServiceWorkerを簡単に書けるworkbox-swの使い方

More than 1 year has passed since last update.

workbox-swの使い方

GMOペパボ Advent Calendar 2017の11日分のエントリーになります。

皆さんはPWA(Progressive Web Apps)をご存知でしょうか。
分かりやすさを優先して言うならば、新しい技術によってネイティブアプリのような動作が可能になったWebアプリケーションのことで、オフラインで動作したりプッシュ通知が送れるWebアプリケーションを実現することができます。
(詳細はGoogle Developers私の発表スライドをご参照ください)

さて今回はPWA作成にあたってとても便利なライブラリであるworkbox-sw(以下、workbox)の紹介記事となります。
workboxとは簡単な記述で最適なServiceWorkerのコードを生成してくれるライブラリです。
PWA自体どうやらまだ認知度が低く、それを作るためのライブラリ関連もまた情報不足だったため今回の記事を書こうと思いました。

workboxとは

PWAと言えばServiceWorkerと言えるくらいにServiceWorkerはPWAにおいてコアとなる技術ですが、そのコードを書くにあたって以下の問題が発生します。

  • 定型的なロジックが大量に発生する
  • ServiceWorkerのライフサイクルの考慮など落とし穴がたくさんある
  • 各種APIが低レベルなものが多く、自分でロジックを書かないといけないものが多い
    • キャッシュ戦略のロジック
    • キャッシュのexpireのロジック

workboxは上記の問題を解決してくれて、宣言的な記述だけで最適なServiceWorkerのコードを生成することができるようになります。
特にキャッシュのexpire機能は実運用でまず必要になると思いますので、これだけでもworkboxは非常に価値のあるライブラリだと思っています。
より多くを知りたい場合はGoogleDevelopersのworkboxのページが便利でしょう。

:exclamation: sw-precacheとsw-toolboxではなくworkbox-swを使いましょう!

Google Codelabsなどを読み進めると、
何度かsw-precachesw-toolboxというライブラリが登場すると思います。
こちらはworkboxの前身となるライブラリで、今ではあまりコミットされていないので、こちらではなくworkboxを使った方が良いです。

The next version of sw-precache & sw-toolbox

Workbox is a rethink of our previous service worker libraries with a focus on modularity. It aims to reduce friction with a unified interface, while keeping the overall library size small. Same great features, easier to use and cross-browser compatible.

使い方

インストール

workboxはnpmパッケージなのでnpmコマンドからインストールします。
また今回はwebpackからworkboxを使っていきたいので、webpack用のパッケージを同時にインストールします。

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

webpack実行時にServiceWorkerのコードを生成してもらうには、webpackのpluginにworkbox-webpack-pluginを追加してやる必要があります。
最低限の設定は以下のような形になると思います。
(workboxの話とは関係ないwebpackの項目は省略しているのでこのままでは実行できません。webpackに必要な項目は別途設定してください。

webpack.config.js
const workboxPlugin = require('workbox-webpack-plugin');

const dist = __dirname + '/dist';

module.exports = {
    plugins: [
        new workboxPlugin({
            globDirectory: dist,
            globPatterns: ['**/*.{html,js}'],
            swDest: dist + '/sw.js',
        })
    ]
};

上記のように設定してwebpackを実行すると dist/sw.js にServiceWorkerのコードを生成されるようになります。
workbox-webpack-pluginにいくつかconfigっぽい値を渡していますが、詳細はworkbox-buildのドキュメントを参照してください。
(workbox-buildとはworkbox-webpack-pluginの内部で使用されている別のnpmパッケージで、実際のコードの生成はこちらが行っています

precacheを行う

おそらくServiceWorkerの最も基本的な使い方であるprecacheを行います。
ServiceWorkerに静的なコンテンツを事前キャッシュさせることで、それらをオフライン時でも取得可能にします。

上記のwebpack.config.jsのうち、 globDirectory はworkboxが監視する対象のディレクトリを、 globPatterns はその配下のprecache対象のファイルのパターンを指定する項目となります。
例えば以下のように設定すると /public/js/public/css/public/images配下のjs/css/pngファイルをprecacheするようなServiceWorkerのコードが生成されます。

webpack.config.js
module.exports = {
    plugins: [
        new workboxPlugin({
            globDirectory: __dirname + '/public',
            globPatterns: [
              'js/**/*.js',
              'css/**/*.css',
              'images/**/*.png'
            ],
            swDest: dist + '/sw.js',
        })
    ]
};

workboxを使わずにprecacheする場合の問題点として、サーバ側でprecacheされたコンテンツを更新した場合にはServiceWorkerの更新も必須になるという問題があります。
何故ならば、それらのコンテンツはクライアント側のServiceWorkerのキャッシュが利用されるようになるため、サーバ側を更新するだけではいつまでも更新がクライアントに反映されないためです。(私の発表スライドの59ページあたりの図を参照してください)

ですがクライアントのServiceWorkerを更新させるにはそのコードを1バイトでも変えなければなりません。
ServiceWorkerは24時間に1回、サーバ側のServiceWorkerのコードの更新を確認しに行きますが、変更がなければ更新を実行しません。

上記の問題をworkboxではデフォルトで解消しています。
生成された dist/sw.js をみればわかりますが、precache対象のコンテンツのrevisionを管理していて、対象に更新があればrevisionも更新される=ServiceWorkerのコードの更新が行われるのでクライアント側でServiceWorkerの更新が実行されるようになっています。

workboxを使わずにServiceWorkerのコードを書くときは、precache対象のコンテンツの更新時には忘れずServiceWorkerのコードも更新しなければならないという面倒な作業が発生していたのですが、workboxを使うだけでそれを解消することができます。

precache以外のリクエストコントロール

precacheの対象は基本的に静的なコンテンツに限定されます。
動的なコンテンツに関してはコンテンツの性質に応じたキャッシュ戦略でリクエストをコントロールする必要があります。
workbox無しでServiceWorkerのコードを書く場合はキャッシュ戦略のロジックを自分で書かなければならず、なかなか大変な作業でした。

ですがworkboxではよく使われるであろうキャッシュ戦略のロジックは一通り揃えていて、手軽にそれを使用することができます。
例えば以下のような runtimeCaching を書くと /images/avatars/*.jpg にマッチするコンテンツはCacheFirstのキャッシュ戦略でコントロールすることができます。
(CacheFirstとは名前の通り、まずキャッシュからコンテンツを返そうとしますが、まだキャッシュされていなかったものはネットワークから取得するキャッシュ戦略となります

webpack.config.js
module.exports = {
    plugins: [
        new workboxPlugin({
            globDirectory: dist,
            globPatterns: [
                '**/*.{html,js}'
            ],
            runtimeCaching: [
                {
                    urlPattern: /images\/avatars\/[^\/\.]+\.jpg$/,
                    handler: 'cacheFirst'
                }
            ],
            swDest: dist + '/sw.js'
        })
    ]
};

runtimeCaching には特定のリクエストに対するコントロールの定義をarrayで渡してやると、その通りにリクエストをコントロールするServiceWorkerのコードを生成してくれます。
各定義にはURLパターンと、そのパターンにマッチした場合に採用するキャッシュ戦略(それを実行するhandlerの名前)を渡します。
現在用意されているhandlerは以下の5つとなりますが、自分でカスタムハンドラを作成することも可能です。

  • CacheFirst: キャッシュから優先して返すが、キャッシュにまだなければネットワークから返す
  • CacheOnly: キャッシュからのみ返す
  • NetworkFirst: ネットワークから優先して返すが、ネットワークから取得に失敗した場合はキャッシュから返す
  • NetworkOnly: ネットワークからのみ返す
  • staleWhileRevalidate: まずキャッシュから優先して返すが、次回アクセス時に備えてバックグラウンドでネットワークから更新をフェッチしてキャッシュを更新しておく

キャッシュのexpireを制御する

ServiceWorkerが使用できるストレージはCacheStorageIndexedDBという2つなのですが、いずれもexpire機能はありません。
明示的にキャッシュを削除しない限りはストレージは溜まっていく一方なので、そのあたりのロジックも自分で書いてやる必要がありました。
ですがworkboxではexpire機能をライブラリ上で実現していて、少しの定義を追加してやるだけで使うことができます。

webpack.config.js
runtimeCaching: [
    {
        urlPattern: /images\/avatars\/\d+\.jpg$/,
        handler: 'cacheFirst',
        options: {
            cacheExpiration: {
                maxAgeSeconds: 86400,
                maxEntries: 10
            }
        }
    }
],

optionsにcacheExpirationプラグインのconfigを渡しています。
(ここでいうプラグインとはhandlerのプラグインです、handlerはプラグインによって動作を拡張することが可能になっています
上記の例では maxAgeSeconds: 86400 で86400秒=1日でexpireされる設定になります。
またキャッシュ件数の上限も指定することもできて、 maxEntries: 10 によって最大10件までにしています。
(上限を超えた場合はキャッシュされなくなるようです、古いエントリから入れ替えるような実装だったら嬉しかった・・・

自分で書いたServiceWorkerのコードとマージする

さて上記までの範囲でServiceWorkerの基本的なユースケースでのコード生成は可能になったと思います。
またworkboxはカスタムハンドラを作れたりpluginを追加することもできて、拡張性にも優れたライブラリになっています。
かゆいところに手が届かない場合でも、必要に応じて独自のロジックを使ったりすることもできるでしょう。

ですが自分で書いたコードも使いたい、workboxでは作ってくれないコードを追加したいケースは存在すると思います。
特に私の知っている範囲ではworkboxはプッシュ通知のロジックを作ってくれないので、そこは自分で書いてやる必要があります。
そういった自分のコード+workboxのコードを実現する方法を最後にご紹介します。

まず以下のような自分のServiceWorkerのコードを用意してやります。

src/sw.js
// 使用するworkboxのバージョンにより以下の文字列は変わります
// distディレクトリに以下のような名前のファイルが出力されているはずなので、そちらを使用してください
importScripts('workbox-sw.prod.v2.1.2.js');

const workboxSW = new WorkboxSW();
workboxSW.precache([]);

self.addEventListener('push', function(event) {
    // 自分のプッシュ通知のロジック
});

そしてwebpack.config.jsを以下のように書き換えてからwebpackを実行すると2つのコードがマージされた結果が生成されたと思います。

webpack.config.js
module.exports = {
    plugins: [
        new workboxPlugin({
            globDirectory: dist,
            globPatterns: [
                '**/*.{js,html}'
            ],
            runtimeCaching: [
                {
                    urlPattern: /images\/avatars\/[^\/\.]+\.jpg$/,
                    handler: 'cacheFirst',
                    options: {
                        cacheExpiration: {
                            maxAgeSeconds: 86400,
                            maxEntries: 10
                        }
                    }
                }
            ],
            swSrc: __dirname + '/src/sw.js',
            swDest: dist + '/sw.js'
        })
    ]
};

上記の例ではwebpack.config.jsに swSrc を追加しただけで、そこに自分のServiceWorkerのコードのパスを渡しています。
こうすることによって2つのコードをマージしたものが生成されるようになります。

気づかれたかもしれませんが、生成されたコードからは runtimeCaching によって定義した部分のコードが無くなっています。
実はこの swSrc が指定されている場合でのコード生成は、内部で行われる処理が今までのものとは根本的に異なります。
今まではworkbox-buildのgenerateSWメソッドによって生成されていましたが、swSrcが指定されると代わりにinjectManifestメソッドが使われるようになります。
詳細はworkbox-buildのドキュメントを参照して欲しいのですが、簡単に言えば前者はServiceWorkerのコード生成全てをworkboxが行うのに対して、後者は既にあるServiceWorkerのコードから .precache([]); と書かれた箇所を見つけ、そこの引数をprecache対象のファイルのリストに置換することしかしません。
要するにprecache以外のコードは自分で用意しなければならなくなります。(ここらへんの設計はイケてないという指摘はよくあるらしいです)

とは言っても、これはそれほど難しい作業ではありません。
src/sw.js に以下のようなコードを追加してやればそれで終わります。
見てわかるかと思いますが、 runtimeCaching の内容を少し変えただけです。
これをすればwebpack.config.jsからruntimeCachingの項目を削除してしまって構いません。

src/sw.js
workboxSW.router.registerRoute(/images\/avatars\/[^\/\.]+\.jpg$/, workboxSW.strategies.cacheFirst({
  "cacheExpiration": {
    maxAgeSeconds: 86400,
    "maxEntries": 10
  }
}), 'GET');

まとめ

いかがだったでしょうか。
正直まだまだご紹介したい機能やテクニックはあるのですが、私が発表を通して得た感触としてはworkbox以前にPWAの認知度自体がイマイチなようで、PWAが一般的に認知される頃にはライブラリ事情も大きく変わっていそうなので今回はこの辺りにいたします。
もしPWAに興味を持たれた方や、あるいはPWAの実像をより知りたい方がいらっしゃれば私の発表スライドをぜひご覧ください。
https://speakerdeck.com/nazonohito51/pwafalsesanpuruapuriwozuo-tutemita

binc
Eコマースプラットフォーム「BASE」、オンライン決済サービス「PAY.JP」、購入者向けID型決済サービス「PAY ID」の3つのサービスを運営しています。
https://binc.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした