Service Workerの基本とキャッシュ制御

2021/10/12更新

目次

Service Workerとは

Service Workerでできること

Service Worker(サービスワーカー)とは、ウェブページのバックグラウンドで動作するJavaScriptの一種である。ブラウザからネットワークへのリクエストが行なわれるときに、プロキシのように割り込んで処理をすることができ、特にネットワークにアクセスできないオフラインの場合などに適切な処理を行なう目的で使用することが多い。

Service Workerを使うことで、画像などのページに関連するファイルを適切にキャッシュさせることができ、オフラインでアクセスした場合でも正常に表示させることが可能となる。Service Workerが使われる目的はたいていの場合はこの「キャッシュ制御」「オフライン対策」だと思われる。他に稀に見かける例としては、リクエストを改変してからネットワークに送信したり、逆にレスポンスを変換してからブラウザに渡したりといったこともできる。また、データファイルなどのテスト用のレスポンスを返したりする目的で使うこともできる。

Service Workerの制限

Service Workerには主に以下のような制限がある。

  • 安全上の目的のため、HTTPS接続のページかlocalhostでしか使用できない。一部のブラウザではプライベートウインドウでも使用できない。

  • 通常のページ上で動作するJavaScriptとは別スレッドで動作し、グローバル変数の共有などはできない。ページのDOMへもアクセスできない。

  • 同期的な処理が不可能なため、XMLHttpRequestlocalStorageは使用できない。

  • スコープ外のページからのリクエストはハンドリングできない。

  • キャッシュできる量はオリジンごとに上限があり、ブラウザによって削除される可能性もある。

  • Service Workerの前段には通常のブラウザキャッシュ(HTTPキャッシュなど)が健在のため、ネットワーク側を完全に制御することはできない(Service Workerからネットワークへのリクエストが常に実際にネットワークまで行くとは限らない)。

Service Workerのライフサイクル

動作するまでの流れ

Service Workerは実際に動作し始めるまでの間に、「登録」「インストール」「有効化」のステップをたどる。

  1. ページ上の通常のJavaScriptコードによって、Service WorkerのJavaScriptファイルの場所がブラウザに知らされ、登録(register)が行なわれる。この時点で、Service Workerが制御する対象となる範囲(スコープ)が決定する。

  2. Service Workerの制御下のページに次にアクセスがあったタイミングなどで、インストール(install)が試みられる。このときoninstallイベントが発火し、通常はキャッシュするファイルの準備などを行なう。oninstallイベントが正常に完了すると、Service Workerのインストールは完了する。もしインストールに失敗した場合は、このService Workerは破棄されて、これ以降のステップには進まない。

  3. インストール完了後、古いService Workerが停止すると、やっと新しいService Workerに出番が回ってくる。これが有効化(activate)である。このときonactivateイベントが発火し、通常はここで古いキャッシュの削除などを行なう。

  4. 有効化された後は、次に新しいService Workerが見つかって有効化されるまで、このService Workerがスコープ内のページを制御できる。

Service Workerの登録

Service Workerを登録するには、ページ上に以下のような通常のJavaScriptのコードを記述する。インストールするかどうかはブラウザが判断する(ファイルに変更がある場合だけインストールされる)ため、気にせずただregister()を毎回呼ぶだけでよい。register()Promiseを返すため、処理の成功時や失敗時に何かをしたい場合は、then()awaitなどを使う必要がある。

<script>
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./sw.js');
}
</script>

sw.jsがService Workerの本体となるJavaScriptファイルである。ファイル名や設置場所は任意であるが、置かれた場所と同じディレクトリ以下の範囲がこのService Workerのスコープとなる。register()の第2引数に{ scope: './hoge/' }のようにして相対パスを渡すことで、スコープをさらに絞ることもできる。

1つのオリジンに対して、複数のService Workerを登録・インストールできるが、有効となるのは1つのスコープにつき1つのService Workerのみである(全く同じスコープに対して新たに別のService Workerが登録されると、前のものは無効になる)。また、2つ以上のService Workerのスコープに同時に入っているページは、スコープのより狭い方だけに制御される(2つ以上のService Workerに同時に制御されることはない)。このように、オリジン内の他のページが登録したService Workerに制御されることもありえるため、スコープの設定には注意が必要である。

登録に失敗する(register()Promiseが拒否される)ケースとしては、ファイルが見つからない、ファイルのMIMEタイプが不正、スコープの範囲が不正、などがある。その場合、当然次のインストールに進むことはない。

Service Workerのインストール

登録成功後、自動でインストールが試みられる。Service Worker内でoninstallイベントが発火するので、ここにインストール時の処理を記述する。

// sw.js内
this.addEventListener('install', function(event) {
    // インストール時の処理
    console.log('インストールされました。');
});

インストールは、Service WorkerのJavaScriptファイル(ここではsw.js)に変更があった場合のみ試みられる。変更が無い場合は、前述の「登録」には成功するものの、インストールは行なわれない。

Service Workerの有効化

インストールが完了して、古いService Workerが停止すると、有効化が行なわれる。このとき、Service Worker内でonactivateイベントが発火するので、ここに有効化時の処理を記述する。

// sw.js内
this.addEventListener('activate', function(event) {
    // 有効化時の処理
    console.log('有効化されました。');
});

有効化は必ずインストールが成功した後に行なわれる。インストールに失敗したService Workerは有効化されずに破棄される。

キャッシュ制御

最も簡単な例として、スコープ内の特定の静的ファイルをキャッシュして、オフラインでも使用できるようにすることを考える。

キャッシュ対象ファイルの準備

Service Worker内で明示しなければいけないことは、「キャッシュするタイミング」と「キャッシュを使用するタイミング」である。

まず「キャッシュするタイミング」として、少量の静的ファイルであれば、インストール時に全て行なってしまうのが簡単である。この処理は、oninstallのイベントハンドラ内に記述する。

// キャッシュの名前
const CACHE_NAME = 'my_hogehoge_cache';
// キャッシュのバージョン
const CACHE_VERSION = 1;
// キャッシュ対象のファイル(スコープからの相対パス)
const CACHE_FILES = [
    './',
    './style.css',
    './script.js',
    './image.jpg'
].map(path => new URL(path, registration.scope).pathname); // ルート相対パスに直しておく

// キャッシュ名とキャッシュバージョンからキーを作る
const CACHE_KEY = `${CACHE_NAME}:${CACHE_VERSION}`;

// インストール時の処理
this.addEventListener('install', function(event) {
    // waitUntil()でイベントの完了を処理が成功するまで遅延させる
    event.waitUntil(
        // cacheStorageの中に指定したキーのcacheを新しく作成して開く
        caches.open(CACHE_KEY).then(function(cache) {
        // パスの一覧を渡してcacheに追加する
            return cache.addAll(CACHE_FILES);
        })
    );
});

まず、CACHE_NAMECACHE_VERSIONはキャッシュの名前とバージョンである。古くなったキャッシュを削除する場合などに、キャッシュを判別するための手がかりに使う。

イベントハンドラ内のwaitUntil()は定型構文だと思っても差し支えない。渡されたPromiseが解決されるまでインストール中であることを明示するために必要なものである(参考:ExtendableEvent.waitUntil() - Web API | MDN)。

caches.open()は指定したキーのキャッシュを開くか、無ければ新規作成する。

cache.addAll()はURLの配列を受け取ると全てネットワークから取得してきてキャッシュに追加してくれる。一つでも取得に失敗すると、cache.addAll()が返すPromiseは拒否され、結果としてwaitUntil()に渡されたPromiseが拒否されることになり、その場合はこのインストールは失敗したものと見なされて、この後の有効化のステップには進まない。これにより、この後のステップではCACHE_FILESに指定したファイルは基本的に全てキャッシュ済みであると見なすことができる。

リクエストに対する応答「キャッシュに無ければネットワーク」

「キャッシュするタイミング」を記述したら、次は「キャッシュを使用するタイミング」を明示する必要がある。

最もシンプルなパターンとして、「キャッシュ対象ファイルであれば常にキャッシュから取得し、それ以外は常にネットワークから取得する」ことを考える。同じバージョン内であれば一切変更される可能性がない静的なファイルなどはこのパターンが適する。

ページを構成する全てのファイルがキャッシュ対象であるならば、ネットワークに取得しに行くことは基本的に無くなるが、何らかの理由によりCache Storageが削除された場合などのために、キャッシュに無ければネットワークに取得しに行く処理は必要である。

// キャッシュ対象ファイルかどうかを判定する
const isTargetFile = function(url) {
    return CACHE_FILES.indexOf(new URL(url).pathname) >= 0;
};

// スコープ内のページからのリクエストによりfetchイベントが発火する
this.addEventListener('fetch', function(event) {
    // レスポンスを宣言する
    event.respondWith(
        // cacheStorageの中から管理しているcacheを開く
        caches.open(CACHE_KEY).then(function(cache) {
            // cache内にこのリクエストに対するキャッシュが存在するか確認する
            return cache.match(event.request).then(function(response) {
                // もしキャッシュがあればそれを返す
                if (response) return response;
                // もし無ければネットワークに取得しに行く
                return fetch(event.request).then(function(response) {
                    // キャッシュ対象のファイルでキャッシュすべきレスポンスであればキャッシュする
                    if (isTargetFile(event.request.url) && response.ok) {
                        cache.put(event.request, response.clone());
                    }
                    // レスポンスを返す
                    return response;
                });
            });
        })
    );
});

respondWith()は、これも定型構文だと思って差し支えない。ブラウザからの本来のリクエストに割り込んで、ここに渡したPromiseをレスポンスの代わりにするためのものである(参考:FetchEvent.respondWith() - Web API | MDN)。

event.requestはこれから行なわれようとしているリクエストを表すオブジェクトである。これをcache.match()に渡すと、キャッシュの中に対応するレスポンスがあるかどうか探して、あればそのレスポンスで、無ければundefinedで解決するPromiseを返す。これで、キャッシュの中にあるかどうかの判定が簡単にできる。

何らかの理由により、キャッシュの中に見つからなかった場合は、fetch()でネットワークに取得しに行く。レスポンスが得られたら、キャッシュ対象ファイルである場合に限ってキャッシュに保存する。万が一何らかの理由によりキャッシュが削除された場合に、次にService Workerがインストールされるまでずっとキャッシュが無い状態となってしまうのを避けるため、この処理は必要である。また、あらゆるファイルが際限なくキャッシュされていってしまうのを避けるために、キャッシュ対象かどうかのチェックも重要である。

レスポンスをキャッシュに保存する際は、response.clone()を使ってブラウザ用とは別に複製する必要がある点が注意である。

リクエストに対する応答「ネットワークがダメならキャッシュ」

もう一つ基本的なパターンとして、「通常はネットワークに取得しに行くが、ダメなときはキャッシュから取得する」というケースがある。これは、ある程度更新される可能性があって通常はネットワークから取得する必要があるが、オフラインのときは直前にキャッシュしたもので代替して構わないというパターンに適する。

// キャッシュ対象ファイルかどうかを判定する
const isTargetFile = function(url) {
    return CACHE_FILES.indexOf(new URL(url).pathname) >= 0;
};

// スコープ内のページからのリクエストによりfetchイベントが発火する
this.addEventListener('fetch', function(event) {
    // レスポンスを宣言する
    event.respondWith(
        // ネットワークに取得しに行く
        fetch(event.request).then(function(response) {
            // 正常なレスポンスでなければネットワークエラーと同じ扱いとする
            if (!response.ok) throw response;
            // キャッシュ対象ファイルの場合
            if (isTargetFile(event.request.url)) {
                // キャッシュを更新する
                const cacheResponse = response.clone();
                caches.open(CACHE_KEY).then(function(cache) {
                    cache.put(event.request, cacheResponse);
                });
            }
            // ネットワークからのレスポンスを返す
            return response;
        // ネットワークエラーの場合
        }).catch(function() {
            // キャッシュから返す
            return caches.open(CACHE_KEY).then(function(cache) {
                return cache.match(event.request);
            });
        });
    );
});

今度はcache.match()よりも先にまずfetch()を行なっている。fetch()が返すPromiseは、ネットワークエラーのときのみ拒否され、何らかのレスポンスが得られた場合は解決となるので、404エラーなどキャッシュすべきでないレスポンスの場合を除外している。

リクエストに対する応答「オフライン不可である旨を表示」

オフライン時に、オフラインでは利用できないページにアクセスが来た場合に、ブラウザのエラー画面ではなくあらかじめ用意したページを代わりに表示させることもできる。

キャッシュ対象ページとして「./error.html」などのエラーページをインストール時にキャッシュしておき、fetch()cache.match()catch()ハンドラ内で、キャッシュしておいたエラーページをcache.match()で返せばよい。

古いキャッシュの削除

前のバージョンのService Workerが制御していたキャッシュは、有効化のときに全て削除しておくのがよい。こうしておけば、CACHE_VERSIONを変更したService Workerをリリースするたびにキャッシュも更新されていくことが保証できる。

// 有効化した時点で処理を行なう
this.addEventListener('activate', function(event) {
    // waitUntil()でイベントの完了を処理が成功するまで遅延させる
    event.waitUntil(
        // cacheStorageの中の全てのcacheを確認する
        caches.keys().then(function(cacheKeys) {
            return Promise.all(
                cacheKeys.filter(function(cacheKey) {
                    // キー名を確認してキャッシュ名とバージョンを確認する
                    const [cacheName, cacheVersion] = cacheKey.split(':');
                    // 同じキャッシュ名でバージョンが異なるものを削除対象とする
                    return cacheName == CACHE_NAME && cacheVersion != CACHE_VERSION;
                }).map(function(cacheKey) {
                    // 削除対象としたキーのcacheを全てcacheStorageから削除する
                    return caches.delete(cacheKey);
                })
            );
        })
    );
});

caches.keys()を呼び出すとCache Storageの全てのキー名の配列(上記コード内のcacheKeys)が得られる。これをまず配列のfilter()で不要なものだけ選別し、次いで同じく配列のmap()caches.delete()が返すPromiseに変換して、Promise.all()に渡している。

ユーザーによるキャッシュ削除

Service Workerとその制御下のページ上のスクリプトは、メッセージをやり取りすることで通信ができる。これを使えば、ユーザーの希望に応じてService Worker上でキャッシュを更新したり削除したりすることができる。

制御下のページからメッセージを送るには以下のようにする。

<p><button id="clear_cache">キャッシュを削除する</button></p>
<script>
document.getElementById('clear_cache').addEventListener('click', function() {
    if ('serviceWorker' in navigator) {
        if (navigator.serviceWorker.controller) {
            navigator.serviceWorker.controller.postMessage({
                message: 'clear'
            });
        } else {
            alert('有効化されたService Workerがありません。');
        }
    }
});
</script>

Service Worker内では以下のようにしてこのメッセージを受信できる。

// onmessageイベントをハンドリングする
this.addEventListener('message', function(event) {
    // 送られてきたメッセージ
    const receivedData = event.data;
    if (receivedData.message == 'clear') {
        // キャッシュ削除の処理など
    }
});

個人的に感じた疑問

Service Workerのまとまった情報は少なく、実際の挙動を確認するのも比較的手間がかかるため、実際に自分の手で触ってみるまでは基本的な部分でさえさまざまな疑問が湧いた。ウェブ上になかなか明確な回答が見つからずに理解に時間がかかったことをまとめておく。※間違っていたり不正確な点があればご指摘いただきたい。

Q. Service Workerとブラウザキャッシュはどのような関係にあるのか?

Service Workerは直接ネットワークにリクエストを投げられるわけではなく、その前に通常のブラウザキャッシュが健在している。つまり、Service Workerは「ユーザーとブラウザキャッシュの間」に割り込んでいるものと考えられる。

Service Workerがネットワークから受け取ったものが本当に今ネットワークから来たものかどうかは確実に判別する方法がない(?)。Service Workerから見るとネットワークに取得しに行ったように見えても、実際は前段のブラウザキャッシュが古いデータを返していることもある。確実にネットワークに取得しに行くためには、ファイルパスを新しくするなどの古典的な手法が最も確実ではないかと思われる。

Q. 何らかの誤りにより更新ができなくなってしまうようなことは起きないのか?

Service Workerを導入する場合に最初に心配になったのがこれである。キャッシュを自分で制御するのだから、自分のミスによってキャッシュが消せなくなってしまったりすることはないのか?という点である。

この不安は、「Service Workerは変更を加えれば必ずインストールされる」という仕様を知っていれば解消できる。ブラウザは頻繁にService Workerの更新を確認しており、最悪でも24時間に一度は確認されることになっている。Service Workerやキャッシュに問題が起きた場合は、新しいService Workerをリリースすればよい。これで、前のService Workerを停止したり、問題のあるキャッシュを削除したりすることが可能である。

Q. どんなリクエストでもキャッシュできるのか?

Cache Storageは、GETリクエストしかキャッシュできない。POSTリクエストなどをcache.put()しようとすると例外が発生する。ただし、リクエストとレスポンスを取り扱うことはできるので、別の形に加工してIndexedDBなどを使用すれば保存する方法はある。

Q. 同じオリジン内で複数のService Workerが競合することはあるのか?

オリジン内では複数のService Workerが稼働している可能性があるが、あるスコープに対しては常に1つのService Workerしか有効とならない。また複数のService Workerのスコープに同時に入っているページは、スコープのより狭い方のService Workerだけに制御される。2つ以上のService Workerに同時に制御されることはない。

Q. 強制リロード(ハードリロード)が行なわれるとどうなるのか?

ユーザーによって強制リロードが行なわれると、Service Workerの制御を無視してネットワークにリクエストが行く。次に通常通りアクセスすると再びService Workerの制御下に戻る。例えば仮に、Service Workerが「常にキャッシュから返す」ような方針になっており、しかもサーバー上のファイルが更新されている場合、強制リロードしたときだけサーバー上の新しいファイルが表示され(=Service Workerの制御を無視)、それ以降は再び古いキャッシュ上のファイルが表示される(=Service Workerの制御下)、といった挙動になってしまう。

外部リンク