Service Workerを理解した上で、Workboxを使ってみる

この記事は ギフティ Advent Calendar 2019 - Qiita 8日目の記事です。

今回は、前々から技術的に気になっていたService Workerを触って得られた知見について書いてみたいと思います。

Service Workerとは

- ブラウザがバックグラウンド(別スレッド)で実行するスクリプト(及びそのための仕組み)。
- メインスレッドで実行するのは、Webページ用の処理(=レンダリングや、ユーザーインタラクションに応じた処理など)。
- 昔々はAppCacheというものがあったそうです(現在は非推奨)。
- AppCacheについては、弊社技術ブログの こちらの記事 をご覧ください(宣伝)。

何ができるのか

- ページからのリクエストをプロキシすることができます。
- メインスレッド上の処理を邪魔することなく何らかの処理を行うことができます。
- オフライン環境下でも動かすことができます。
- オンラインになったら〇〇する、とかも(一応)できるようです。

「ブラウザ内に組み込まれたプロキシサーバー」のようなイメージかと思います。

何ができないのか

- DOMへの直接的なアクセスはできません。
  - これはService WorkerのというよりWeb Workerとしての制限です。ただしメインスレッドからのmessage経由で、間接的にDOM操作に絡むことはできるようです。
- 必要になったら起動し、不要になったら終了する(おそらくメモリ節約等を目的としたブラウザの仕様?)。そのため、変数の値を保持しておけません。
  - 保持したい場合は、一度別の場所(Indexed DBやCache Storageなど)にデータを入れておく必要があります。
- 同期型のAPIであるSessionなどを使うことはできません。

何が嬉しいのか

- レンダリングに絡むメインプロセスを邪魔することなく、バックグラウンドでデータ同期させることができます。
  - これも厳密にはWeb Workerとしてのメリットです。
- オフラインのときはキャッシュした内容を表示し、オンラインになったときに、オフライン時にできなかった処理の続きを行う、などができます。
- プッシュメッセージを送ることができます。
  - PWAを実現するための技術要素として、Service Workerがあるというイメージです。

他にもいろいろできますが、おそらく現時点(2019.12)では、オフライン環境下での動作に使うのが主な用途になっていそうだと感じました。

使ってみる

前提条件
- (そもそも)ブラウザがサポートしていること
  - IE以外は全部動くようです。https://jakearchibald.github.io/isserviceworkerready/
- 開発環境(localhost)以外では、httpsが必須
  - Service Workerはブラウザ(frontend)とサーバー(backend)の間をプロキシするため、通信の改ざんやフィルタリング等々ができてしまいます。悪用を避けるため、httpsによって提供されるページにおいてのみService Workerを登録できるようになっています。

Service Workerの登録

if ('serviceWorker' in navigator) {
 window.addEventListener('load', function() {
   navigator.serviceWorker.register('/sw.js').then(function(registration) {
     console.log('ServiceWorker registration successful!');
   }, function(error) {
     console.log('ServiceWorker registration failed. ', error);
   });
 });
}

上記を読むと、ページロードのたびに register() されるように感じますが、既にService Workerが登録されているかどうかをブラウザが調べ、登録処理が必要かどうかを判断してくれるようです。
また、Service Workerはオリジン+パスに対して登録され、それがスコープとなるため、service worker script(↑だとsw.js)の配置には注意が必要そうです。
ルートパスの場合、登録したservice worker scriptのスコープはオリジン全体になります。

Service Workerの処理を書いてみる
各種イベントドリブンで処理を書いていくことになります。
例えばinstall eventの場合は以下のようなかたちです。

self.addEventListener('install', function(event) {
 // Perform install steps
});

新しいService Workerに更新する
新しいものに更新するときの流れは以下のようになっています。

1. ブラウザが(新しいスクリプトだと認識した場合)、新しいService Workerをdownloadする。
2. 新しい Service Worker がスタートし、install イベントが起こる。
3. この時点では、まだ古い Service Worker が現在のページを制御しているため、新しい Service Worker は waiting 状態になる。
4. 開かれているページが閉じると、古い Service Worker は終了し、新しい Service Worker がページを制御するようになる。このとき、activate イベントが起こる。

※ちなみにactivate直後はfetchイベントが起こらないため、新しいService Workerによってfetchイベントにおける制御が行われるのは、最低3回目のreload後になります。

- 旧worker
- →reload→新worker install
- →reload→新worker activate
- →reload→新worker fetchイベント制御可能

ややこしいですが、 skipWaiting() でinstallingからactive状態にすぐに移行させたり、 claim() でactiveになったらすぐにページを制御させたり、といったことも可能なようです。

試したもの

https://github.com/yashi8484/service-worker-test

localhostで動作します。
前述の通り、Service Workerでできることはたくさんあるのですが、今回行ったことは以下の3つです。

- アクセスしたページのキャッシュを保存する。
- キャッシュが存在すれば、(サーバーにアクセスすることなく)それを返す。
- オフライン時かつキャッシュが存在しないページなら、固定のページを返す。

Service Workerの処理を書くのは難しくないのですが、(普段書いている画面描画に関わるjsと違い)バックグラウンドでの処理になるため、動作が見えにくいです。
また、古いService Workerが思ったように消えてくれず、結局DevToolsや、chrome://serviceworker-internals/ で無理やり削除していました。
そのためややデバッグしにくい、という所感です。(良いやり方ありましたら教えて下さい。)
さらに運用を考えた場合、Service WorkerのLife Cycle、Cache strategy、キャッシュ破棄の仕組み等々を考慮したscriptを書かなければならなそうで、辛そうな印象を受けました。(逆に、各々のサービスに最適化された仕組みを導入できそう、とも言えます。)

と、ここまでは実は前置きです。

Service Workerの作成をもう少し楽に行う方法は無いものかと探していたところ、ありました。
それが Workbox です。

Workboxとは

Webアプリにおけるオフライン対応を楽に行うことができるよう、Googleが提供しているライブラリ&モジュール群です。

使ってみる

今回は、 workbox-webpack-plugin を使ってみます。
これは、記述した設定に応じたService Worker scriptをgenerateしてくれる、といったものです。
generateまでを行いますので、generateされたService Worker scriptの登録処理は自分で書く必要があります。

インストールします。

yarn add -D workbox-webpack-plugin

その名の通りwebpackのpluginですので、webpack.config.jsに設定を記述していきます。

const WorkboxPlugin = require('workbox-webpack-plugin');
module.exports = {
 // Other webpack config...
 plugins: [
   // Other plugins...
   new WorkboxPlugin.GenerateSW(
     swDest: `${__dirname}/dist/service-worker.js`,
     // Other options...
   )
 ]
};

加えて、generateされるscriptを登録する処理を追加します。

<script>
// Check that service workers are supported
if ('serviceWorker' in navigator) {
 // Use the window load event to keep the page load performant
 window.addEventListener('load', () => {
   navigator.serviceWorker.register('/service-worker.js');
 });
}
</script>

上記は 公式 から拝借しています。

大枠としてはこれで完了です。
buildすると、swDest で指定された場所に service-worker.js が生成され、上記html内のscriptによってService Workerが登録され、動作します。

試したもの

サイト
https://workbox-cache.netlify.com/

リポジトリ
https://github.com/yashi8484/workbox-cache

httpsにする必要がありますので、 Netlify でホスティングしました。
get photos を押すと、https://jsonplaceholder.typicode.com/ のAPIから画像を取得して表示します。
このとき取得したresponse(とimage)をキャッシュします。オフライン環境下でもう一度 get photos を押すと、キャッシュにある画像を取得して表示します。

workboxの設定は こちら です。一部を以下に記載します。

runtimeCaching: [
 {
   urlPattern: new RegExp('^https://jsonplaceholder.typicode.com/'),
   handler: 'NetworkFirst',
   options: {
     cacheName: 'api',
     expiration: {
       maxEntries: 1,
       maxAgeSeconds: 24 * 60 * 60,
     },
     cacheableResponse: {
       statuses: [0, 200],
     },
   },
 },
 {
   urlPattern: new RegExp('^https://via.placeholder.com/'),
   handler: 'NetworkFirst',
   options: {
     cacheName: 'image',
     expiration: {
       maxEntries: 50,
       maxAgeSeconds: 24 * 60 * 60,
     },
     cacheableResponse: {
       statuses: [0, 200],
     },
   },
 },
],

このruntimeCachingの部分に、動的リソースのキャッシュ設定を書いていきます。 ドキュメント もしっかりしており、特に困ること無く記述できました。
(何でもキャッシュしすぎるとストレージを圧迫してしまうため、expiration周りはもっと詰める必要がありそうです。)
handler はcache strategiesを指定する部分で、候補は こちらのclasses になるようです。
cache strategiesについては こちら を参照してください。それぞれ一長一短ありますので、要件に応じて最適なものを選択しましょう。

まとめ

技術的に気になっていたService Workerを触り、大まかな概念や使い方を理解できました。
Workboxを使う場合でも、記事の前半で記載したようなService Workerの基本的な知識は必要だと(実際に使ってみて)感じました。
今後はこれらの技術を実際の運用で使ってみた結果など、もう少し一歩踏み込んだ内容が共有できると良いなと思っています。

この記事が気に入ったらサポートをしてみませんか?