アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

workbox を導入してServiceWorkerによるキャッシュを実装した話

morishitaです。

いこレポに workbox を導入してServiceWorkerによるキャッシュを実装しました。
そのことについて書きます。

f:id:HeRo:20181005085921p:plain

導入の背景

いこレポはおかげさまで順調に成長できており、アクセス数も伸びてきています。 先日 1 周年を迎え、ますます頑張っていなかければと思っている所存です。

report.iko-yo.net

いこレポのトラフィックは現状、オーガニック流入が最も多く新規ユーザが大部分を占めています。
それはそれで良いのですが、多少、成長の質にも目も向けられる様になり、 リピータを増やしたいなぁと思い始めました。

メディアの本分としては、魅力的なコンテンツでユーザを惹きつけるということに注力するのですが、それは編集チームのみなさんが頑張ってくれています。

私はエンジニアなのでエンジニアとしてやれることは何かなぁと考えたときに A2HS をもっと使ってもらえるようにしたいなぁと思いました。

A2HS は Add To Home Screen の略で、Web サイトのショートカットとなるホームアイコンをスマホのホーム画面に追加する機能です。 実はいこレポは manifest.json と ServiceWorker をサイトオープン時より導入しており、一応 A2HS 対応はしていました。
ホームアイコンから Web サイトにアクセスすると、URL バーやツールバーが非表示になりアプリっぽく表示できるようになったりします。

更に Chrome では一定条件(後述)を満たすと A2HS を促すインストールバナーを表示してくれます。 インストールバナーもサービス開始前の開発時には動いていたのですが、いつのまにやら動かなくっていました。
調べてみると、オフライン時キャッシュがなく、オフライン時には何も表示できないのでインストールバナー機能が無効になっているようでした。

最近、Safari も ServiceWorker やA2HSにも対応したようですし、キャッシュを実装しA2HS周りの整備に取り組むことにしました。

導入の目的

ということで、今回の目的は次の二点です。

  • Chrome のインストールバナー(A2HS を促す機能)を有効にするためにオフライン対応する
  • できればプリキャッシュでページロードを速くしたい

オフラインと言っても、サイトまるごとキャッシュさせるのは容量が大きすぎてスマホに優しくないのでトップページだけ対応することにしました。

Workbox の導入

ServiceWorker でキャッシュをを自分で実装するのは大変そうなので、 Google 製のサポートライブラリWorkboxを使います。

f:id:HeRo:20181005082437p:plain

いこレポは、一応 webpacker 導入済みの Rails アプリケーションなので、 webpackのビルドプロセスに組み込みより楽できそうなWorkbox webpack PluginsgenerateSWを使うことにしました1

次のコマンドでインストールします。

$ yarn install workbox-webpack-plugin

ちなみに、webpack は 3.12.0、 workbox-webpack-plugin は3.6.1を利用しています。

後は webpack のコンフィグの pluginsGenerateSWの設定を追加していけばいいのですが、既存の設定ファイルを肥大化させるのも何なんで、次のような設定ファイルに分離して設定をマージするようにしました。

const WorkboxPlugin = require("workbox-webpack-plugin");
const { env } = require("./configuration");

let modifyUrlPrefix;
if (env.AWS_S3_HOST_ALIAS && env.AWS_S3_HOST_ALIAS !== "") {
  modifyUrlPrefix = { assets: `//${env.AWS_S3_HOST_ALIAS}/assets` };
}

module.exports = {
  plugins: [
    new WorkboxPlugin.GenerateSW({
      swDest: "../sw.js",
      cacheId: "ikorepo",
      clientsClaim: true,
      skipWaiting: true,
      importWorkboxFrom: "local",
      include: [/public/],
      exclude: [/<管理画面のパス>/],
      globDirectory: "public/",
      globPatterns: ["assets/**/*.{js,css,jpg,png,gif,webp,svg,ttf,woff}"],
      globIgnores: ["assets/pc/**/*"],
      modifyUrlPrefix,
      offlineGoogleAnalytics: true,
      runtimeCaching: [
        {
          urlPattern: "/",
          handler: "networkFirst",
          options: {
            cacheName: "page",
            expiration: {
              maxAgeSeconds: 60 * 60 * 24
            },
            matchOptions: {
              ignoreSearch: true
            }
          }
        },
        {
          urlPattern: "/?utm_source=homeicon",
          handler: "networkFirst",
          options: {
            cacheName: "homeicon",
            expiration: {
              maxAgeSeconds: 60 * 60 * 24
            },
            matchOptions: {
              ignoreSearch: true
            }
          }
        },
        {
          urlPattern: new RegExp(
            "^https://[a-zA-Z0-9]+\\.cloudfront\\.net/uploaded/"
          ),
          handler: "cacheFirst",
          options: {
            cacheName: "images",
            expiration: {
              maxEntries: 30,
              maxAgeSeconds: 60 * 60 * 24,
              purgeOnQuotaError: true
            },
            cacheableResponse: {
              statuses: [0, 200]
            }
          }
        }
      ]
    })
  ]
};

以降、この設定について説明します。

プリキャッシュの設定

いこレポの事情

最初にいこレポの JS の構成について説明します。 いこレポでは Webpacker を導入していますが、全面的に Webpack によるトランスパイルをしているわけではありません。
記事を編集する管理画面にのみ Vue.js を導入しており、webpack で ES6 な JS をトランスパイルしています。
一方、コンシューマユーザ向けの表側のページは旧来の JS を書いて Rails のアセットパイプラインを利用しています。

このような構成なので、Webpack で生成されたファイルは管理画面用なのでキャッシュさせたくない。
一方アセットパイプラインの生成物はキャッシュさせたいという状況です。

Webpack で生成されたファイルの除外

Workbox webpack plugin は基本的には webpack でビルドしたファイルをプリフェッチするように ServiceWorker を生成します。 プリフェッチ対象のファイルは public/packs/precache-manifest.xxxxxx.js に書き出されます。

webpack で生成されるファイルはいこレポの場合、管理画面向けのものだけで全て<管理画面のパス>以下にあります。そのため、次の設定で、プリフェッチから除外しています。
これにより、public/packs/precache-manifest.xxxxxx.js の内容は空のリストとなりました。

exclude: [/<管理画面のパス>/],

Webpack が出力するするファイルを全部プリキャッシュならこの設定は不要ですし、部分的にキャッシュ対象にする場合には正規表現にマッチするファイルパスのものだけを除外することもできます。

Webpack 管理外ファイルのプリキャッシュ

webpack で出力しないファイル群をプリフェッチに加えるために次の設定をしています。

globDirectory: "public/",
globPatterns: ["assets/**/*.{js,css,jpg,png,gif,webp,svg,ttf,woff}"],
globIgnores: ["assets/pc/**/*"],

基本的にはpublic/ディレクトリ以下をスキャンし、globPatternsにパスが一致するファイルをキャッシュ対象にするという設定です。

globIgnores: ["assets/pc/**/*"]assets/pcディレクトリ以下のファイルをプリキャッシュの対象外にする設定です。
assets/pcディレクトリ以下には PC 向けのアセットがあります。
スマホに PC 向けのアセットをキャッシュさせたくないし、ユーザエージェントを見てキャッシュ対象を分けるような複雑なこともしたくないのでPC はプリキャッシュなしと割り切りました。

glob〜の設定でプリフェッチされるファイルは、public/packs/precache-manifest.xxxxxx.js ではなく生成される SeviceWorkerのJSソースに直接書き込まれます。
最初これがわからなくてハマりました。どう設定してもpublic/packs/precache-manifest.xxxxxx.jsにファイルが追加されないので設定方法が間違っているのかとあれこれ試してだいぶ時間をロスしてしまいました。

CDN 配信のためのもう一歩

本番環境で使うためにはもう 1 つ設定を追加する必要がありました。
というのも本番環境ではアセット生成物もすべて CloudFront で配信しているため、URL のドメインが異なるのです。

それを調整するための設定が modifyUrlPrefixです。これはプリキャッシュファイルのファイルパスを書き換える設定です。
値は次のようなコードで環境変数AWS_S3_HOST_ALIASがビルド実行時に設定されていればその値に応じてファイルパスを書き換えるようにしました。

let modifyUrlPrefix;
if (env.AWS_S3_HOST_ALIAS && env.AWS_S3_HOST_ALIAS !== "") {
  modifyUrlPrefix = { assets: `//${env.AWS_S3_HOST_ALIAS}/assets` };
}

AWS_S3_HOST_ALIAS がなければ次のようなパスとしてキャッシュ設定されます。

assets/admin/cms-randomcharsaddedbyassetspipelinexxxxxxxxxxx.js

しかし、環境変数AWS_S3_HOST_ALIASに CloudFront のドメインを設定しておくと次のようなパスに書き換え、CDN のファイルをプリキャシュに加えることができるようになりました。

//d28w2qw7dtr435.cloudfront.net/assets/admin/cms-randomcharsaddedbyassetspipelinexxxxxxxxxxx.js

ランタイムキャッシュ

続いて、ランタイムキャッシュです。
runtimeCachingの値がランタイムキャッシュのための設定です。

次の 3 つのurlPatternをキャッシュするように設定しました。

  • "/"
  • "/?utm_source=homeicon"
  • new RegExp("^https://[a-zA-Z0-9]+\\.cloudfront\\.net/uploaded/")

上 2 つは、A2HS のために必要な設定です。
ホームスクリーンアイコンから開いたときのアクセス URL はmanifest.jsonstart_urlに設定します。このstart_urlがオフラインでも開くようにキャッシュしておかないとホームアイコンを追加するように促すダイアログが有効にならないのです。

いこレポのmanifest.jsonstart_urlの値は"/?utm_source=homeicon"です。 サイトトップのパスなのですが、ホームアイコンからのアクセスだとわかるようにキャンペーンパラメータを付けています。
matchOptions.ignoreSearch: trueの設定でクエリパラメータを無視してキャッシュをヒットしてくれるのかと思ったのですが、どうもその様に動いてくれませんでした。そのため、キャンペーンパラメータありのurlPatternと念の為、なしのurlPatternを両方キャッシュに追加しています。

3 つ目のurlPatternは、記事の画像をキャッシュするための設定です。 たくさんキャッシュしてスマホの容量を圧迫しないようにexpiration.maxEntriesで数を制限しています。 加えてpurgeOnQuotaError: trueQuotaErrorが発生したらキャッシュを捨てるようにも設定しています。

以上でServiceWorker でのキャッシュ設定は終了です。

Chrome のインストールバナー

さて、ここから先は主にAndroidのChromeの話です。

ここまでの設定を施してやると、次の条件を満たす場合に Android Chrome ではMini-infobarというものが表示され、ホームアイコンの追加を促してきます。

  • manifest.json に次が定義されている
    • short_name
    • name
    • 192x192 の png アイコン(PNGだけです)
    • start_url
  • ServiceWorkerが導入されている(そのためにサイトがHTTPSで配信されている必要あり)
  • 2 回以上のアクセスがあり、そのアクセスに 5 分以上の間隔がある。

注意が必要なのはstart_urlのコンテンツがオフラインでも表示できる必要があるということです。そのためServiceWorkerでキャッシュの実装をしたのです。

Mini-infobarは画面の最下部に表示され、いこレポではこんな感じです。

f:id:HeRo:20181004221736p:plain

ときどき、これが表示されるサイトを見かけますが、 いこレポのユーザがこれをみてもなんのこっちゃって感じかと思うので事前に説明を表示したいと考えました。

beforeinstallprompt イベント

事前説明を表示するためには beforeinstallpromptイベントをハンドリングする必要があります。

beforeinstallpromptイベントはホームアイコンの追加を促せるとChromeが判定したタイミングで発火します。 そのイベントを受けるイベントハンドラを次の様に実装します。

var deferredPrompt;
window.addEventListener('beforeinstallprompt', function (event) {
   event.preventDefault();      // デフォルト動作をキャンセル
   deferredPrompt = event;   // あとで利用するのでイベントオブジェクトをとっておく
   openInstallPopup();            // ポップアップを開く
   return false;
});

deferredPrompt変数に受けたイベントを格納しておくのがポイントです。 openInstallPopup()関数を実行すると次のようなポップアップを表示するようにしました。

f:id:HeRo:20181004224141p:plain

このポップアップの「ホームアイコンを作る」ボタンをタップしたときの動作を次の様に実装しました。

btnSave.addEventListener('click', function () {
   closeInstallPopup();  // ポップアップを閉じる関数
   if (deferredPrompt !== undefined) {
      deferredPrompt.prompt();  // A2HSダイアログを表示する

      deferredPrompt.userChoice.then(function (choiceResult) {

         if (choiceResult.outcome === 'accepted') {
            pushA2hsEvent('installed', 'home-icon');  // GAへのイベント送信
         } else {
            disableA2hs();  // 一定期間 beforeinstallprompt イベントを無視する。後述
            pushA2hsEvent('dismissed', 'home-icon');  // GAへのイベント送信
         }
         deferredPrompt = null;  // 一度しか使えないので後始末
      });
    }
 });

deferredPrompt.prompt()を実行すると次のようなA2HSダイアログを開きます。

f:id:HeRo:20181004225256p:plain

ここで、「追加」をタップするとホームアイコンが追加され、「キャンセル」すると追加されません。

いずれかがタップされるとdeferredPrompt.userChoiceのブロックが解かれthenに渡されたコールバック関数が実行されます。

choiceResult.outcomeに「追加」と「キャンセル」どちらをタップしたかが入っているのでそれぞれをGoogle Anlyticsにイベント送信しています。

ここで、一番のポイントはdisableA2hs()関数です。

キャンセルしてもキャンセルしてもbeforeinstallpromptイベントが発火する

残念ながらA2HSダイアログで「キャンセル」するということはユーザがホームアイコンを作りたくないということです。

「キャンセル」するとその時は処理を終わるのですが、なんとページをロードするたびにbeforeinstallpromptイベントは発火するのです。 イベントが発火するとポップアップを表示してしまいます。

何度「キャンセル」してもページ遷移のたびにポップアップが出れば煩わしくてUXが著しく悪化するばかりか、ユーザが離脱してしまいます。

ホームアイコンを追加すると流石に発火しませんが、アンインストールするとやはり毎回発火するようになります。その場合もユーザの意思に反してホームボタンの設置を強制しているかのような動作となります。ユーザに嫌われてしまいます。

どうやらこれが仕様どおりの動きのようです。 しょうがないので、いこレポではexpireを指定したクッキーを作って、そのクッキーがある間はbeforeinstallpromptイベントを無視する(event.preventDefault()を実行する)様にしました。disableA2hs()関数はそのクッキーを作っている関数なのです。

リリースしてみてどうなったのか?

Lighthouseによる計測結果

ChromeのDevToolに組み込まれている Lighthouse(Auditタブ)ではサイトのパフォーマンスを計測できます。

リリース前には次のようなスコアでした。 f:id:HeRo:20181004231705p:plain

で、リリース後どうなったかというと次のようになりました。

f:id:HeRo:20181004231835p:plain

やりました!Progressive Web Appのスコアが100点満点になりました!

キャッシュによってPerformanceのスコアも大きく改善するのではと思っていたのですが、良くなったとは言えない結果でした。

WebPageTestでもリリース前後のパフォーマンスを3G相当の遅い回線をシミュレートして計測してみました。100msecほど速くなっていますがやはり期待したほどではありませんでした。 f:id:HeRo:20181004232135p:plain

うーん、残念。

ホームアイコンは追加してもらえてるのか?

リリースして一週間ほど経ちます。GAで計測したイベントを確認してみました。 思った以上にポップアップを開いているユーザは多かったのですが、コンバージョンレートが予想よりもよくありませんでした。はっきり言って悪かったです。

まあ、歩みが遅くても着実に増えていけばよいのですが、もう少しなんとかならないかなぁと思っています。

ポップアップを☓ボタンで閉じてしまうユーザが多いのでポップアップの内容を工夫したり、ポップアップではなくページの一部にもホームアイコン追加ボタンを置くなどすればホームアイコンを作るユーザをもっと増やせるかもしれません。今後の課題ですね。

まとめ

  • WorkboxによってServiceWorkerのキャッシュは比較的容易に実装できる
  • Webpackを利用しているのなら webpack pluginを利用すると楽
  • Webpackで出力しないファイルやCDNから配信するファイルのキャッシュも可能
  • ホームアイコンを作ってもらうのはなかなか険しい

最後に

アクトインディではエンジニアを募集しています。 一緒にサービスを成長させてみませんか?

actindi.net


  1. Workbox webpack Pluginsには実装量は増えますがより柔軟に機能実装できる InjectManifestプラグインがあります。