Service Worker

ブラウザとネットワークの間でプロキシとして動作し、オフライン対応やキャッシュ制御を実現する Web API

APIフロントエンド

Service Worker とは

Service Worker は、ブラウザとネットワークの間でプロキシとして動作する JavaScript で、ネットワークリクエストのインターセプト、キャッシュ制御、オフライン対応、プッシュ通知を実現する。PWA (Progressive Web App) の基盤技術だ。

ライフサイクル

登録からリクエストを横取りできるようになるまでに、インストールと待機とアクティベートの 3 段を通る。待機が入るのが Service Worker の癖で、更新版はスクリプトが 1 バイトでも違えば別物として扱われ、既存の版と並んで起動して自分の install イベントを受けるが、既存の版が制御しているページが 0 になるまでアクティベートされない。

1. 登録 (register)
   navigator.serviceWorker.register('/sw.js')

2. インストール (install)
   → 静的アセットを先回りしてキャッシュへ
   → waitUntil に渡した Promise が失敗すればこの版は破棄され、
     既存の版が動き続ける

3. 待機 (waiting)  ※更新時のみ
   → 旧版が制御するページが 0 になるまで足止め
   → self.skipWaiting() で飛ばせる

4. アクティベート (activate)
   → 不要になった世代のキャッシュを削除

5. フェッチ (fetch)
   → スコープ内のリクエストを横取り

リロードでは旧版のページと新版のページが一瞬重なるため、タブを 1 回再読み込みしても待機は解けない。すべてのタブを閉じるか、self.skipWaiting() を書いて明示的に飛ばす。

登録とスコープ

登録は機能の有無を確認してから行う。古いブラウザや安全なコンテキストでない場合に navigator.serviceWorker 自体が存在しないため、参照する前に判定する。

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

どのページを制御するかは、スクリプトの置き場所で決まる。既定のスコープはスクリプトが置かれたディレクトリで、/sw.js ならオリジン全体、/assets/sw.js なら /assets/ 以下だけになる。スコープはサイトのベース URL からの相対で解決されるため、登録を呼んだページの場所には左右されない。ディレクトリより上を制御したいときは、スクリプトの応答に Service-Worker-Allowed ヘッダーを付けて許可する必要がある。ビルド出力を /assets/ などへ固めている構成でスクリプトも一緒に置いてしまい、トップページが制御されないまま「オフラインで動かない」となるのは、この規則の見落としが原因になりやすい。

キャッシュ戦略

選び分けの軸は、古い内容を返してしまった場合の害の大きさである。内容が変わればファイル名も変わるビルド済みアセットは古い応答を返す危険がないので Cache First で良く、価格や在庫のように古い値が誤情報になるものはネットワークを先に試す。HTML を Cache First にすると、新しい版をデプロイしても手元のキャッシュが返り続けて更新が止まるため避ける。

戦略説明用途
Cache Firstキャッシュを優先、なければネットワーク静的アセット (CSS, JS, 画像)
Network Firstネットワークを優先、失敗したらキャッシュAPI レスポンス
Stale While Revalidateキャッシュを返しつつ、バックグラウンドで更新頻繁に更新されるデータ

Cache First の実装

install での書き込みは event.waitUntil() に渡す。渡さないとインストール完了と見なされて処理中に停止し得る。addAll は 1 つでも取得に失敗すると全体が失敗し、この版は破棄される。キャッシュ名に世代を持たせ、activate で不要な世代を消すところまでを一組にする。

// sw.js
const CACHE_NAME = 'v1';
const PRECACHE_URLS = ['/index.html', '/styles.css', '/app.js'];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
    )
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => cached || fetch(event.request))
  );
});

Workbox による記述

戦略ごとの分岐や世代の後始末を自分で書くと、条件の抜けがそのままオフライン時の白画面になる。Workbox はこの定型を関数として提供し、ビルド時に差し込まれるプリキャッシュ一覧 (self.__WB_MANIFEST) とリクエストの条件を組み合わせて宣言的に書ける。

import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst } from 'workbox-strategies';

precacheAndRoute(self.__WB_MANIFEST);

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({ cacheName: 'images' })
);

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

オフライン時の代替ページ

代替ページを返す対象は、ページ遷移のリクエストだけに絞る。すべての失敗に HTML を返すと、画像や JavaScript の取得失敗にも HTML が渡り、読み込みエラーが分かりにくい形で表に出る。

self.addEventListener('fetch', (event) => {
  if (event.request.mode !== 'navigate') return;
  event.respondWith(
    fetch(event.request).catch(() => caches.match('/offline.html'))
  );
});

注意点

  • 安全なコンテキストでのみ動作する。開発用に http://localhost は例外として扱われる
  • DOM にアクセスできない (Web Worker と同様)。使えるのは fetch、Cache API、IndexedDB、postMessage など
  • 登録した直後のページは、そのページ自身の取得が Service Worker を通っていないため制御下に入らない。効果を見るには再読み込みが必要で、clients.claim() を書けば制御下にない既存のページも引き取れる
  • 更新版は待機に入るため、デプロイしただけでは切り替わらない (self.skipWaiting() を書くか、全タブを閉じてもらう)

つまずきやすい点

self.skipWaiting() は更新の遅れを解決する代わりに、開いているページの下でキャッシュの世代を入れ替える。すでに読み込まれた HTML が、遅延読み込みで新しい世代のファイルを取りに行って 404 になる事故はこれで起きる。ファイル名に内容の識別子が入っていない構成や、遷移のたびに追加のスクリプトを取りに行く構成では、待機を飛ばすかどうかを設計として決める必要がある。

もう一つは、スクリプト自身のキャッシュである。/sw.js が長い有効期限で配信されていると、更新版の存在にブラウザが気付くまで時間がかかる。仕様上は取得時に一定期間を超えたキャッシュは使われないが、途中の配信層が返し続ける場合があるため、スクリプトの応答は短い有効期限で配る。

PWAキャッシュWeb Worker と合わせて押さえると、ブラウザ側で完結する処理の範囲が見えてくる。

この記事は役に立ちましたか?

関連用語

関連する記事