🧰

PWAマスター講座 第2部:Workboxについて

2024/12/11に公開

ウェブ開発者の方々、こんにちは。PWA開発シリーズの第2部です。

入門記事 では、Service Worker の基本的な仕組みについて解説しました。実際に試してみるとわかると思いますが、キャッシュの更新、クライアントとのやり取りなど、基本的な機能を実装するにはかなりのコード量が必要になります。

今回は、Workbox というライブラリを活用することで、PWA開発を大幅に効率化します。
コード例や細かい点もたくさんあるので、どうぞご辛抱ください(そして美味しい何かを片手に)。

この記事では、今後のPWA開発で活用できる、Workbox の包括的なリファレンスを目指します。

wb-logo

Workbox とは?

Workbox は、Google が開発した、PWA (Progressive Web App) の開発を容易にし、ウェブアプリのオフライン機能を強化するための強力なライブラリです。リソースの効率的なキャッシュとfetch対応、Service Worker の管理、オフラインシナリオの実装のためのツールと戦略を提供します。Workbox は、一般的なキャッシュ戦略の実装を簡素化し、開発者にとって信頼性の高いウェブアプリを作成するための包括的なツールセットを提供します。

なぜ必要なのか

Service Worker の開発には、多くの細かな点があります。アプリケーションの規模が大きくなると、開発者は、ますます複雑な相互作用を適切に動作させることが難しくなります。ネットワークリクエスト、キャッシュ戦略、キャッシュの管理、プリキャッシュなど、これらすべてを理解しないと、効率的な Service Worker の開発は不可能に思えます。しかし、それは Service Worker の技術が良く設計されていなかったという意味ではありません。このような複雑さは避けられません。なぜなら、この技術は、非自明な問題を解決することを目的としているからです。

一方では、開発者はリクエストのインターセプト、キャッシュなどに対する高い制御権を得ることができ、これにより非常に特殊なシナリオを実現できます。他方では、この高い制御権は、Service Worker の一般的なユースケースの実装を、本来あるべきよりも複雑にします。

そして、ここで Workbox が登場します。優れた抽象化により、複雑な API をより便利に使用できます。Workbox は、キャッシュやネットワークリクエストの管理といった一般的な操作を簡素化するモジュール群で構成されています。各モジュールは、Service Worker の使用時に発生する特定のタスクセットを解決することを目的としています。Workbox は、ネイティブな Service Worker の機能を制限することなく、可能な限り Service Worker の開発を簡素化することを目指しています。

動作原理

Workbox の主な役割は、service worker ファイルを構築することです。Workbox は柔軟性に優れています。構築は、完全に自動化されたものから、開発者が記述した既存の Service Worker コードに基づくものまで、さまざまな方法があります。最も簡単なケースでは、generateSW 関数だけで十分です。これにより、提供された設定に従ってリソースをキャッシュする service worker が自動的に生成されます。既存の Service Worker コードに基づいてより詳細な設定が必要な場合は、injectManifest メソッドを使用できます。

generateSW

設定の柔軟性は低くなりますが、使いやすさを優先した方法です。ユーザーの構成に基づいて生成された、完成した Service Worker を出力します。

generateSW を使用するべきケース:

  • ビルドプロセスに関連付けられたファイルの事前キャッシュを実行したい。これらのファイルの URL には、事前にわからないハッシュが含まれている可能性があります。
  • generateSW の設定で提供される、単純な動的キャッシュ設定で十分です。

generateSW を使用すべきでないケース:

  • Service Worker の追加機能(Web Push, Background Sync など)を使用したい。
  • 外部スクリプトを Service Worker コードにインポートしたり、特定の Workbox モジュールを使用してアプリケーションのニーズに合わせて service worker を設定したりする必要がある。

injectManifest

設定は複雑になりますが、柔軟性が大幅に向上します。このシナリオでは、ユーザーが記述した Service Worker を基に service worker が生成されます。入力として、ビルドデータに基づいて生成される静的リソースのアドレスの配列である precache manifest を渡します。言い換えると、service worker のコードは自分で記述する必要があります。(最初の部分で基本的な原則を説明しているので、大丈夫です。)

injectManifestgenerateSW と根本的に異なります。injectManifest は、ユーザーが提供した Service Worker ファイルに基づいて最終的な Service Worker ファイルを生成します。ビルド時に、Service Worker コード内で self.__WB_MANIFEST という特別な文字列を探し、その場所に precache manifest を挿入します。

injectManifest を使用するべきケース:

  • ファイルの事前キャッシュを実行したいが、独自のロジックも記述したい。
  • generateSW の設定ではカバーされない、キャッシュなどに関連する非標準的なニーズがある。
  • Web Push、Background Sync など、追加の Web API を使用したい。

injectManifest を使用すべきでないケース:

  • 事前キャッシュは必要ない。
  • プロジェクトに必要な Service Worker の機能は generateSW の設定でカバーできる。
  • 設定の簡単さを柔軟性よりも重視する。

Workbox Build の利用

Workbox を用いた Service Worker のビルドツールを簡単に紹介します。ここでは、フレームワークに依存しない方法のみを説明します。特定のフレームワークに対応した方法については、こちら をご覧ください。

img

Service Worker のビルドには、主に 3 つの選択肢があります。

  • workbox-cli
  • workbox-build - コマンドラインツール
  • バンドラーを使用する(workbox-webpack-plugin などの対応するプラグインを使用)

これらのツールは、generateSWinjectManifest の両方で使用できます。

Workbox CLI の利用

最も簡単な方法は、Workbox CLI を使用することです。まず、それをインストールする必要があります。

npm install workbox-cli --save-dev

その後、ウィザードを実行して設定します。

npx workbox wizard

選択したパラメーターに基づいて、workbox-config.js が生成されます。これは、ニーズに合わせてカスタマイズできます。以下のような形になります。

// config.js
export default {
    globDirectory: 'dist/',
    globPatterns: [
        '**/*.{css,woff2,png,svg,jpg,js}'
    ],
    swDest: 'dist/sw.js'
};

構成ファイルが作成されると、CLI は generateSWinjectManifest の両方を実行できます。
たとえば、generateSW を使用して Service Worker を生成するには、次のようにします。

npx workbox-cli generateSW ./config.js

workbox-build

workbox-build を使用する場合、config を作成する必要はありません。代わりに、generateSW または injectManifest メソッドを直接呼び出し、パラメーターとして構成ファイルと同様の設定を指定します。

// build-sw.mjs
import {generateSW} from 'workbox-build';

generateSW({
    globDirectory: 'dist/',
    globPatterns: [
        '**/*.{css,woff2,png,svg,jpg,js}'
    ],
    swDest: 'dist/sw.js'
});

このスクリプトを Node を通じて実行すると、dist ディレクトリに Service Worker ファイルが作成されます。

node build-sw.mjs

バンドラーの使用

さまざまなバンドラーには、Workbox を統合する独自のプラグインがあります。Workbox チームが公式にサポートする唯一のプラグインは workbox-webpack-plugin です。使用方法は次のとおりです。

// webpack.config.js
import {GenerateSW} from 'workbox-webpack-plugin';

export default {
    // その他の設定
    plugins: [
        new GenerateSW({
            swDest: './dist/sw.js'
        })
    ]
};

GenerateSW または InjectManifest のパラメーターは、workbox-cli または workbox-build のものとは異なりますが、かなりの共通点があります。

generateSW を使用すると、Service Worker の生成に対する制御が大幅に制限されます。シンプルな設定で十分であれば、公式ドキュメントで workbox-cliworkbox-build を詳しく確認できます。このシリーズは、Service Worker 内部のプロセスをより深く理解することを目的としているため、以降は injectManifest の方法のみを扱います。

injectManifest を使用すると、開発者は Service Worker のロジックを自分で記述する必要があります。良い点は、Workbox には、service worker の作成プロセスを大幅に簡素化する多くのモジュールが用意されていることです。主要なモジュールを説明し、Workbox の使いやすさを実例で確認します。

workbox-core

Workbox の重要な設計思想の一つにモジュール化があります。しかし、多くのモジュールで共通して使われるユーティリティ関数があります。それらを各モジュールに複製する代わりに、workbox-core に集約されています。そのため、workbox-core は Workbox ベースのライブラリ開発者にとって特に有用ですが、アプリケーション開発者にとっても便利な機能がいくつか含まれています。

setCacheNameDetails

様々なタイプのキャッシュの名前を設定することができます。Workbox の内部定数を変更するため、単純な Service Worker API とは直接対応していません。

import {cacheNames, setCacheNameDetails} from 'workbox-core';

setCacheNameDetails({
    // キャッシュ名にプレフィックスとして追加する文字列
    prefix: 'my-app',
    // キャッシュ名にサフィックスとして追加する文字列
    suffix: 'v1',
    // プリキャッシュ用キャッシュ名
    precache: 'install-time',
    // ランタイム用キャッシュ名
    runtime: 'run-time',
});

// 出力結果は 'my-app-install-time-v1'
console.log(cacheNames.precache);

// 出力結果は 'my-app-run-time-v1'
console.log(cacheNames.runtime);

clientsClaim

名前の通り、以前の記事で解説した self.clients.claim() メソッドと類似しています。このライブラリメソッドは同じ機能を提供しますが、コードを簡潔に記述できます。

import {clientsClaim} from 'workbox-core';
// 必ずトップレベルで呼び出す必要があります
clientsClaim();

これはネイティブの Service Worker API の以下のコードと等価です。

self.addEventListener('activate', event => {
    // 必要に応じて新しい Service Worker を自動的に有効化
    self.clients.claim();
});

workbox-precaching

プリキャッシングとは、Service Worker のインストール時にリソースをキャッシュするプロセスです。これにより、開発者はデフォルトでネットワーク接続なしで利用可能なリソースと、それらのリソースをキャッシュする期間を指定できます。Workbox は、一般的なシナリオのハンドラーを提供することで、これらの操作を簡素化します。

precacheAndRoute

Workbox を使ってプリキャッシュの基本的な例を見てみましょう。

import {precacheAndRoute} from 'workbox-precaching';

// アプリのバージョンを表す値の例(本番環境ではビルド情報を使用するべき)
const buildHash = "64347f3b32969e10d80c";

// オフラインでも利用可能なページを事前にキャッシュする
precacheAndRoute([
    {url: "/", revision: buildHash},
    {url: "/dashboard", revision: buildHash},
    {url: "/catalog", revision: buildHash},
])

Workbox を使わない場合、ほぼ同じ機能は次のようになります。

const precacheResources = ["/", "/dashboard", "/catalog"];

self.addEventListener('install', (event) => {
    event.waitUntil(async () => {
        const cache = await caches.open("precache-v1");
        cache.addAll(precacheResources);
    });
});

self.addEventListener("fetch", event => {
    async function cacheFirst(url) {
        const cacheStorage = await caches.open("cache-v1");
        const cachedResponse = await cacheStorage.match(url);

        if (cachedResponse) {
            return cachedResponse;
        } else {
            const response = await fetch(url);
            await cacheStorage.put(url, response);
            return response;
        }
    }

    if (precacheResources.includes(event.request.url)) {
        event.respondWith(cacheFirst(event.request.url));
    }
});

precacheAndRoute は、インストール時にリソースをキャッシュするだけでなく、cacheFirst ハンドラーも追加します。この戦略の基本的な考え方は、キャッシュに存在すればキャッシュから、そうでなければネットワークから取得するというものです。

しかし、Workbox の魔法はそれだけではありません。

  • 重複するリソースは削除されます。
  • キャッシュされたリソースの revision は設定されます。

これは Service Worker API にはなかった機能ですが、Workbox は新しい Service Worker バージョンで変更されたリソースを正確に特定できます。例えば、最初の Service Worker はフォントと CSS をキャッシュしていました。2 番目のバージョンでデザインが変更された場合でも、フォントは変更されていません。Workbox は revision を使って違いを検出し、CSS を更新しますが、フォントは変更されません。

ビルド出力時にファイル名にハッシュが追加されている場合は、revision を明示的に指定する必要はありません。例えば、/styles/app.0c9a31.css の場合は、{url: '/styles/app.0c9a31.css', revision: null} と指定できます。この場合、revision はファイル名から 0c9a31 に設定されます。

precacheAndRoute 関数の 2 番目の引数でプリキャッシングの動作をカスタマイズできます。例えば、デフォルトでは、/about のリソースがキャッシュにない場合、Workbox は /about.html もチェックします。これは cleanURLs をデフォルトでサポートしています。この動作を変更するには:

import {precacheAndRoute} from 'workbox-precaching';

precacheAndRoute([{url: '/about.html', revision: 'b79cd4'}], {
    cleanUrls: false,
});

パラメータの詳細は こちら を参照してください。

プリキャッシュされたリソースを動的なリクエストの処理結果として返す必要がある場合があります。matchPrecache メソッドを使用できます。例えば、キャッシュにないウェブページがサーバーから取得できない場合、事前にキャッシュされたオフラインフォールバックを返すことができます。

import {matchPrecache} from 'workbox-precaching';

const response = await matchPrecache('/offline.html');

cleanupOutdatedCaches

このメソッド名は少し分かりにくいですが、非常に特定のシナリオをカバーしています。古いバージョンの Workbox で追加されたキャッシュを削除するために使用されます。v3 から v4 への移行が、この機能を追加するきっかけになったようです。いずれにせよ、使用されないキャッシュを保持しておく意味はありません。Service Worker のコードにこの行を追加することをお勧めします。

import {cleanupOutdatedCaches} from 'workbox-precaching';

cleanupOutdatedCaches();

workbox-routing

Workbox は、非常に使いやすい柔軟なリクエスト処理システムを提供します。例えば、ハンドラーは通常のネットワークリクエストに応答したり、キャッシュからのレスポンスを返したり、サービスワーカー内でコンテンツを生成するなど、より複雑なロジックを実装できます。あなたのニーズに合わせて、workbox-routing モジュールは適切なソリューションを提供します。

Workbox のルーティングメカニズムを理解する前に、重要な概念を説明します。

ルーティングの仕組み

ルーティングとは、サービスワーカー内の入ってくる fetch リクエストとハンドラーを関連付けることです。Workbox は同じリクエストに対して複数のハンドラーを登録できますが、どのハンドラーが使用されるかを理解することが重要です。

サービスワーカー全体でデフォルトのハンドラーを追加できます。これは、特定のハンドラーが見つからない場合に呼び出されます。

デフォルトハンドラーと同様に、キャッチハンドラーも設定できます。これは、他のハンドラーの呼び出しがエラーで終了した場合に実行されます。

ハンドラーを登録するには、matchhandler という 2 つの関数パラメータを指定する必要があります。

  • match は、「このハンドラーはリクエストの処理に使用すべきか?」という質問に答えます。
  • handler は、リクエストを処理する実際の処理手順です。
  • 3 番目のオプションのパラメータとして、リクエストの種類 (GET、POST、PUT、DELETE など) を指定できます。

これらの関数の詳細については後ほど説明しますが、まずはルーティングの一般的な仕組みを見てみましょう。

ルーティング
Workbox のルーティング図

この図に従って説明します。

  1. サービスワーカーはクライアントからのリクエストを受け取ります。
  2. 同じリクエストタイプ (GET、POST など) のすべてのルートが選択されます。
  3. 選択されたすべてのルートに対して match 関数が実行されます。matchtrue を返した最初のハンドラー (登録順) が使用されます。
  4. ハンドラーが見つかった場合:
    1. handler 関数を呼び出します。
    2. エラーが発生しなかった場合、その結果をクライアントに返します。
    3. エラーが発生した場合、キャッチハンドラーが存在するかどうかを確認します。存在する場合、4.1 に進みます。
    4. キャッチハンドラーが存在しないか、エラーで終了した場合、エラーをスローします
  5. ハンドラーが見つからない場合:
    1. デフォルトハンドラーが存在するかどうかを確認します。存在する場合、4.1 に進みます。
    2. デフォルトハンドラーが存在しない場合、そのリクエストは無視されます。リクエストはチェーン上でさらに処理され、サービスワーカーで fetch イベントの個別サブスクリプションがある場合に処理されます。

したがって、一般的なハンドラーの実行順序は次のようになります。

  1. 特定のパラメータ (URL、ヘッダーなど) を持つリクエストの専用ハンドラー
  2. 専用ハンドラーが存在しないリクエストの一般的なハンドラー
  3. ネイティブの fetch イベントハンドラー

一般的に、ルートは次のように登録されます。

import {registerRoute} from 'workbox-routing';

registerRoute(matcher, handler);

デフォルトでは、ルートは GET リクエストに対して登録されます。 しかし、3 番目のパラメータでこの動作を変更できます。

import {registerRoute} from 'workbox-routing';

registerRoute(matcher, handler, "POST");

このパラメータはあまり使用されませんが、オフライン機能がオンライン版と完全に一致する PWA では、ローカルで作成、更新、削除を行うメカニズムを実装する必要があります。この場合、データをローカルに保存し (ローカルデータベースなど)、サーバーと同期する複雑なロジックが必要になります。このシナリオでは、3 番目のパラメータが非常に役立ちます。

match関数について

前述の通り、match 関数は、入ってきたリクエストが指定した条件に合致するかを判定し、true または false を返します。最も単純な例は、URL が特定の定数と一致するかを調べる場合です。

const matchCallback = ({request, event}) => {
    return request.url === '/api/ping';
};

しかし、リクエストを選択する基準はこれだけに限りません。
例えば、リクエストされるリソースの種類に基づいて判断することもできます。具体的な例として、クライアントがフォント(静的なリソースで、ほとんど変更されないか、全く変更されない)をリクエストしている場合、キャッシュからすぐに返却する方が適切です。

const matchFontsCallback = ({request, event}) => {
    return request.destination === 'font';
};

これらの例はほんの一部です。Request オブジェクトに含まれるあらゆる情報を利用できます。

Workbox は、事前に定義された match 条件を持つプリセットも提供しています。その中でも特に便利なクラスに NavigationRoute があります。これは、新しい HTML ページがリクエストされた場合 (request.mode === "navigate") に true を返すルートを定義できます。

import {NavigationRoute, registerRoute} from 'workbox-routing';

const handler = () => {
    // ハンドラのロジック
};

// ハンドラだけを定義する
const navigationRoute = new NavigationRoute(handler);
registerRoute(navigationRoute);

match 関数の代わりに、正規表現を使うこともできます。

registerRoute(/api\/.+/, handler);

handle 関数について

match 関数がいずれかのハンドラに対して true を返した場合、handle 関数が実行されます。ハンドラの最終的な目的は、クライアントに Response を返すことです。しかし、その前に、追加の処理(例えば、レスポンスをキャッシュする)を行うことができます。さらに、サーバーから受け取ったレスポンスを変更し、新しい情報で補完することも可能です。これには、サーバーが返す Response と同じ Response クラスを使用します。Response について詳しくは、MDN Web Docs を参照してください。

const handlerCallback = async ({request, event, params}) => {
    // 提供された URL をフェッチする
    const response = await fetch(request);
    // フェッチした情報を取得する
    const responseBody = await response.text();
    // 新しいレスポンスを作成して、既存の情報に変更を加える
    return new Response(`${responseBody} <!-- Service Worker からのメッセージ -->`,
        // 元のヘッダーを使用する
        {
            headers: response.headers,
        });
};

handle 関数は、Response オブジェクトで解決する Promise を返す必要があります。上記の例では、async/await を使用しており、これは関数に自動的に Promise を付与します。ほとんどの場合、独自のハンドラを作成する必要はありません。Workbox は、リクエスト処理でよく使用される戦略を提供しています。それらについては、workbox-strategies のセクションで説明します。

デフォルトハンドラ

独自のハンドラを持たないすべてのリクエストに対して、デフォルトのハンドラを追加できます。例えば、ネットワークが利用できない場合でもキャッシュからデータを表示できるように、すべてのサーバーレスポンスをデフォルトでキャッシュできます。

import {setDefaultHandler} from 'workbox-routing';
import {NetworkFirst} from "workbox-strategies";

// NetworkFirst は下記の workbox-strategies 項目で説明する
setDefaultHandler(new NetworkFirst());

Catch ハンドラ

Catch ハンドラは、デフォルトハンドラと同様に設定されます。Catch ハンドラの一般的な使用例は、キャッシュからのフォールバックを提供することです。例えば、ウェブページの読み込みに失敗した場合、"インターネット接続を確認し、もう一度お試しください" というメッセージが表示されるページを返すことができます。

import {setCatchHandler} from 'workbox-routing';
import {matchPrecache} from 'workbox-precaching';

// オフライン用のページをプリキャッシュする
precacheAndRoute([{url: '/fallbacks/offline-page.b79cd4.html', revision: null}]);

setCatchHandler(({request, event, params}) => {
    // ページの取得に失敗したら、オフライン用のページを返す
    if (request.destination === "document") return matchPrecache("/fallbacks/offline-page");
});

workbox-strategies

実用的な場面では、多くの場合、標準的なリクエスト処理戦略で十分です。このモジュールには、一般的なアプリケーション開発でよく使用される戦略が含まれています。 前回は Cache First 戦略について説明しました。今回は、さらに4つの戦略について見ていきます。

Network First

Cache First の対極にあるのが Network First 戦略です。Cache First はキャッシュの内容を優先し、キャッシュがない場合にのみネットワークを使用するのに対し、Network First はその逆です。
img
Network First 戦略の図

アルゴリズムは以下のとおりです。

  1. クライアントが fetch リクエストを送信します。
  2. Service Worker がリクエストをインターセプトし、ネットワークに同じリクエストを送信します。
  3. リクエストが成功した場合:
    1. レスポンスをキャッシュに保存します。
    2. レスポンスをクライアントに返します。
  4. リクエストが失敗した場合、キャッシュからのレスポンスを返します。

この戦略は、要求されるデータが頻繁に更新される場合に最適です。各リクエストで最新データを表示する必要がある場合は、Network First を使用してください。

import {registerRoute} from 'workbox-routing';
import {NetworkFirst} from 'workbox-strategies';

registerRoute(
    ({url}) => url.pathname.startsWith('/profile/'),
    new NetworkFirst()
);

Workbox を使用しない場合のコード例を次に示します。

self.addEventListener("fetch", event => {
    async function networkFirst(url) {
        const cacheStorage = await caches.open("cache-v1");
        try {
            const response = await fetch(url);
            await cacheStorage.put(url, response);
            return response;
        } catch (error) {
            const cachedResponse = await cacheStorage.match(url);
            if (cachedResponse) return cachedResponse;
            else throw error;
        }
    }

    if (event.request.url.pathname.startsWith('/profile/')) {
        event.respondWith(networkFirst(event.request.url));
    }
});

以降の戦略については、ネイティブな Service Worker コードでの実装例は省略します。演習として、これらの戦略をネイティブな Service Worker コードで実装してみてください。

Network Only

Network Only 戦略は、Service Worker なしで fetch リクエストを行うのと同じです。ネットワークがデータの唯一のソースであり、キャッシュはまったく使用されません。

img
Network Only 戦略の図

ネットワークの接続が必須な場合に使用されます。たとえば、管理パネルに最新の情報を表示する必要がある場合などです。管理者が行った変更はすぐに反映される必要があります。管理者の操作をローカルに長時間保存することはできません(データベースのデータが頻繁に変更される場合、すぐに意味を失う可能性があるため)。Network Only 戦略を使用します。

import {registerRoute} from 'workbox-routing';
import {NetworkOnly} from 'workbox-strategies';

registerRoute(({url}) => url.pathname.startsWith('/admin/'), new NetworkOnly());

Cache Only

Network Only があれば、Cache Only も必要です。この戦略はネットワークを使用せず、すべてのデータはキャッシュから直接取得されます。

img
Cache Only 戦略の図

通常、precache と組み合わせて使用されます。静的なリソースを即座に返すことができます。更新がないことが保証されている場合に適しています。フォントや、一度追加され、変更されないことが保証されているその他のファイルに使用できます。

Stale-While-Revalidate

Network First と Cache First の組み合わせです。キャッシュからデータを取得し、より新しいデータで静かに更新します。これによりパフォーマンスが向上しますが、表示されるデータの最新性は犠牲になります。

img
Stale While Revalidate 戦略の図

アルゴリズムは以下のとおりです。

  1. クライアントが fetch リクエストを送信します。
  2. Service Worker がリクエストをインターセプトし、キャッシュからレスポンスを返します(キャッシュがある場合)。
  3. サーバーに最初のステップで取得したリクエストを送信します。
  4. リクエストが成功すると、キャッシュが更新されます。

この戦略は、リクエストに迅速に応答し、同時にキャッシュのデータを更新することを可能にします。アプリケーションで常に最新のデータが必要ではない場合に、パフォーマンスを向上させるために使用されます。言い換えると、データの最新性が極めて重要でない場合は、Stale-While-Revalidate を使用してパフォーマンスを向上させることができます。
注意: Workbox では、キャッシュされたリクエストの年齢に関係なく、キャッシュは常に更新されます。

import {registerRoute} from 'workbox-routing';
import {StaleWhileRevalidate} from 'workbox-strategies';

registerRoute(
    ({url}) => url.pathname.startsWith('/images/avatars/'),
    new StaleWhileRevalidate()
);

カスタマイズ

記述した戦略のいずれも、オプションの構成を使用してカスタマイズできます。

  • cacheName: このハンドラ用のキャッシュストアの名前を設定できます。デバッグに役立ちます。例:API からのレスポンスを ApiCache という名前のキャッシュに保存するなど。
  • plugins: ハンドラの動作を拡張するプラグインの配列。
  • fetchOptions: このハンドラから fetch リクエストを送信するときに使用する Request のオプションを設定できます。
  • matchOptions: このハンドラがキャッシュを使用するときに使用するキャッシュオプションを設定できます。

プラグインについて詳しく説明します。Workbox は、戦略に接続できるいくつかのプラグインを提供しています。workbox-background-syncworkbox-broadcast-updateworkbox-cacheable-responseworkbox-expirationworkbox-range-requests などです。

ExpirationPlugin の例を見てみましょう。これは、キャッシュの項目数と有効期限を指定するために使用されます。

import {registerRoute} from 'workbox-routing';
import {CacheFirst} from 'workbox-strategies';
import {ExpirationPlugin} from 'workbox-expiration';

registerRoute(
    ({request}) => request.destination === 'image',
    new CacheFirst({
        cacheName: 'image-cache',
        plugins: [
            new ExpirationPlugin({
                // 1週間のキャッシュ有効期限
                maxAgeSeconds: 7 * 24 * 60 * 60,
                // 最大キャッシュ数
                maxEntries: 10,
            }),
        ],
    })
);

Workbox は、独自の戦略を作成することもできます。この記事では、このプロセスを詳しく説明しません(カスタム戦略はまれに必要です)。興味のある方は、このドキュメント を参照してください。

カスタムプラグイン

Workbox のカスタマイズ機能の一つに、ユーザー定義のプラグインがあります。
戦略とは異なり、プラグインの構文は非常にシンプルで、実際にはより頻繁に記述する必要があります。

開発に役立つ例を一つ見てみましょう。
初めて Service Worker を使用したとき、次のような問題に直面しました。Service Worker は、開発者ツールでネットワークがオフラインに設定されている場合でも、ローカルネットワークへのリクエストを行うことができます。これは、ローカルサーバーを手動で停止および起動する必要があるため、開発を著しく複雑にする可能性があります。ネットワークがオフラインに設定されている場合に、すべてのリクエストを自動的に中断するプラグインを作成しましょう。

基本から始めましょう。すべてのプラグインは、その中に定義された関数によって動作するオブジェクトです。関数の名前は Workbox の開発者によって指定されており、リクエスト処理のさまざまな段階に対応しています。プラグインには最大 12 個のハンドラーが存在する可能性があります。

  • cacheWillUpdate - Response がキャッシュに追加される前に呼び出されます。ここで、response の内容を変更したり、null を返してキャッシュをキャンセルしたりできます。
  • cacheDidUpdate - キャッシュに新しい値が追加されたか、古い値が更新された後に呼び出されます。
  • cacheKeyWillBeUsed - キャッシュの検索とキャッシュへの書き込みの両方、その両方を行う前に呼び出されます。
  • cachedResponseWillBeUsed - キャッシュから Response が返される前に呼び出されます。
  • requestWillFetch - リクエストがネットワークに送信される前に呼び出されます。リクエストを送信する前に Request の内容を変更する必要がある場合に便利です。
  • fetchDidFail - ネットワークへのリクエストがエラーで終了した後に呼び出されます。
  • fetchDidSucceed - ネットワークへのリクエストが正常に終了した後に呼び出されます。
  • handlerWillStart - ハンドラー (handler) の実行前に呼び出されます。
  • handlerWillRespond - ハンドラーからの結果を送信する前に呼び出されます。
  • handlerDidRespond - ハンドラーがハンドラーから結果を返した後に呼び出されます。
  • handlerDidComplete - event.waitUntil(() => {}) 内のすべてのハンドラーが完了した後に呼び出されます。
  • handlerDidError - ハンドラーがリクエストに正しく応答できなかった後に呼び出されます。フォールバックを提供するのに最適なタイミングです。

各関数のメソッドと引数についての詳細は、こちら で確認できます。

プラグインはクラスとして記述することをお勧めします。TypeScript を使用する場合、次の構文を使用する必要があります。

import {WorkboxPlugin} from "workbox-core/types";

export class PluginName implements WorkboxPlugin {

}

プラグインの作成に移りましょう。この場合、fetchDidSucceed の 1 つのハンドラーで十分です。

export class RejectOfflineRequestPlugin {
    async fetchDidSucceed(params) {
        // オフライン状態の場合、手動でエラーを返す
        if (!navigator.onLine) return new Response.error(); 
        // その他の場合、レスポンスをそのまま返す
        else return params.response;
    }
}

リクエストが成功した場合でも、ネットワークの状態に応じてエラーを返すことができます。
このプラグインを戦略に接続します。

import {registerRoute} from 'workbox-routing';
import {CacheFirst} from 'workbox-strategies';
import {RejectOfflineRequestPlugin} from './plugins';

registerRoute(
    /api\/.+/,
    new CacheFirst({
        cacheName: 'api-cache',
        plugins: [
            new RejectOfflineRequestPlugin(),
        ],
    })
);

プラグインは、戦略を非常に柔軟にします。開発中に、戦略に関係なくある種の動作が繰り返されることに気づいたら、プラグインで記述する時かもしれません。

workbox-window

workbox-window は、ブラウザのコンテキスト(window のコンテキスト)で実行される一連のモジュールから構成されています。workbox-window の主な機能/タスクは次のとおりです。

  • Service Worker の登録と更新プロセスを簡素化し、ユーザーが Service Worker のライフサイクルにおける最も重要なポイントを制御できるようにすることで、これらのポイントをより簡単に処理できるようにします。
  • 開発者が 一般的なエラー を回避するのを支援します。
  • Service Worker のコードとウェブページのコード間の 通信を簡素化 します。

workbox-window の利点を活用するには、workbox-window が提供する Workbox クラスを使用する必要があります。これは、ページのコンテキストで Service Worker を登録および管理するために使用されます。

import {Workbox} from 'workbox-window';

if ('serviceWorker' in navigator) {
    const wb = new Workbox('/sw.js');
    wb.register();
}

コードの分離が可能であれば、次のように記述する方が良いでしょう。

if ('serviceWorker' in navigator) {
    // 動的なインポートを使用する
    const {Workbox} = await import('workbox-window');

    const wb = new Workbox('/sw.js');
    wb.register();
}

このコードは、次のネイティブなコードと同等です。

if ('serviceWorker' in navigator) {
    const register = await navigator.serviceWorker.register("/sw.js")
}

前述の通り、Workbox はいくつかの利点を提供します。

ライフサイクルの制御

第1部で、Service Worker のライフサイクルについて説明しました。かなり明らかでない点やニュアンスが多かったと思います。この内容を忘れてしまった場合は、ライフサイクルに関する章 をもう一度読んでください。一見、Service Worker のライフサイクルを詳細に理解することはそれほど重要ではないように思えるかもしれませんが、それは誤解です。

具体的な例を挙げましょう。Service Worker の更新に関するユーザーへの通知です。以前説明したように、新しいバージョンへの制御の即時移行はできません。簡単に言うと、古いバージョン(キャッシュから古いリソースなどを渡していた可能性がある)で動作を開始したページが、新しいバージョンで正しく動作を続ける保証はありません。最も簡単な解決策は、ページをリロードすることです。ウェブアプリは最初から新しいバージョンの Service Worker を使用します。ただし、ユーザーがフォームの入力、記事の閲覧など、重要な作業をしている場合、プログラムでページをリロードすることは失礼です。理想的な解決策は、ユーザーに新しいバージョンの Service Worker について通知し、ユーザーがページをリロードする最適なタイミングを選択できるようにすることです。このようなシナリオを成功させるためには、ライフサイクルのニュアンスを理解することが重要です。

workbox-window は、このプロセスにどのような利点を提供するのでしょうか?
まず、1 つのオブジェクトにまとめられた、シンプルで分かりやすいイベント名です。

通常の Service Worker コードと比較してみましょう。アプリケーションが新しいバージョンの Service Worker を取得すると、updatefound イベントが発生します。

navigator.serviceWorker.register("/my-service-worker.js").then(registration => {
    registration.addEventListener("updatefound", handleUpdate)
})

このイベントに登録するには、非同期関数 navigator.serviceWorker.register の結果である ServiceWorkerRegistration オブジェクトを使用する必要があります。

一方、workbox-window は、より使いやすい方法を提供します。

const wb = new Workbox('/sw.js');
wb.addEventListener("installed", event => {
    if (event.isUpdate) handleUpdate();
})
wb.register();

新しいバージョンが検出されると、デフォルトで更新がインストールされます。installed イベントに登録すると、インストールされた Service Worker が更新であるか、最初のインストールであるかの情報にもアクセスできます。さらに、これはすべて同期的に発生するため、コードはより理解しやすいです!

違いはそれほど大きくありませんが、より分かりやすい例を提示しましょう。
シナリオは次のとおりです。更新がインストールされ、ユーザーにページをリロードするように通知する必要があります。そのため、更新された Service Worker が waiting 状態にあることを理解する必要があります。

registration.addEventListener('updatefound', () => {
    navigator.serviceWorker.register("/my-service-worker.js").then(registration => {
        // 既にリロード待ちのService Workerがあれば
        if (registration.waiting) {
            nofityUser();
        }

        // インストール中のService Workerがあれば
        if (registration.installing) {
            // インストール中のService Workerの状態変更イベントにサブスクライブする
            registration.installing.addEventListener('statechange', () => {
                // 元々インストール中のService Workerはwaiting状態になったら
                if (registration.waiting) {
                    // 有効のService Workerがあれば(古いバージョン)
                    if (navigator.serviceWorker.controller) {
                        nofityUser();
                    }
                }
            })
        }
    })
})

一見シンプルなシナリオが、分岐とサブスクリプションを含むコードに変わってしまいました。メモリリークの可能性を感じます😅 さらに重要なのは、このコードはあまり読みやすいものではありません。workbox-window は、このようなシナリオをどのように処理するのでしょうか?

const wb = new Workbox('/sw.js');
// waiting 状態にサブスクライブ
wb.addEventListener("waiting", event => {
    nofityUser();
});
wb.register();

はい、waiting イベントに 1 つのサブスクリプションで済みます。workbox-window がそれ以外の処理を行います!この例は、モジュールの利点を疑う余地がないでしょう。workbox-window を使用すると、次のイベントに登録できます。

  • installed - service worker のインストール。最初のインストールとそれ以降のインストールを区別するには、イベント event の一部である isUpdate フラグを使用できます。
  • waiting - アクティブ化を待っている更新された service worker。ページを開いたときに既に waiting 状態であった場合でも発生します。この場合、wasWaitingBeforeRegister フラグは true になります。このイベントは、更新のインストール時にのみ発生します。
  • controlling - 新しいバージョンの service worker がページの制御を取得しました。これは、ページを開いたときに使用されていた Service Worker のバージョンと、ページを制御するようになったバージョンが異なる場合を意味します。新しいバージョンがページを制御し始めるまで、このイベントは発生しません。このイベントは、Service Worker のコードで skipWaiting() メソッドが呼び出された場合にのみ発生します。
  • activated - activate イベントが完全に処理されました。

疑問や混乱がある場合は、次の図を参照してください。

service worker の最初のインストール手順


service worker の更新手順

更新が計画通りに進まない場合もある

前の部分で触れなかったシナリオとして、新しいタブで新しいバージョンの Service Worker が開かれるケースがあります。このシナリオを順を追って見ていきましょう。

  • ユーザーはタブ A で、v1 バージョンの Service Worker がアクティブになっています。
  • ユーザーは新しいタブ B でページを開き、サーバーから v2 バージョンの Service Worker が送信されます。
  • 現在アクティブなバージョンは v1 なので、v2 は待機状態になり、新しいバージョンの通知が表示されます。
  • ユーザーはページの再読み込みを承認します。
  • 新しいバージョンの Service Worker が有効になります。
  • タブ A の古いページコードは、v2 バージョンの Service Worker と共に動作します。

このようなシナリオには注意が必要です。Workbox を使用すると、このようなケースを検知できます。

const wb = new Workbox('/sw.js');
wb.addEventListener("controlling", event => {
    if (event.isExternal) {
        // 別のタスクから実行された更新はここで対応する
    }
});
wb.register();

しかし、実際には、タブ B で、他のタブも通知をクリックすると再読み込みされることをユーザーに伝えるだけで十分です。

クライアントとのやり取り

workbox-window モジュールの中でも重要な部分に、クライアントとサービスワーカー間の拡張された通信機能があります。Workbox を使わない場合の通信を思い出してみましょう。クライアントと Service Worker は message イベントに登録し、メッセージを送信するには postMessage メソッドを使用します。

これはネイティブの Service Worker が提供する唯一のメカニズムです。このアプローチの欠点の 1 つは、コードの可読性です。

ボタンをクリックすると updateQuota メソッドが呼び出され、ウェブアプリに割り当てられた空き容量を更新するケースを考えてみましょう。

// sw.js
self.addEventListener("message", async (event) => {
    if (event.data.type === "GET_QUOTA") {
        // ストレージ関連の情報取得
        const storageEstimate = await navigator.storage.estimate();
        // クライアントに送信
        postMessage({type: "RETURN_QUOTA", quota: storageEstimate.quota});
    }
});
// app.js
function updateQuota() {
    sw.postMessage({type: "GET_QUOTA"});
}

sw.addEventListener("message", event => {
    if (event.data.type === "RETURN_QUOTA") {
        setQuota(event.data.quota);
    }
});

app.js のコードを見ると、updateQuota メソッド内で更新が行われていません。メッセージを送信するだけで、その結果が message イベントのハンドラーでしか利用できません。この単純な例ではコードが複雑になることはありませんが、スケーラビリティは疑問です。リクエストとレスポンスへの分割は、処理手順を長くし、一見分かりにくくしています。これはクライアント側でのみ問題であり、Service Worker ではすべての処理が 1 つのハンドラーで行われるためです。

workbox-window は、クライアント側の messageSW メソッドを非同期化することでこの問題を解決しました。

// sw.js
self.addEventListener("message", async (event) => {
    if (event.data.type === "GET_QUOTA") {
        const storageEstimate = await navigator.storage.estimate();
        // event.ports[0] はクライアントへのチャネル
        event.ports[0].postMessage(storageEstimate.quota);
    }
});
//app.js

// const wb = new Workbox('/sw.js');
async function updateQuota() {
    const quota = await wb.messageSW({type: "GET_QUOTA"});
    setQuota(quota);
}

はるかに使いやすくなりましたね。
event.ports[0] はクライアントへのメッセージ送信用のチャネルを含み、クライアントはリクエストが発生した同じ場所で Service Worker から非同期レスポンスを受け取ります。コードは全体的に大幅に可読性が高まり、クライアント側のコード量も削減されました。あらゆる面でメリットがあります。

このメカニズムの仕組みに関心がある方は、重要な技術である MessageChannel を確認することをお勧めします。

messageSW メソッドの関数引数は postMessage メソッドとは異なります。postMessage を使用すると、任意の直列化可能なデータを送信できます(前回の投稿で詳しく説明しています)。一方、messageSW は送信されるデータを形式化します。関数の引数は、次のフィールドを持つオブジェクトである必要があります。

  • type (必須) - 文字列。対応するイベントのハンドラーを実行するために使用されます。
  • meta (任意) - 任意の直列化可能な値。メッセージに関するメタ情報を表します。
  • payload (任意) - 任意の直列化可能な値。メッセージの本文を表します。

これで、モジュールの概要は終わりです。Workbox の他のモジュールについても知りたい場合は、ドキュメントの該当セクション を確認することをお勧めします。

まとめ

お疲れさまでした!

大変な内容だったと思いますが、最後まで読んでいただきありがとうございます。これで、PWA 開発で Workbox を活用してくれると確信できます。

すぐにすべてを覚えようとせず、落ち着いてください。この記事を、Service Worker のコードを書く際の Workbox のリファレンスとして活用してください。

そして、いよいよ最終部です。実践的な PWA のメカニズムを解説します。

これまでの理論的な知識を活かし、以下のことを学びます。

  • ウェブアプリをスタンドアロンアプリとしてインストールする方法
  • ページの読み込みに失敗した場合にオフラインフォールバック HTML ページを提供する方法
  • 潜在的に古いデータ(ネットワークがない場合のキャッシュからのデータ)が表示されることをユーザーに通知する方法
  • その他多数!

次回の最終部でお会いしましょう!

Happy coding!

Sun* Developers

Discussion