TypeScriptでの`Service Worker`
この記事ではTypeScriptでのService Worker
について説明します。
TypeScriptでのService Worker
について実際的なサンプルを含めて解説します。
YouTube Video
TypeScriptでのService Worker
Service Workerは、ブラウザとネットワークの間に位置する“リクエストプロキシ”です。Fetchの横取り、キャッシュ制御、オフライン動作、バックグラウンド処理(同期・プッシュ)を可能にします。TypeScriptで書くと型安全になり、保守性が上がります。
TypeScriptの準備
tsconfig.json
(WebWorker型を有効化)
tsconfig.json
で、WebWorker
型を有効化した場合の例を見てみましょう。
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "module": "ES2020",
5 "lib": ["ES2020", "WebWorker"],
6 "moduleResolution": "Bundler",
7 "strict": true,
8 "noEmitOnError": true,
9 "outDir": "out",
10 "skipLibCheck": true
11 },
12 "include": ["sw.ts"]
13}
lib
にWebWorker
を入れることで、ServiceWorkerGlobalScope
などの型が使えます。DOM
とWebWorker
は型が異なるため、ブラウザ側(アプリ本体)向けとService Worker
用で、tsconfig.json
の設定は分けるのが一般的です。Service Worker
ファイルは最終的にスコープに合わせたパス(多くはサイトルート/sw.js
)へ出力します。- セキュリティ上、Service Worker は HTTPS(もしくは
localhost
)でのみ動作します。
ブラウザ側の登録コード
register-sw.ts
1// register-sw.ts
2async function registerServiceWorker() {
3 if (!('serviceWorker' in navigator)) return;
4
5 try {
6 const registration = await navigator.serviceWorker.register(
7 '/sw.js', { scope: '/' }
8 );
- この処理では、
Service Worker
を登録しています。scope
はService Worker
が制御できるパスの範囲 です。例えば/sw.js
をルート直下に置き、scope
に ルートディレクトリ(/
) を指定すると、サイト全体のリソースを制御対象にできます。一方で、/app/
のように特定のディレクトリを指定すると、そのディレクトリ配下だけが対象となります。
1 // If there's a waiting worker, notify the user.
2 if (registration.waiting) {
3 promptUserToUpdate(registration);
4 }
waiting
は 新しい Service Worker がインストール済みで、アクティブ化を待っている状態 を示します。この段階では既存ページはまだ古いService Worker
によって制御されているため、ユーザに更新確認を行い、了承後にskipWaiting()
を呼び出して即座に新しいService Worker
を有効化するのが一般的な流れです。これにより、次回のページリロードを待たずに最新の処理を反映できます。
1 // When a new SW is installing, monitor its state changes
2 registration.addEventListener('updatefound', () => {
3 const newWorker = registration.installing;
4 if (!newWorker) return;
5 newWorker.addEventListener('statechange', () => {
6 if (newWorker.state === 'installed' &&
7 navigator.serviceWorker.controller) {
8 // New content available, prompt the user
9 promptUserToUpdate(registration);
10 }
11 });
12 });
updatefound
は 新しい Service Worker のインストールが開始されたタイミングで発火します。このイベントが発生したらregistration.installing
に新しいワーカーがセットされるため、そのstatechange
を監視することで「インストール完了 (installed
)」を検知できます。さらにnavigator.serviceWorker.controller
が存在していれば、既に古い Service Worker が制御中であることを意味するため、この時点でユーザに新しいバージョンの存在を通知するきっかけにできます。
1 // When the active worker changes (e.g., after skipWaiting), reload if desired
2 navigator.serviceWorker.addEventListener('controllerchange', () => {
3 // Optionally reload to let the new SW take over
4 window.location.reload();
5 });
6 } catch (err) {
7 console.error('Service Worker registration failed: ', err);
8 }
9}
controllerchange
イベントは、新しい Service Worker が現在のページを制御し始めたタイミングで発火します。この時点でリロードを行えば、新しいキャッシュ戦略や処理内容をすぐに反映できます。ただし、自動リロードはユーザ体験を損ねる場合があるため、ユーザ同意を得てからリロードする方が望ましいです。
1function promptUserToUpdate(reg: ServiceWorkerRegistration) {
2 // Show UI to user. If user accepts:
3 if (reg.waiting) {
4 reg.waiting.postMessage({ type: 'SKIP_WAITING' });
5 }
6}
7
8registerServiceWorker();
postMessage({ type: 'SKIP_WAITING' })
を送るチェーンをService Worker
側で受け取りself.skipWaiting()
を実行することで、更新を促せます。
sw.ts
でのスコープ宣言
続いて、典型的な「アプリシェル」キャッシュを行う Service Worker
の例を見てみましょう。
Service Worker
を利用する際、TypeScriptではself
に正しい型を与えると便利です。
1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
- TypeScriptでは
self
がデフォルトでany
扱いになるため、そのままだとskipWaiting()
やclients
などService Worker
専用APIで型補完・型チェックが効きません。 ServiceWorkerGlobalScope
を指定することで、補完が有効になり誤用を防げるうえ、通常のDOMスクリプトと区別して安全に開発できるようになります。
基本のService Worker(インストール/有効化/フェッチ)
シンプルなキャッシュバージョン管理、インストールでのプリキャッシュ、アクティベートでの古いキャッシュ削除、フェッチでのキャッシュ戦略(静的アセットは cache-first、API は network-first)を示します。
sw.ts
(最小構成+キャッシュ雛形)
1const CACHE_NAME = 'app-shell-v1';
2const STATIC_ASSETS = [
3 '/',
4 '/index.html',
5 '/styles.css',
6 '/main.js',
7 '/fallback.png'
8];
9
10self.addEventListener('install', (event: ExtendableEvent) => {
11 // Pre-cache application shell
12 event.waitUntil(
13 caches.open(CACHE_NAME)
14 .then(cache => cache.addAll(STATIC_ASSETS))
15 // Activate immediately (optional: coordinate with client)
16 .then(() => self.skipWaiting())
17 );
18});
install
イベントでは、アプリの**静的リソース(App Shell)**を事前にキャッシュします。また、self.skipWaiting()
を呼び出すことで、新しいService Worker
がすぐにアクティブ化され、次回のアクセスを待たずに最新キャッシュが利用可能になります。
1self.addEventListener('activate', (event: ExtendableEvent) => {
2 // Clean up old caches and take control of clients immediately
3 event.waitUntil(
4 caches.keys().then(keys =>
5 Promise.all(keys
6 .filter(key => key !== CACHE_NAME)
7 .map(key => caches.delete(key)))
8 ).then(() => self.clients.claim())
9 );
10});
activate
イベントでは、古いバージョンのキャッシュを削除し、Service Worker
を最新状態に保ちます。さらに、self.clients.claim()
を呼び出すことで、ページの再読み込みを待たずに、すべてのクライアントをこの新しいService Worker
が制御できるようになります。
1self.addEventListener('fetch', (event: FetchEvent) => {
2 const request = event.request;
3 const url = new URL(request.url);
4
5 // Navigation requests (SPA) -> network-first with fallback to cached index.html
6 if (request.mode === 'navigate') {
7 event.respondWith(
8 fetch(request).catch(() => caches.match('/index.html') as Promise<Response>)
9 );
10 return;
11 }
12
13 // Simple API routing: network-first for /api/
14 if (url.pathname.startsWith('/api/')) {
15 event.respondWith(networkFirst(request));
16 return;
17 }
18
19 // Static assets: cache-first
20 event.respondWith(cacheFirst(request));
21});
fetch
ではリクエストを横取りしてレスポンスを制御できます。キャッシュ優先やネットワーク優先など戦略を実装し、オフライン対応や高速化に活用します。
1self.addEventListener('message', (event: ExtendableMessageEvent) => {
2 const data = (event as any).data;
3 if (!data) return;
4
5 if (data.type === 'SKIP_WAITING') {
6 // Force the waiting service worker to become active
7 self.skipWaiting();
8 }
9});
SKIP_WAITING
を受け取った場合、self.skipWaiting()
を呼び出すことで、待機中の Service Worker を即座にアクティブ化できます。これにより、ページをリロードしなくても次のリクエストから新しいバージョンが適用されるようになります。
実用的なキャッシュ戦略の整理
cache-first
cache-first は、まずキャッシュを確認し、あれば即座に返します。なければネットワークから取得してキャッシュに保存します。静的ファイルに向いています。
1async function cacheFirst(request: Request): Promise<Response> {
2 const cache = await caches.open(CACHE_NAME);
3 const cached = await cache.match(request);
4 if (cached) {
5 return cached;
6 }
7 const response = await fetch(request);
8 if (response && response.ok) {
9 cache.put(request, response.clone());
10 }
11 return response;
12}
- このコードはキャッシュを最優先する場合の実装例です。キャッシュが存在すればそれを返し、なければネットワークから取得してキャッシュに保存します。画像やCSSなど変更頻度の少ない静的リソースに適しています。
network-first
network-first は、まずネットワークを試み、失敗時にキャッシュを返します。最新性が重要なAPIに適しています。
1async function networkFirst(request: Request): Promise<Response> {
2 const cache = await caches.open(CACHE_NAME);
3 try {
4 const response = await fetch(request);
5 if (response && response.ok) {
6 cache.put(request, response.clone());
7 }
8 return response;
9 } catch (err) {
10 const cached = await cache.match(request);
11 if (cached) return cached;
12 return new Response(JSON.stringify({ error: 'offline' }), {
13 status: 503,
14 headers: { 'Content-Type': 'application/json' }
15 });
16 }
17}
- このコードはネットワークを最優先する場合の実装例です。ネットワーク応答が得られればキャッシュに保存し、失敗した場合はキャッシュを返します。ニュース記事やAPIレスポンスなど最新性が重視されるリソースに適しています。
stale-while-revalidate
stale-while-revalidate は、まずキャッシュを返し、同時にバックグラウンドでネットワークから更新します。応答速度と更新性を両立できます。
1async function staleWhileRevalidate(request: Request, cacheName = CACHE_NAME): Promise<Response> {
2 const cache = await caches.open(cacheName);
3 const cachedResponse = await cache.match(request);
4 const networkFetch = fetch(request).then(networkResponse => {
5 if (networkResponse && networkResponse.ok) {
6 cache.put(request, networkResponse.clone());
7 }
8 return networkResponse;
9 }).catch(() => undefined);
10
11 // Return cached immediately if exists, otherwise wait network
12 return cachedResponse || (await networkFetch) || new Response('offline', { status: 503 });
13}
- このコードはキャッシュがあれば即座に返しつつ、裏でネットワークから最新データを取得してキャッシュを更新します。ユーザーには高速な応答を提供しながら、次回アクセス時には更新済みの内容を利用できるため、UIや軽量データの配信に適しています。
更新フローの最適化(更新通知と安全なリロード)
Service Worker
の更新は即時ではなく、既存のタブが閉じられるまで新しいバージョンは待機状態になります。
ここでは、新バージョンの準備完了をクライアントに通知し、ユーザ操作によって安全にリロードする仕組みを実装します。
Service Worker
側で新バージョンの準備完了を通知
1// In sw.ts: after 'activate' or when new version is ready, broadcast a message
2async function notifyClientsUpdated() {
3 const all = await self.clients.matchAll({ type: 'window' });
4 for (const client of all) {
5 client.postMessage({ type: 'SW_UPDATED' });
6 }
7}
8
9// e.g., call this at the end of 'activate'
10self.addEventListener('activate', (event) => {
11 event.waitUntil((async () => {
12 if ('navigationPreload' in self.registration) {
13 await self.registration.navigationPreload.enable();
14 }
15 // cache cleanup
16 const cacheNames = await caches.keys();
17 await Promise.all(
18 cacheNames.map((name) => {
19 if (name !== CACHE_NAME) {
20 return caches.delete(name);
21 }
22 })
23 );
24
25 await self.clients.claim();
26 await notifyClientsUpdated();
27 })());
28});
- このコードでは、
activate
イベントの最後でnotifyClientsUpdated
を呼び出し、接続中のすべてのクライアントに「新バージョン準備完了」を通知します。clients.claim()
は 現在開いているページ(クライアント)を、新しくアクティブになった Service Worker で即座に制御する メソッドです。通常、Service Worker
は 次回ページロード時に初めて制御を開始しますが、clients.claim()
を使うとページをリロードしなくても即時に制御可能です。
クライアント側で更新UIを出し、ユーザ操作でリロード
1// in app startup
2navigator.serviceWorker.addEventListener('message', (e) => {
3 if (e.data?.type === 'SW_UPDATED') {
4 // Show a non-intrusive toast or banner: "New version available"
5 // When user clicks "Reload", call:
6 window.location.reload();
7 }
8});
- クライアントは
message
イベントでSW_UPDATED
を受け取り、UI 上に更新通知を表示します。ユーザがリロードを選択するとwindow.location.reload()
が実行され、ページ内の古いHTMLやCSSなどを最新バージョンに更新できます。これにより、clients.claim()
で切り替えられたService Worker
のキャッシュや制御内容が、ページ全体に反映されます。
オフラインフォールバック
重要なナビゲーションには/offline.html
を用意し、画像やフォントがなくても意味が伝わる最小UIにします。API に失敗した場合は、可能であれば キャッシュされた最後の状態 を表示し、同時にバックグラウンドで再取得を試みると UX が向上します。
実装例
1// sw.ts
2const CACHE_NAME = 'app-cache-v1';
3
4// Cache offline.html during install
5self.addEventListener('install', (event) => {
6 event.waitUntil((async () => {
7 const cache = await caches.open(CACHE_NAME);
8 await cache.addAll(['/offline.html']);
9 })());
10});
11
12// Handle fetch requests
13self.addEventListener('fetch', (event) => {
14 const request = event.request;
15
16 // Navigation requests (e.g., page transitions)
17 if (request.mode === 'navigate') {
18 event.respondWith((async () => {
19 try {
20 // Try to fetch from the network as usual
21 return await fetch(request);
22 } catch (err) {
23 // On failure, return offline fallback page
24 const cache = await caches.open(CACHE_NAME);
25 return await cache.match('/offline.html') as Response;
26 }
27 })());
28 }
29});
install
イベントで/offline.html
をプリキャッシュし、ネットワークが落ちても最低限のページを返せるように準備します。fetch
イベントでは、request.mode === 'navigate'
でナビゲーションリクエストを監視し、ページ遷移だけを対象にできます。- ネットワーク失敗時にフォールバックし、オフライン時でも
/offline.html
を表示します。
クライアントとService Worker
間のメッセージング
Service Worker
はページのライフサイクルから独立して動作するため、状態の通知やコマンドの実行には 双方向メッセージング が重要になります。メッセージに型を指定することで、誤ったメッセージ送信を防ぎ、補完も効くので実装が堅牢になります。
コード例
- メッセージ型の定義
1type SwToClient =
2 | { type: 'SW_READY' }
3 | { type: 'SW_UPDATED' }
4 | { type: 'CACHE_CLEARED' }
5 | { type: 'PING'; ts: number };
6
7type ClientToSw =
8 | { type: 'CLEAR_CACHE' }
9 | { type: 'PING'; ts: number };
SwToClient
は、Service Worker
からクライアントに送るメッセージの型です。ClientToSw
は、クライアントからService Worker
に送るメッセージの型です。- これにより双方向通信でやり取りできるイベントの種類を明確化できます。
- Service Worker 側の処理
1self.addEventListener('message', (event) => {
2 const data = event.data as ClientToSw;
3 if (data?.type === 'CLEAR_CACHE') {
4 event.waitUntil((async () => {
5 const keys = await caches.keys();
6 await Promise.all(keys.map((k) => caches.delete(k)));
7 await broadcast({ type: 'CACHE_CLEARED' });
8 })());
9 } else if (data?.type === 'PING') {
10 event.source?.postMessage({ type: 'PING', ts: data.ts } as SwToClient);
11 }
12});
- クライアントからのメッセージを受け取り、型で分岐処理しています。
CLEAR_CACHE
の場合、キャッシュを削除してから、全クライアントにCACHE_CLEARED
を通知します。PING
の場合、元のクライアントにタイムスタンプ付きでPING
を返信します。
- Service Worker から全クライアントへ通知
1async function broadcast(msg: SwToClient) {
2 const clients = await self.clients.matchAll({ includeUncontrolled: true });
3 for (const c of clients) c.postMessage(msg);
4}
clients.matchAll
を使って全てのウィンドウタブを取得します。- それぞれに
postMessage
を送ることでブロードキャストが可能です。 - 更新通知 (
SW_UPDATED
など) やエラー通知に使えます。
- クライアント側の処理
1navigator.serviceWorker.controller?.postMessage({
2 type: 'PING',
3 ts: Date.now()
4} as ClientToSw);
- クライアントから
PING
を送信し、Service Worker
側からの応答を受け取ることで、双方向通信が正しく動作しているかを確認できます。これにより、接続状態やメッセージハンドリングのテストが容易になります。
1navigator.serviceWorker.addEventListener('message', (e) => {
2 const msg = e.data as SwToClient;
3 switch (msg.type) {
4 case 'SW_READY':
5 console.log('Service Worker is ready');
6 // Example: hide loading spinner or enable offline UI
7 break;
8 case 'SW_UPDATED':
9 console.log('A new version of the Service Worker is available');
10 // Example: show update notification or reload prompt
11 const shouldReload = confirm('A new version is available. Reload now?');
12 if (shouldReload) {
13 window.location.reload();
14 }
15 break;
16 case 'CACHE_CLEARED':
17 console.log('Cache cleared');
18 // Example: show confirmation message to user
19 alert('Cache has been successfully cleared.');
20 break;
21 case 'PING':
22 console.log(`Received PING response, ts=${msg.ts}`);
23 break;
24 }
25});
- {^ i18n_speak
クライアント側では
Service Worker
から送信されるメッセージを受信し、種類に応じて処理を分岐します。SW_READY
は初期化完了、SW_UPDATED
は新バージョン検出、CACHE_CLEARED
はキャッシュ削除完了、PING
は通信確認を示します。各メッセージに応じて、UI の更新やリロード、通知表示などを行います。^}
型付きメッセージングの利点
- 型付きメッセージを導入すると、どのメッセージが送受信できるか明確になり、補完や型チェックが効いて安全です。
postMessage
によって 1対1、broadcast
によって 1対多 の通信が可能です。- 更新通知 (
SW_UPDATED
)、キャッシュ管理 (CACHE_CLEARED
)、ヘルスチェック (PING
) など、運用に不可欠な仕組みをシンプルに実現できます。
まとめ
- TypeScript を利用することで、
Service Worker
の API 呼び出しやメッセージングが型安全になり、開発効率と保守性が大幅に向上します。 install
、activate
、fetch
といったライフサイクルを正しく理解し、cache-first や network-first などのキャッシュ戦略をシーンに応じて選択することが、快適なユーザー体験につながります。- 運用面では、キャッシュのバージョン管理や更新フロー(
updatefound
、waiting
、SKIP_WAITING
など)の理解が欠かせません。 - クライアントと
Service Worker
間の通信には型付きメッセージングを導入することで、誤実装を防ぎ、長期的に見ても拡張・保守しやすい仕組みを構築できます。
YouTubeチャンネルでは、Visual Studio Codeを用いて上記の記事を見ながら確認できます。 ぜひYouTubeチャンネルもご覧ください。