Skip to content

ウェブサイトのアプリ化 2 (Service Worker)

ウェブサイトを PWA に変換することでネイティブアプリのようにホーム画面にインストールすることができるようになりますが、それだけでは中身はただのウェブサイトへのブックマークに過ぎません。 例えばオフラインの環境では「ページを開けません」といった表示になり、全くアプリっぽさがありません。

Service Worker という技術を用いることにより、ウェブサイトをオフラインで動作させたりプッシュ通知を処理することができ、ネイティブアプリと同等の体験を提供することができます。 サービスワーカー自体は PWA とは独立した概念であり、PWA に Service Worker が必須というわけではありませんが、両者はいっしょに用いられることが多いです。

サービスワーカーの基本

サービスワーカーはウェブページとは独立して動作する JavaScript のコードです。

以下に例を示します。

index.html
<html>
<head>
<title>SW sample</title>
</head>
<body>
hello, world!
<script>
// serviceWorker が実装されていない古いブラウザではなにもしない
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
</script>
</body>
</html>
sw.js
self.addEventListener("install", (event) => {
console.log("sw.js: install");
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
console.log("sw.js: activate");
event.waitUntil(self.clients.claim());
});
self.addEventListener("fetch", (event) => {
console.log("sw.js: fetch");
});

初回アクセス時

  • このページを開くと、 navigator.serviceWorker.register() を呼び出すことにより、サービスワーカーとして実行するファイルを登録します。
    • 返り値は ServiceWorkerRegistration のプロミスで、サービスワーカーがインストール中かどうか、アクティブかどうかなどの情報が得られます。
  • ブラウザは sw.js をフェッチし、サービスワーカーとして実行します。
    • sw.js では self に対して install イベント、 activate イベント、 fetch イベント のイベントハンドラーを設定します。
  • 初回の実行なので、まずサービスワーカーが「インストール」され、 install イベントが実行されます。
  • install イベントが完了したらサービスワーカーは「有効化」され、 activate イベントが実行されます。
  • activate イベントが完了したら、以降新しく開かれたページをこのサービスワーカーが「制御」できるようになります。具体的に何ができるかは後述します。
    • すでに開いているページ (初回アクセス時のページ) でサービスワーカーを使用するにはページをリロードする必要があります。
    • または、activate イベントハンドラの中で self.clients.claim() を呼び出すことにより、すでに開いているページに対しても新しいサービスワーカーを適用することができます。
      • claim() はプロミスを返し、これを event.waitUntil() で待機する必要があります。
    • これ以降以降ページやブラウザを閉じたり新しく開いたりしても、有効化されたサービスワーカーは有効のままです。

2回目以降のアクセス時

  • ページを開いた際、ブラウザは再度 sw.js をフェッチします。
    • この例では navigator.serviceWorker.register() が再度実行されますが、実行してもしなくても同じです。
  • もしフェッチした sw.js の中身が変わっていなければ、何もしません (すでに有効化されているサービスワーカーが有効なままです)
  • もしフェッチした sw.js の中身が1文字でも変わっている場合、新しいサービスワーカーが「インストール」され、 install イベントが実行されます。 この間以前のサービスワーカーは有効なままです。
  • 初回と異なり、以前のサービスワーカーによって制御されているページ (タブ) がすべて閉じられるまでの間、新しいサービスワーカーの有効化は始まりません。
    • install イベントハンドラーの中で self.skipWaiting() を呼び出すことにより、これをスキップすることが可能です。
      • skipWaiting() はプロミスを返しますが、 await や waitUntil() する必要はないです。
  • 新しいサービスワーカーが「有効化」され、 activate イベントが実行されます。
  • activate イベントが完了したあとこのサービスワーカーがページを「制御」するのは register() が成功したあとに開かれたページに対してのみです。
    • すでに開いているページ (skipWaiting をした場合) は引き続き以前のサービスワーカーによって制御されます。
    • ウェブページと Service Worker を両方変更した場合に、整合性をとるためですかね。

サービスワーカーのデバッグ

Chrome の開発者ツールの Application タブの中の Service Workers を開くと、登録されているサービスワーカーの状態を確認できます。 Unregister ボタンを押すことでサービスワーカーの登録を解除できます。

また、chrome://inspect の Service Worker タブで Inspect ボタンを押すことでサービスワーカーのコンソールにアクセスできる開発者ツールが開きます。

サービスワーカーでページを制御する

サービスワーカーがページを「制御」するとは、クライアントからサーバーへのリクエスト (fetch() だけでなく <img> や <script> のソース、HTML 自体のリロードなども含めたすべて) に割り込んでプロキシーとして動作することです。

ミドルウェア プロキシとしての Service Worker (画像は Service Worker | web.dev より)

サービスワーカー内でリクエストを受け取りレスポンスを返すのは、 fetch イベントのイベントハンドラーです。 引数には FetchEvent が渡されます。 event.requestRequest が得られ、event.respondWith(response)Response を返します。

sw.js
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname === "/hello") {
event.respondWith(Response.json({ message: "hello, world" }));
}
});

サービスワーカーのインストールと有効化が完了したあとに /hello にアクセスしてみると、(サーバー側にはそんなリソースはないにも関わらず) サービスワーカーが返したメッセージが得られることがわかります。

また開発者ツールの Network タブではこのようにレスポンスがサービスワーカーから返されたかどうかがわかります。 サービスワーカーのレスポンス

event.respondWith() は同期的に (イベントハンドラが return する前に) 呼び出す必要があります。 以下のように非同期にレスポンスを返すことはできません。

self.addEventListener("fetch", (event) => {
doSomethingAsync().then(() => {
event.respondWith(someResponse);
});
});

respondWith() に Response で解決するプロミスを渡すことはできます。

async function respondAsync() {
await doSomethingAsync();
return someResponse;
}
self.addEventListener("fetch", (event) => {
event.respondWith(respondAsync());
});

respondWith() をせずに return した場合、クライアントは改めてサーバーにリクエストを送ります。 つまり、サービスワーカーが存在しなかった場合と同じ結果になります。

サービスワーカーのスコープ

サービスワーカーは自身の js ファイルが置かれているのと同じ階層かそれ以下のページに対してしか「制御」しません。 そのため、サービスワーカーの js ファイルはページと同じオリジンの直下のパスに置きましょう。 (https://example.com/sw.js は OK、 https://example.com/public/sw.js だと public/ 以下のページしか機能しなくなります)

スコープを気にする必要があるのはページ自体のURLのみであり、例えばスコープ内のページがリクエストしたスコープ外のリソース (js ファイルより上の階層や、異なるドメイン) については問題なく制御することができます。

キャッシュストレージ

サービスワーカーがページや静的なアセットファイルをストレージに保存しておき、サーバーの代わりにレスポンスを返すことで、端末がオフラインやネットワークが遅い環境でも高速にページを読み込むことができるようになります。

こういった用途のためサービスワーカー内で使えるストレージが CacheStorage です。 キャッシュストレージは Request をキーとし、対応する Response を保存しておくことができます。 キャッシュストレージには任意の名前をつけて複数作成することができます。

キャッシュストレージに保存する

Cache.put() で Request と Response のペアを保存します。 以下の例では / に fetch リクエストをし、レスポンスを v1 という名前のキャッシュストレージに保存します。

const cache = await self.caches.open("v1");
const res = await fetch("/");
await cache.put("/", res);

リソースを fetch してそのままキャッシュストレージに保存するという処理は、Cache.add() を使ってよりかんたんに書けます。

const cache = await self.caches.open("v1");
await cache.add("/");

Cache.addAll() を使うと指定した複数の URL をすべて fetch して保存することができます。 オフラインでも動作させたいアプリの場合は、サービスワーカーの install イベントの中で addAll を実行する以下のようなパターンが一般的です。

self.addEventListener("install", (event) => {
event.waitUntil(
self.caches.open("v1").then((cache) => {
cache.addAll([
// ページの表示に必要なファイルをすべて列挙する
"/",
"/styles.css",
"/hoge.js",
"/fuga.js",
"/assets/piyo.png",
]);
})
);
});

保存されているレスポンスを返す

Cache.match() でキャッシュストレージに保存されているレスポンス (のプロミス) を得られます。 一致するものが見つからなかった場合 undefined が返ります。

例えばキャッシュストレージにレスポンスが保存されていればそれを返し、なければ 404 を返すサービスワーカーは以下のように書けます。

self.addEventListener("fetch", (event) => {
event.respondWith(
(async () => {
const cache = await self.caches.open("v1");
const res = await cache.match(event.request);
if (res) {
return res;
} else {
return new Response("not found", { status: 404 });
}
})(),
);
});

キャッシュストレージはキャッシュという名前ですが時間経過で自動的に削除されるというような機能はありません。ただのストレージです。 そのため上の例のように install イベントでページと静的アセットをすべて保存して、 fetch イベントで常にそれをレスポンスとして返すようにした場合、ウェブサイトを更新してもクライアント側では永遠に更新されないことになります。 したがって定期的にキャッシュを更新する仕組みを実装する必要があるでしょう。 あるいは、サービスワーカーの js ファイルが更新されれば install イベントが再度実行されキャッシュが新しくなるため、ウェブサイトを更新する際に毎回サービスワーカーの js ファイルも更新するようにするという対策もあります。