Workbox + webpackでServiceWorkerのオフラインキャッシュと戯れる

HivelocityでフロントエンドエンジニアをしているHaradaです。

先日PWAのカンファレンスに参加する機会があったので、最近のPWA事情について知識をアップデートしておこうと前から気になっていたWorkboxを使ったデモを作成してみたので、その辺りを少しご紹介したいと思います。

せっかくなので、将来的に自社プロダクトへの実装も視野に入れて、今回はwebpackからWorkboxを使用する構成でトライしてみました。

Workboxとは

普段開発に従事していない人でも、トレンドとしてPWA(Progressive Web Apps)という言葉は聞いたことがあると思います。
端的に言うと、Webサイトでスマホアプリのような機能や動作を実現するための仕組みです。

一般的にPWAの利点として主に以下のようなものが挙げられています。

  1. ホーム画面へのアイコン追加
  2. プッシュ通知
  3. オフラインでも動作する

ただ、現段階ではiOSへの対応状況がイマイチな気もするので、本格的に実装に踏み切るかどうかは微妙なラインかもしれません。
ですが、ネットワーク接続がない状態でもページを閲覧できるというのは十分に魅力的だと思うので、ユーザー体験の向上のためにいずれは実装していきたいと思っております。

サイトをオフラインの状態で表示するには、ServiceWorkerというWebページのバックグラウンドで動作するJavaScriptを使ったキャッシュコントロールの仕組みを実装する必要があります。
ざっくり言うと、一度アクセスしたサイトの必要なリソースをユーザーのデバイスにキャッシュしておいて、そこからレスポンスを返してページを表示するという仕組みです。
ServiceWorkerを使って実現できるとはいえ、キャッシュの管理は複雑で制御が難しいと思います。Workboxはその辺りを手軽に実装できるライブラリです。

環境

今回は以下の環境にWorkboxによるキャッシュコントロールの仕組みを実装してみます。

  • webpack 4.33.0
  • webpack-dev-server 3.7.0
  • react 16.8.6

実装する環境のファイル構成は以下のようになっています。
簡素ですが、webpack-dev-serverのルートディレクトリがdist以下となっており、index.htmlの内容が表示されるという構造です。

┣━━ dist/
┃      ┣ bundle.js
┃      ┗ index.html
┃
┣━━ node_modules/
┣━━ package.json
┣━━ src/
┃      ┣ components/
┃      ┣ index.js
┃      ┗ index.html
┃
┣━━ webpack.config.js
┗━━ yarn.lock

インストール

yarn(npm)コマンドでWorkboxを入れていきます。

yarn add --dev workbox-sw

webpackからWorkboxを使うので、workbox-webpack-plugin もインストールします。

yarn add --dev workbox-webpack-plugin

webpack.config.jsに以下のような設定を記述することで、バンドル時にService Workerファイルが生成されます。

const WorkboxWebpackPlugin = require('workbox-webpack-plugin')

module.exports = {

  // 他のwebpack設定

  plugins: [
    new WorkboxWebpackPlugin.GenerateSW()
  ],
}

precache設定をしてみる。

基本設定

Workboxを使ってServiceWorkerにprecacheの設定をしてみましょう。
以下のオプション設定を追加します。

  • globDirectory

precache対象のディレクトリを指定します。
distディレクトリ以下をprecache対象としてみます。

  • globPatterns

precache対象のファイルパターンを指定します。
distディレクトリ以下のhtml、js、cssファイル。さらにdist/imagesディレクトリ以下のpng、gif、webp、svgファイルをprecacheするよう設定します。

  • swDest

workboxによって生成されるServiceWorker.jsの出力先を指定します。
distディレクトリ以下にServiceWorker.jsを生成するように設定します。設定がないと自動的にservice-worker.jsというファイル名になるようなので、sw.jsに変えてみます。

const WorkboxWebpackPlugin = require('workbox-webpack-plugin')
const dist = __dirname + '/dist'

module.exports = {

  // 他のwebpack設定

  plugins: [
    new WorkboxWebpackPlugin.GenerateSW([
      globDirectory: dist,
      globPatterns: [
        '*.{html,js,css}',
        'images/*.{png,gif,webp,svg}'
      ],
      swDest: dist + '/sw.js',
    ]),
  ],
}

今回のデモ用に用意した構成だとdistディレクトリにhtmlとjsがビルドされる一般的なReact構成だったので、出力先ディレクトリに配置したimageファイルをコンポーネントで読み込む形に変えてimageファイルもprecacheできるか試してみました。
*Reactのアプリケーション構成としてあまり実用的な構成ではないですが、今回のテストのために無理やりカスタムしました。

以下のように distディレクトリに imagesディレクトリを設けて、png形式の画像とjpg形式の画像を設置しました。
あとでちゃんとキャッシュされているかどうかのテストを行いますが、ポイントはjpgファイルはprecacheしない設定にしていることです。

┣━━ dist/
┃      ┣ images/
┃      ┃      ┣ eyecatch.png
┃      ┃      ┗ post_w600.jpg
┃      ┣ bundle.js
┃      ┗ index.html
┃
┣━━ node_modules/
┣━━ package.json
┣━━ src/
┃      ┣ components/
┃      ┣ index.js
┃      ┗ index.html
┃
┣━━ webpack.config.js
┗━━ yarn.lock

この状態でビルドしてみましょう

以下のファイルが生成されました

  • bundle.js
  • precache-manifest.[ハッシュ値].js
  • sw.js

次にビルドされたsw.jsをindex.htmlで読み込んであげる必要があるので追記します。
今回はsw.jsの読み込みが成功しているかの確認も行いたいので、consoleでメッセージを表示するように条件分岐させました。

<script>
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('sw.js').then(function(registration) {
    //登録成功
    console.log('ServiceWorker registration successful with scope: ', registration.scope);
  }).catch(function(err) {
    //登録失敗
    console.log('ServiceWorker registration failed: ', err);
  });
}
</script>

webpack dev serverを起動してブラウザで動作を確認していきましょう。
まずChrome DevToolsの“Console”タブを開いてServiceWorkerの登録が成功しているかどうかを確認します。

“ServiceWorker registration successful with scope” が表示されていますね。

次に“Application”タブを開きます。ServiceWorkerのデバッグでは、主にここを使うことが多いです。

それでは、Workboxで設定したキャッシュが動作しているか見てみましょう。
ServiceWorkerによってキャッシュされたファイルは、 “Cache Storage”メニューから確認できます。

webpack.config.jsで設定したとおりに、distディレクトリ内のファイルが格納されていますが、“post_w600.jpg”というjpg形式の画像だけはキャッシュされていないようです。

オフライン状態にしてみる

この状態のまま“ServiceWorkers”メニューよりオフライン状態に切り替えてリロードしてみます。

VIEWではjpg画像のみnot foundになりましたが、それ以外のファイルはキャッシュから取得しているのでオフラインでも表示できています。

precache以外のキャッシュ

precacheは基本的には静的なコンテンツに対しての設定になりますが、動的なコンテンツについては、種類に応じてキャッシュのStrategies(キャッシュの方法)をコントロールした方が良いと思います。

キャッシュのStrategiesには主に以下のようなものがありますが、Googleの オフラインクックブック で説明されていますのでこちらに目を通しておくことでより理解が深まると思います。

  • Cache First(キャッシュから優先的に返す。キャッシュになければネットワークから取得して返す)
  • Cache Only(キャッシュからのみ返す)
  • Network First(ネットワークから優先的に返す。ネットワークからの取得に失敗した場合、キャッシュから返す)
  • Network Only(ネットワークからのみ返す)
  • Stale While Revalidate(キャッシュから優先的に返す。さらにネットワークから更新をフェッチしてキャッシュの内容を更新しておく)
  • Cache then network(キャッシュがあればキャッシュを返す。さらにネットワークアクセスしてキャッシュとページの内容を更新する)

この辺をWorkboxではruntimeCachingで設定していきます。
キャッシュするファイルは、S3にアップロードしたこちらの画像ファイルです。

webpack.config.jsに以下のように runtimeCachingの設定を追記します。

  • urlPattern

キャッシュするURLパターンを定義します。ここでは外部ドメイン(https://bst-cdn-image.s3-ap-northeast-1.amazonaws.com/) 内のすべての形式のファイルを対象としてみます。

  • handler

キャッシュのStrategiesを指定します。一致した条件のファイルをどうキャッシュするかを設定します

  • options

handlerの動作を拡張するさまざまな設定を指定します。
ここでは、 cacheNameを“cdn-s3”に設定。制限なくキャッシュしてしまうとデバイスのローカルストレージを圧迫してしまうのでとりあえずmaxEntriesを10件。データを10日間キャッシュするようにしました。

new WorkboxWebpackPlugin.GenerateSW({
  globDirectory: dist,
  globPatterns: [
    '**/*.{html,js}',
    'images/*.{png,gif,webp,svg}'
  ],
  swDest: dist + '/sw.js',
  runtimeCaching: [
    {
      urlPattern: new RegExp('^' + 'https://bst-cdn-image\.s3-ap-northeast-1\.amazonaws\.com/' + '.*'),
      handler: 'CacheFirst',
      options: {
        cacheName: 'cdn-s3',
        expiration: {
          maxEntries: 10,
          maxAgeSeconds: 240 * 60 * 60
        },
        cacheableResponse: { statuses: [0, 200] },
      }
    }
  ],
})

ブラウザで動作を確認してみましょう。

cacheNameで指定した“cdn-s3”という表示が追加されています。
無事キャッシュに格納されたようです。

オフライン状態でも確認してみます。

Consoleに“Found a cached response in the ‘cdn-s3’ cache.” と表示されていることからも分かるように、キャッシュからファイルを表示しています。

次にS3にアップロードされている画像を差し替えてみます。

キャッシュStrategiesは、Cache Firstに設定していますので、画像が更新されていてもキャッシュから取得してくるため表示されるファイルは変わらないはずです。
S3の画像を上書きした後にページ内に表示される画像を確認したのかどうか伝わりにくいので、コンポーネントに現在時刻を表示するようにしました。
S3にアップロードした画像のResponse HeadersのLast-Modified Timeと比較してみましょう。

リロードしてみましたが、画像は変わっていません。
Cache Storageのキャッシュを削除してリロードしてみます。

キャッシュにデータがないので、ネットワークから取得し直したため画像が差し代わりました。

Workboxを触ってみて

僕がiOSユーザーということで気持ち的にServiceWorkerから距離を取っていたということもあるかもしれないですが、今回デモを作成してみてキャッシュコントロールによるオフラインサポートはproductionにも投入できそうと感じました。
ただ、提供するサービスやコンテンツごとにどのようなキャッシュ戦略をとっていくかなど、掘り下げて考える必要があると思います。

自社サービスへ実装するケースについて少し考えてみて、またアウトプットしていきたいと思います。

ライターおすすめ記事

  • f
  • t
  • p
  • h
  • l
  • n