静的サイト向けWorkBoxレシピ

はじめに

WorkboxはGoogle製のJavaScriptライブラリです。PWAに必要となるService Workerの各種機能を簡単な記述で利用できるようにします。このライブラリを使用して、静的サイトのキャッシュとオフライン表示に対応してみました。

結果

Chromeのシークレットモードを利用し、アドオンの影響を受けない状態で転送量を計測しました。 サンプルは自分のポートフォリオサイトを利用しています。

初回ロード

スマホサイズの画面でサイトをネットワーク側から取得した状態です。総転送量は319KBです。

2回目ロード

データはすべてService WorkerのCacheから読み込まれるため、総転送量は662Bでした。 また、オフライン状態でもキャッシュされた範囲のデータは表示が可能です。 読み込み速度はかなり低下しますが、Service Worker非対応のIE11でも表示が可能です。

前提条件

本記事の前提とする

  • 開発環境
  • 対象ブラウザ
  • ホスティング環境

は以下の通りです。

Service Workerは活発に開発と更新が進んでいます。短期間で機能と挙動が大きく変わります。ご注意ください。

開発環境

  • macOS 10.14.3
  • node.js v10.15.1

を前提としてます。node.jsの依存パッケージは以下の通りです。

▼package.json

"browser-sync": "^2.26.5",
"gulp": "^4.0.1",
"gulp-awspublish": "^4.0.0",
"gulp-awspublish-router": "^0.2.0",
"gulp-cloudfront-invalidate": "^0.1.5",
"gulp-rev": "^9.0.0",
"gulp-rev-delete-original": "^0.2.3",
"gulp-rev-rewrite": "^1.1.4",
"workbox-build": "^4.3.0"

対象ブラウザ

Service Workerの対応ブラウザおよびIE11でサイトが表示できることを目標とします。

ホスティング環境

Service Workerの実行にはhttps通信が必須です。これは第三者からのキャッシュの改ざんを不可能にするためです。(localhostに限り、http通信でも実行可能です。)

そのためホスティング環境は

  • Amazon S3
  • Amazon CloudFront
  • Amazon Route 53

を使用しています。

ビルド

静的サイトでのWorkboxの利用には

  • 静的ファイルのrevision管理
  • ServiceWorkerのJSファイルのビルド

の2段階の作業が必要になります。

静的ファイルのrevision管理

今回のサイトでは、ServiceWorkerの本体JSファイルとhtml以外のすべての静的ファイルは、ファイル名にハッシュを追加してrevision管理を行います。 静的ファイルのファイル名にハッシュを追加することで、ファイル更新後に旧バージョンのキャッシュが読み込みを避けられます。更新があればファイル名も変わり、別のファイルとして扱われます。その結果、混在の危険を避けキャッシュの有効期間を最大限長くできます。

revision管理にはこれらのパッケージを使用しています。

▼package.json

"gulp": "^4.0.1",
"gulp-rev": "^9.0.0",
"gulp-rev-delete-original": "^0.2.3",
"gulp-rev-rewrite": "^1.1.4",

今回はgulpとgulp-revを中心としたパッケージを利用していますが、gulpは必須ではありません。皆様のワークフローにあったパッケージを選んでください。

serviceWorker.jsのビルド

Workboxは、Node.jsから操作が可能なworkbox-buildを利用します。以下がビルド用のnodeスクリプトです。

▼workbox.js

"use strict";
const path = require("path");
const { generateSW } = require("workbox-build");

//variable
const distDir = path.join(process.cwd(), "dist");
const swPath = path.join(distDir, "serviceWorker.js");
const cacheId = "my-cache-id";

generateSW({
    cacheId: cacheId,
    swDest: swPath,
    importWorkboxFrom: "local",
    globDirectory: "./dist/",
    globPatterns: [],
    runtimeCaching: [
        {
            urlPattern: /.+(\/|.html)$/,
            handler: "NetworkFirst",
            options: {
                cacheName: cacheId + "-html-cache",
                expiration: {
                    maxAgeSeconds: 60 * 60 * 24 * 7,
                },
            },
        },
        {
            urlPattern: /.+\.(js|css|woff)$/,
            handler: "CacheFirst",
            options: {
                cacheName: cacheId + "-dependent-cache",
                expiration: {
                    maxAgeSeconds: 60 * 60 * 24 * 90,
                },
            },
        },
        {
            urlPattern: /.+\.(png|gif|jpg|jpeg|svg)$/,
            handler: "CacheFirst",
            options: {
                cacheName: cacheId + "-image-cache",
                expiration: {
                    maxAgeSeconds: 60 * 60 * 24 * 30,
                },
            },
        },
    ]
}).then(({ count, size, warnings }) => {
    for (const warning of warnings) {
        console.warn(warning);
    }
    console.log(
        `Generated ${swPath}, which will precache ${count} files, totaling ${size} bytes.`
    );
});

それでは個別の設定項目を解説します。

cacheId

▼workbox.js

generateSW({
    cacheId: **,
    ...

cacheIdはこのPWAが管理するキャッシュの一意なIDになります。同一ドメイン(とくに開発環境のlocalhost)で複数のPWAが稼働していた場合、キャッシュを混同しないためにつけるものです。このIDはアプリに対して常に1つですので、バージョンを付与するのはやめましょう。キャッシュのバージョン管理はWorkboxが担当してくれます。

swDest

▼workbox.js

generateSW({
    ...
    swDest: swPath,
    importWorkboxFrom: "local",
    ...

swDestは出力されたServiceWorkerスクリプトの保存先とファイル名を指定します。この記事では出力したファイルをserviceWorker.jsとしています。serviceWorker.jsの保存されたディレクトリが、serviceWorker.jsがデフォルトで管理できるディレクトリになります。問題がなければそのサイトのルートディレクトリに配置することをオススメします。 importWorkboxFromはserviceWorker.jsから呼び出すWorkboxライブラリの取得元を指定します。デフォルト値はcdnで、GoogleのCDNからWorkboxライブラリを取得します。localに変更するとWorkboxライブラリもswDestと同一階層に展開されて読み込まれます。今回はCloudFrontを利用しているため、localに展開しました。

precache

▼workbox.js

generateSW({
    ...
    globDirectory: "./dist/",
    globPatterns: [],
    ...

globDirectoryおよびglobPatternsはprecache対象を指定するオプションです。

precacheとは、PWAのコア部分をキャッシュする機能です。ServiceWorkerのインストールと同時にキャッシュを生成します。

  • 必ずキャッシュが存在している
  • 自動でリビジョン管理が行われ、古いキャッシュや重複したキャッシュが排除される
  • バックグラウンドで更新の確認が行われる
  • 再起動時に最新のファイルが自動で適用される

という利点があります。 オフラインファーストのPWAを作るのには中核となる機能ですが、今回は静的サイトのキャッシュとして利用するためprecacheは利用しません。globPatternsに空の配列を渡すことでprecache対象を無しにできます。globPatternsを設定しないと、デフォルトでhtmlとcssがprecache対象になりますので明示的に空の配列を設定しています。

runtimeCaching

▼workbox.js

generateSW({
    ...
    runtimeCaching: [
            {
            ...
            },
        ]
    ...

今回サイトのキャッシュに使用するのが、runtimeCachingです。こちらはprecacheとは異なり、ネットワークリクエストが発生した際にそれぞれキャッシュが作成されます。

  • キャッシュが存在するか否かは不明、ネットワークリクエストが発生したかどうかで別れる
  • 古いキャッシュの扱い、更新確認のタイミングなどはそれぞれのキャッシュ戦略に左右される
  • リクエストしていないファイルはキャッシュされないため、容量は最小限になる

という利点と欠点があります。precacheもruntimeCacheもオフラインで利用可能なのは同じです。

htmlのruntimeCaching

▼workbox.js

    runtimeCaching: [
        {
            urlPattern: /.+(\/|.html)$/,
            handler: "networkFirst",
            options: {
                cacheName: cacheId + "-html-cache",
                expiration: {
                    maxAgeSeconds: 60 * 60 * 24 * 7,
                },
            },
        },
        ...

runtimeCaching配列の中には、対象を指定する正規表現のurlPattern、キャッシュ戦略を指定するhandler、そのほかの設定のoptionsをセットにした設定オブジェクトを詰めていきます。この設定セット1つに対してキャッシュストレージ内にキャッシュオブジェクトが1つ割り当てられます。個別のキャッシュはこのキャッシュオブジェクト内に格納されます。

urlPattern

このurlPatternの場合は末尾が/で終わるか拡張子.htmlを対象とするruntimeCaching設定になります。

handler

キャッシュ戦略はnetworkFirstを利用しています。

このキャッシュ戦略は

  • 優先してネットワーク側からファイルの取得を試みる
  • オフラインの場合はキャッシュを利用する
  • オンラインで、かつ更新がなかった場合もキャッシュを利用する
  • 更新があったら最新ファイルをキャッシュに格納し、その後ブラウザに結果を返す
  • キャッシュもなくオフラインの場合は諦める

という挙動をします。ファイル名でのrevision管理が不能で、かつ更新頻度が高いファイルに向いたキャッシュ戦略です。しかしほかのキャッシュ戦略よりもネットワークに対する負荷は大きくなります。本記事ではhtmlのみがnetworkFirst戦略の対象になります。

options

options内にはさまざまな設定を追加できますが、とくに重要なのがexpirationです。これはブラウザキャッシュのように、このキャッシュオブジェクト内のキャッシュの有効期限や最大数を制限するものです。 仕様上、Service Workerのキャッシュは削除処理がない場合は永続します。precacheの場合、Workboxが指定から外れた古いキャッシュは自動的に削除してくれますので心配はありません。しかしランタイムキャッシュには差分検出と削除の仕組みがありません。その結果、削除条件のないランタイムキャッシュが積もってユーザーの貴重なストレージ領域を圧迫します。ランタイムキャッシュを設定する場合は、削除条件を合わせて指定しましょう。 (この点については正確な調査ができていませんが、もしかしたらWorkbox側でランタイムキャッシュのデフォルト削除条件を指定してるのかもしれません。) 今回htmlはnetworkFirst戦略を選択しているのもあり、キャッシュの有効期間は比較的短めな1週間に指定しました。

依存ファイルのruntimeCaching

▼workbox.js

runtimeCaching: [
    ...
    {
        urlPattern: /.+\.(js|css|woff)$/,
        handler: "cacheFirst",
        options: {
            cacheName: cacheId + "-dependent-cache",
            expiration: {
                maxAgeSeconds: 60 * 60 * 24 * 90,
            },
        },
    },
    ...

css, js, webフォントファイルのruntimeCaching設定です。

handler

キャッシュ戦略はcacheFirstを利用しています。

このキャッシュ戦略は

  • 優先してキャッシュ側からファイルの取得を試みる
  • 有効期限内のキャッシュが存在する場合はキャッシュを利用する
  • オンラインで、かつキャッシュが期限切れの場合はネットワークに接続し、最新ファイルをキャッシュに格納した後、ブラウザに結果を返す
  • キャッシュもなくオフラインの場合は諦める

という挙動をします。ファイル名でのrevision管理が可能なファイルに向いたキャッシュ戦略です。ネットワークに対する負荷も低く抑えられます。

options

更新頻度が低く、ファイルのサイズは小さく、かつrevision管理で古いキャッシュを掴む心配もないため、キャッシュの有効期限は極力長く設定します。今回の場合は3か月を指定しました。

画像のruntimeCaching

▼workbox.js

    runtimeCaching: [
        ...
        {
            urlPattern: /.+\.(png|gif|jpg|jpeg|svg)$/,
            handler: "cacheFirst",
            options: {
                cacheName: cacheId + "-image-cache",
                expiration: {
                    maxAgeSeconds: 60 * 60 * 24 * 30,
                },
            },
        },
        ...

画像ファイルのキャッシュ設定です。画像ファイルもrevision管理が可能なため、cacheFirst戦略を利用しています。ファイルサイズは大きくなるため、ストレージへの影響を考えて依存ファイルよりも有効期間を短めに設定しています。

serviceWorker.jsの読み込み

serviceWorker.jsがWorkboxから生成されたら、js側から読み込みを行います。if ("serviceWorker" in navigator)で非対応環境を判定しています。

▼main.js

if ("serviceWorker" in navigator) {
    navigator.serviceWorker
        .register("serviceWorker.js")
        .then(reg => {
            console.log(reg);
        })
        .catch(e => {
            console.log("Registration failed with " + e);
        });
}

デプロイ

ホスティング環境にファイルをアップロードする際には、CloudFrontへの影響と、IE11への対応を考えて

  • Cache-Controlヘッダーの設定
  • CDNのキャッシュの無効化

を行います。

Cache-Controlヘッダーのmax-age設定

html

この記事の設定では、WorkBoxはhtmlに対してnetwork first戦略を取ります。WorkBoxは必ずネットワークから最新のファイルを確認します。

CloudFrontはキャッシュ更新にmax-ageを利用します。max-ageがゼロでない場合、CloudFrontはその期間中S3にアクセスせず、CloudFront自身のキャッシュを返します。キャッシュが生きている間に新しいファイルをデプロイすると、CloudFront上に古いキャッシュが残ります。そのためデプロイの度にファイルの無効化(Invalidation)を行います。ファイルの無効化が行われたあとは一度だけ、CloudFrontは最新のhtmlファイルをS3から取得します。 (念のため確認を行いましたが、IE11でもファイルの無効化後はHTTPリクエストヘッダーのIf-Modified-Sinceを利用してS3から最新のファイルを取得しました。)

max-ageをゼロに設定すると、CloudFrontはかならずS3のオリジンに対してリクエストを行い、毎回htmlファイルを取得します。これは読み込み速度の低下につながるため避けるべきです。CloudFront経由で毎回S3にアクセスすると、S3単体の速度よりさらに遅くなります。

したがって、htmlのmax-ageはゼロではなく、短期間に設定するのが適切です。まずは1日程度に設定し、影響を見ながら調整してください。

serviceWorker.js

serviceWorker.jsの更新確認は、今後はブラウザキャッシュを迂回します。そのためserviceWorker.jsのキャッシュ期間も長くできる見込みですが、現状では対応していないブラウザがあります。

現状ではserviceWorker.jsのmax-ageは、デフォルトの更新確認期間の24時間かそれ以下を設定するのが適切です。 この設定はブラウザの開発状況に左右されます。設定の際はそのたびに状況を再確認してください。 Service Worker非対応環境ではそもそもこのファイルは読み込まれませんので、その点を考慮する必要はありません。

それ以外の静的ファイル

htmlとserviceWorker.js以外の静的ファイルはすべてファイル名にハッシュを付与されているので、キャッシュの寿命は極力長く設定します。キャッシュの寿命がどれだけ長くても、古いファイルを読み込んでしまう危険性はありません。 ブラウザキャッシュはブラウザ側の判断で削除されますので、誤って永続化してしまう心配もありません。

CDNキャッシュの無効化と再構築

この記事の設定では

  1. S3
  2. CloudFront
  3. Service Workerのキャッシュストレージ

という経路でファイルが伝播します。

同一ファイル名でファイルの更新を行なった場合、上記の経路1と2の間で更新が滞ります。ファイルがrevision管理されている場合、この問題は発生しません。CloudFrontの更新のタイミングの詳細はAWSのドキュメントを参照してください。

今回の例では、同一ファイル名でファイルの更新が発生しうるのは以下の2種類になります。

  1. htmlファイル
  2. serviceWorker.js

この2つのファイルの更新に失敗しないよう、デプロイ時にはファイルの無効化(Invalidation)を行います。ファイルの無効化が実行されると、CloudFront上から対象のキャッシュが削除され、次のリクエスト時に最新のファイルをS3へ確認に行きます。

上記のファイルを無効化するためには

 ["/index.html", "/serviceWorker.js", "/"]

の3つのパスを無効化します。 注意点としてCloudFrontはファイルに対してではなくURLに対してキャッシュを持ちますので、ディレクトリルート(例:index.html)を無効化するには"index.html"と末尾が"/"になるURLの両方を無効化する必要があります。より詳しい情報はAWSの公式ドキュメントを参照してください。

今回の例では適用ができませんが、パスの末尾に限り*(ワイルドカード)が指定できます。**末尾以外のパス内では、ワイルドカード指定はできません。**ワイルドカード指定を利用することで、少ないパス数でファイルの無効化が可能になり、またファイル無効化の漏れを防ぐことができます。 詳しくはAWSの公式ドキュメントの無効化パスの項目を参照してください。

ファイルの無効化はデプロイごとに必要なので、ビルドプロセスに統合することをオススメします。

gulp-cloudfront-invalidate

gulpの場合はこのプラグインでファイルの無効化が実行できます。各種タスクランナー用に同様の機能を持ったモジュールが存在しますので、皆様のワークフローにあったものを導入してください。

開発環境Tips

Chromeの各種機能を利用すると、ServiceWorkerの開発効率が上がります。

キャッシュストレージの削除

Chromeの開発者ツールから、キャッシュストレージの削除が可能です。キャッシュ構成を変更する場合、従来のキャッシュを削除できます。

アップデートオプション

Googleの解説 この設定をONにすると、新しいバージョンのサービスワーカーにアップデートされた後、待機状態をスキップして即座に稼動します。頻繁に更新を行う開発者にはとても便利な機能です。

ネットワークバイパスオプション

Bypass for networkチェックボックスをONにすると、ネットワーク関係の処理がSWをバイパスし、リソースを直接オンラインから取得するようになります。html, js, cssの開発中チェックボックスをONにしておくと従来通りホットリロードで編集結果を確認できます。デザイナーに嬉しい機能です。

javascriptライブラリの対応確認

javascriptライブラリの中には、ServiceWorkerに十分対応できていないものが多くあります。導入の際はライブラリが正常に動作しているかのテストを行なってください。

とくにload系のイベントで呼び出される処理と、ServiceWorkerのキャッシュからの読み込みがバッティングした際に問題が起きやすいようです。今回対象にしたサイトではscrollrevealMobileオプションを設定すると初期化処理が走らないという問題が発生したため、オプションを削除しました。

以上、ありがとうございました。