T-CREATOR

JavaScript Service Worker 運用術:オフライン対応・更新・キャッシュ戦略の最適解

JavaScript Service Worker 運用術:オフライン対応・更新・キャッシュ戦略の最適解

現代の Web アプリケーション開発において、オフラインでも動作する PWA(Progressive Web Apps)が注目を集めています。その中核技術となるのが Service Worker です。しかし、実際に運用してみると、キャッシュ戦略の選定や更新タイミングの制御など、さまざまな課題に直面することになるでしょう。

本記事では、Service Worker の実践的な運用術として、オフライン対応の実装方法、更新メカニズムの最適化、そして状況に応じたキャッシュ戦略の選び方を詳しく解説します。初めて Service Worker を導入する方でも理解できるよう、段階的に説明していきますね。

背景

Service Worker の基本概念

Service Worker は、ブラウザがバックグラウンドで実行するスクリプトで、Web ページとは独立して動作します。これにより、ネットワークリクエストのインターセプト、リソースのキャッシュ、プッシュ通知など、従来の Web では実現できなかった機能を提供できるのです。

以下の図で、Service Worker がどのように Web アプリケーションと連携するかを確認しましょう。

mermaidflowchart TB
  user["ユーザー"] -->|ページアクセス| browser["ブラウザ"]
  browser -->|登録| sw["Service Worker"]
  browser -->|リクエスト| sw
  sw -->|キャッシュ確認| cache[("Cache Storage")]
  sw -->|なければ取得| network["ネットワーク"]
  network -->|レスポンス| sw
  sw -->|保存| cache
  sw -->|レスポンス返却| browser
  browser -->|表示| user

図の要点:ブラウザからのリクエストは Service Worker が仲介し、キャッシュとネットワークのどちらからレスポンスを返すかを制御します。

Service Worker のライフサイクル

Service Worker は独自のライフサイクルを持ち、登録、インストール、アクティベート、待機という段階を経て動作します。

mermaidstateDiagram-v2
  [*] --> Parsed: スクリプト解析
  Parsed --> Installing: install イベント
  Installing --> Installed: インストール完了
  Installed --> Activating: activate イベント
  Activating --> Activated: アクティベート完了
  Activated --> Idle: 待機状態
  Idle --> Fetch: fetch イベント
  Fetch --> Idle: 処理完了
  Idle --> [*]: 終了

図の要点:Service Worker は段階的にライフサイクルを進み、各段階でイベントが発火します。この仕組みを理解することが運用の鍵となるのです。

Service Worker の適用範囲

Service Worker はスコープという概念を持ち、登録されたパス以下のページでのみ有効になります。通常はルート(​/​)に登録することで、サイト全体をカバーできますね。

課題

オフライン対応の複雑さ

オフライン対応を実装する際、どのリソースをキャッシュすべきか、どのタイミングでキャッシュするか、キャッシュの有効期限をどう管理するかなど、考慮すべき点が多数あります。特に動的なコンテンツとスタティックなアセットでは、最適なキャッシュ戦略が異なるため、設計が複雑になりがちです。

更新タイミングの制御

Service Worker の更新は自動的に行われますが、ユーザーがページを開いている間は古い Service Worker が動き続けます。新しいバージョンをいつアクティベートするか、ユーザー体験を損なわずに更新を反映させる方法を検討する必要があるでしょう。

キャッシュ戦略の選定

以下の図で、主なキャッシュ戦略のパターンを確認しましょう。

mermaidflowchart TB
  request["リクエスト"] --> strategy{戦略選択}

  strategy -->|Cache First| cf["キャッシュ優先"]
  cf --> cacheCheck1{キャッシュ<br/>存在?}
  cacheCheck1 -->|Yes| returnCache1["キャッシュ返却"]
  cacheCheck1 -->|No| fetchNet1["ネットワーク取得"]

  strategy -->|Network First| nf["ネットワーク優先"]
  nf --> netCheck{ネットワーク<br/>成功?}
  netCheck -->|Yes| returnNet["レスポンス返却"]
  netCheck -->|No| cacheCheck2{キャッシュ<br/>存在?}
  cacheCheck2 -->|Yes| returnCache2["キャッシュ返却"]

  strategy -->|Stale While Revalidate| swr["キャッシュ即返却<br/>+バックグラウンド更新"]

図で理解できる要点

  • Cache First:静的アセット向け、高速だがリアルタイム性は低い
  • Network First:API データ向け、最新データ優先だが通信必須
  • Stale While Revalidate:バランス型、即座に表示しつつ更新も行う

適切なキャッシュ戦略を選ばないと、古いコンテンツが表示され続けたり、逆にオフライン時に何も表示できなくなったりします。リソースの性質に応じて戦略を使い分ける必要があるのです。

キャッシュ容量の管理

ブラウザのキャッシュストレージには容量制限があります。無制限にキャッシュし続けると、ストレージが圧迫され、パフォーマンスが低下する可能性があるでしょう。

解決策

Service Worker の基本的な登録

まずは Service Worker の登録から始めましょう。メインスクリプトから Service Worker を登録する方法を解説します。

以下のコードは、ブラウザが Service Worker をサポートしているかチェックし、登録を行います。

javascript// メインスクリプト(main.js など)で実行

if ('serviceWorker' in navigator) {
  // ページ読み込み完了後に登録
  window.addEventListener('load', async () => {
    try {
      // Service Worker を登録
      const registration =
        await navigator.serviceWorker.register('/sw.js', {
          scope: '/', // スコープを指定(省略可能、デフォルトは sw.js の場所)
        });

      console.log(
        'Service Worker 登録成功:',
        registration.scope
      );
    } catch (error) {
      console.error('Service Worker 登録失敗:', error);
    }
  });
}

ポイントload イベント後に登録することで、初回ページ表示のパフォーマンスへの影響を最小限に抑えます。

インストール時のキャッシュ設定

次に、Service Worker 自体のコード(sw.js)を作成しましょう。インストール時に必須のリソースをキャッシュします。

まず、キャッシュ名とキャッシュするファイルリストを定義します。

javascript// Service Worker ファイル(sw.js)

// キャッシュのバージョン管理(更新時に変更する)
const CACHE_VERSION = 'v1.0.0';
const CACHE_NAME = `app-cache-${CACHE_VERSION}`;

// インストール時にキャッシュする必須リソース
const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.png',
  '/manifest.json',
];

次に、install イベントでこれらのリソースをキャッシュします。

javascript// インストールイベント:必須リソースのキャッシュ
self.addEventListener('install', (event) => {
  console.log('Service Worker: インストール中...');

  // インストール処理が完了するまで待機
  event.waitUntil(
    caches
      .open(CACHE_NAME)
      .then((cache) => {
        console.log('キャッシュオープン成功');
        // 全リソースをキャッシュに追加
        return cache.addAll(PRECACHE_URLS);
      })
      .then(() => {
        console.log('必須リソースのキャッシュ完了');
        // すぐにアクティベート状態に移行
        return self.skipWaiting();
      })
      .catch((error) => {
        console.error('キャッシュ処理失敗:', error);
      })
  );
});

ポイントskipWaiting() を呼ぶことで、待機状態をスキップして即座にアクティベートします。

アクティベート時の古いキャッシュ削除

アクティベート時には、古いバージョンのキャッシュを削除して、ストレージを最適化しましょう。

javascript// アクティベートイベント:古いキャッシュの削除
self.addEventListener('activate', (event) => {
  console.log('Service Worker: アクティベート中...');

  event.waitUntil(
    caches
      .keys()
      .then((cacheNames) => {
        // 削除対象のキャッシュを抽出
        return Promise.all(
          cacheNames.map((cacheName) => {
            // 現在のバージョン以外のキャッシュを削除
            if (cacheName !== CACHE_NAME) {
              console.log(
                '古いキャッシュを削除:',
                cacheName
              );
              return caches.delete(cacheName);
            }
          })
        );
      })
      .then(() => {
        // すべてのクライアントを即座に制御下に置く
        return self.clients.claim();
      })
      .catch((error) => {
        console.error('アクティベート処理失敗:', error);
      })
  );
});

ポイントclients.claim() により、既存のページも即座に新しい Service Worker の制御下になります。

Cache First 戦略の実装

静的なアセット(CSS、JavaScript、画像など)には Cache First 戦略が適しています。キャッシュを優先し、なければネットワークから取得する方法です。

以下のコードで、リクエストの種類を判定するヘルパー関数を定義します。

javascript// リソースタイプの判定ヘルパー関数

// 静的アセットかどうかを判定
function isStaticAsset(url) {
  const staticExtensions = [
    '.css',
    '.js',
    '.png',
    '.jpg',
    '.jpeg',
    '.gif',
    '.svg',
    '.woff',
    '.woff2',
  ];
  return staticExtensions.some((ext) =>
    url.pathname.endsWith(ext)
  );
}

// API リクエストかどうかを判定
function isApiRequest(url) {
  return url.pathname.startsWith('/api/');
}

次に、Cache First 戦略を実装します。

javascript// Cache First 戦略:キャッシュ優先、なければネットワーク
async function cacheFirstStrategy(request) {
  // キャッシュから検索
  const cachedResponse = await caches.match(request);

  if (cachedResponse) {
    console.log('キャッシュヒット:', request.url);
    return cachedResponse;
  }

  // キャッシュになければネットワークから取得
  console.log('ネットワークから取得:', request.url);
  try {
    const networkResponse = await fetch(request);

    // 取得成功したらキャッシュに保存
    if (networkResponse && networkResponse.status === 200) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, networkResponse.clone());
    }

    return networkResponse;
  } catch (error) {
    console.error('ネットワーク取得失敗:', error);
    // オフライン用のフォールバックページを返す(オプション)
    return caches.match('/offline.html');
  }
}

ポイントnetworkResponse.clone() でレスポンスを複製してからキャッシュします。レスポンスは一度しか使えないためです。

Network First 戦略の実装

API データなど、常に最新の情報が必要なリソースには Network First 戦略を使用します。

javascript// Network First 戦略:ネットワーク優先、失敗時はキャッシュ
async function networkFirstStrategy(request) {
  try {
    console.log('ネットワークから取得を試行:', request.url);
    const networkResponse = await fetch(request);

    // 取得成功したらキャッシュに保存
    if (networkResponse && networkResponse.status === 200) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, networkResponse.clone());
    }

    return networkResponse;
  } catch (error) {
    // ネットワーク失敗時はキャッシュから返す
    console.log(
      'ネットワーク失敗、キャッシュを確認:',
      request.url
    );
    const cachedResponse = await caches.match(request);

    if (cachedResponse) {
      console.log('キャッシュから返却:', request.url);
      return cachedResponse;
    }

    // キャッシュもなければエラーレスポンス
    console.error('キャッシュも存在しません:', error);
    return new Response('ネットワークエラー', {
      status: 503,
      statusText: 'Service Unavailable',
    });
  }
}

ポイント:ネットワークエラー時にキャッシュをフォールバックとして使用することで、オフライン対応を実現します。

Stale While Revalidate 戦略の実装

ユーザー体験とデータの新鮮さを両立したい場合は、Stale While Revalidate 戦略が効果的です。

javascript// Stale While Revalidate 戦略:キャッシュを即返却しつつバックグラウンド更新
async function staleWhileRevalidateStrategy(request) {
  const cache = await caches.open(CACHE_NAME);

  // キャッシュから即座に返す
  const cachedResponse = await caches.match(request);

  // バックグラウンドでネットワークから取得して更新
  const fetchPromise = fetch(request)
    .then((networkResponse) => {
      if (
        networkResponse &&
        networkResponse.status === 200
      ) {
        console.log('バックグラウンド更新:', request.url);
        cache.put(request, networkResponse.clone());
      }
      return networkResponse;
    })
    .catch((error) => {
      console.warn('バックグラウンド更新失敗:', error);
    });

  // キャッシュがあればそれを返し、なければネットワークを待つ
  return cachedResponse || fetchPromise;
}

ポイント:キャッシュを即座に返しつつ、バックグラウンドで最新データを取得してキャッシュを更新します。次回アクセス時には更新されたデータが表示されるのです。

Fetch イベントでの戦略振り分け

実際の fetch イベントで、リクエストの種類に応じて適切な戦略を選択しましょう。

javascript// Fetch イベント:リクエストのインターセプト
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // 外部リソース(CDN など)はスキップ
  if (url.origin !== location.origin) {
    return;
  }

  // リクエストタイプに応じた戦略を選択
  let strategyPromise;

  if (isApiRequest(url)) {
    // API は Network First
    strategyPromise = networkFirstStrategy(request);
  } else if (isStaticAsset(url)) {
    // 静的アセットは Cache First
    strategyPromise = cacheFirstStrategy(request);
  } else {
    // HTML ページは Stale While Revalidate
    strategyPromise = staleWhileRevalidateStrategy(request);
  }

  event.respondWith(strategyPromise);
});

ポイント:URL のパターンに基づいて戦略を振り分けることで、リソースごとに最適なキャッシュ動作を実現できます。

キャッシュ容量の制限と管理

キャッシュサイズを管理するため、古いエントリを自動削除する仕組みを実装しましょう。

まず、キャッシュの設定を定義します。

javascript// キャッシュ管理の設定

const CACHE_CONFIG = {
  maxEntries: 50, // 最大エントリ数
  maxAgeSeconds: 86400, // 最大保持期間(24時間)
};

次に、キャッシュをクリーンアップする関数を作成します。

javascript// キャッシュのクリーンアップ関数
async function cleanupCache(cacheName, config) {
  const cache = await caches.open(cacheName);
  const requests = await cache.keys();

  // エントリ数が上限を超えている場合
  if (requests.length > config.maxEntries) {
    // 古いものから削除(FIFO)
    const deleteCount = requests.length - config.maxEntries;
    for (let i = 0; i < deleteCount; i++) {
      await cache.delete(requests[i]);
      console.log(
        '古いキャッシュエントリを削除:',
        requests[i].url
      );
    }
  }
}

キャッシュに追加する際にクリーンアップを実行します。

javascript// キャッシュ追加時のヘルパー関数
async function addToCache(request, response) {
  const cache = await caches.open(CACHE_NAME);
  await cache.put(request, response);

  // クリーンアップを実行
  await cleanupCache(CACHE_NAME, CACHE_CONFIG);
}

ポイント:定期的にキャッシュをクリーンアップすることで、ストレージの圧迫を防ぎます。

Service Worker の更新通知

新しい Service Worker が利用可能になったとき、ユーザーに通知して更新を促す仕組みを実装します。

メインスクリプト側で更新を検知します。

javascript// メインスクリプト(main.js)での更新検知

if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    const registration =
      await navigator.serviceWorker.register('/sw.js');

    // 更新が見つかったときの処理
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;

      newWorker.addEventListener('statechange', () => {
        if (
          newWorker.state === 'installed' &&
          navigator.serviceWorker.controller
        ) {
          // 新しい Service Worker がインストールされた
          console.log('新しいバージョンが利用可能です');

          // ユーザーに更新を通知(例:バナー表示)
          showUpdateNotification();
        }
      });
    });
  });
}

更新通知の UI を表示する関数を実装します。

javascript// 更新通知バナーの表示
function showUpdateNotification() {
  const banner = document.createElement('div');
  banner.id = 'update-banner';
  banner.innerHTML = `
    <p>新しいバージョンが利用可能です</p>
    <button id="reload-button">更新する</button>
  `;
  document.body.appendChild(banner);

  // 更新ボタンのクリックイベント
  document
    .getElementById('reload-button')
    .addEventListener('click', () => {
      // ページをリロードして新しい Service Worker を適用
      window.location.reload();
    });
}

ポイント:ユーザーが明示的に更新を選択できるようにすることで、作業中のデータ損失を防げます。

強制的な即時更新の実装

開発中や緊急時には、待機中の Service Worker を即座にアクティベートしたい場合があります。

Service Worker 側でメッセージを受信する処理を追加します。

javascript// Service Worker でメッセージを受信
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    console.log('即時アクティベート要求を受信');
    self.skipWaiting();
  }
});

メインスクリプト側から skipWaiting を実行するよう指示します。

javascript// メインスクリプトから即時更新を指示
function forceUpdateServiceWorker() {
  navigator.serviceWorker.ready.then((registration) => {
    if (registration.waiting) {
      // 待機中の Service Worker にメッセージを送信
      registration.waiting.postMessage({
        type: 'SKIP_WAITING',
      });
    }
  });

  // Service Worker の制御が変わったらリロード
  navigator.serviceWorker.addEventListener(
    'controllerchange',
    () => {
      window.location.reload();
    }
  );
}

ポイントpostMessage を使って Service Worker とメインスクリプト間で通信することで、柔軟な更新制御が可能になります。

具体例

実践的な Service Worker の完全実装

ここまで解説した機能を統合した、実践的な Service Worker の完全版を見ていきましょう。

まず、設定と定数を定義します。

javascript// sw.js - 完全版 Service Worker

// ===== 設定・定数 =====
const CACHE_VERSION = 'v1.2.0';
const CACHE_NAME = `pwa-cache-${CACHE_VERSION}`;

// キャッシュ管理設定
const CACHE_CONFIG = {
  maxEntries: 100,
  maxAgeSeconds: 604800, // 7日間
};

// プリキャッシュリソース
const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/styles/theme.css',
  '/scripts/app.js',
  '/scripts/utils.js',
  '/images/logo.svg',
  '/images/icon-192.png',
  '/images/icon-512.png',
  '/manifest.json',
  '/offline.html',
];

次に、ヘルパー関数を定義します。

javascript// ===== ヘルパー関数 =====

// 静的アセットの判定
function isStaticAsset(url) {
  const extensions = [
    '.css',
    '.js',
    '.png',
    '.jpg',
    '.jpeg',
    '.gif',
    '.svg',
    '.webp',
    '.woff',
    '.woff2',
    '.ttf',
  ];
  return extensions.some((ext) =>
    url.pathname.endsWith(ext)
  );
}

// API リクエストの判定
function isApiRequest(url) {
  return (
    url.pathname.startsWith('/api/') ||
    url.pathname.startsWith('/graphql')
  );
}

// HTML ページの判定
function isHtmlPage(url) {
  return (
    url.pathname.endsWith('.html') ||
    url.pathname === '/' ||
    !url.pathname.includes('.')
  );
}

インストールイベントを実装します。

javascript// ===== インストールイベント =====
self.addEventListener('install', (event) => {
  console.log(`[SW] インストール開始: ${CACHE_VERSION}`);

  event.waitUntil(
    caches
      .open(CACHE_NAME)
      .then((cache) => {
        console.log('[SW] プリキャッシュ開始');
        return cache.addAll(PRECACHE_URLS);
      })
      .then(() => {
        console.log('[SW] プリキャッシュ完了');
        return self.skipWaiting();
      })
      .catch((error) => {
        console.error('[SW] インストールエラー:', error);
      })
  );
});

アクティベートイベントを実装します。

javascript// ===== アクティベートイベント =====
self.addEventListener('activate', (event) => {
  console.log(`[SW] アクティベート開始: ${CACHE_VERSION}`);

  event.waitUntil(
    Promise.all([
      // 古いキャッシュの削除
      caches.keys().then((cacheNames) => {
        return Promise.all(
          cacheNames.map((cacheName) => {
            if (cacheName !== CACHE_NAME) {
              console.log(
                '[SW] 古いキャッシュを削除:',
                cacheName
              );
              return caches.delete(cacheName);
            }
          })
        );
      }),
      // すべてのクライアントを制御下に
      self.clients.claim(),
    ])
      .then(() => {
        console.log('[SW] アクティベート完了');
      })
      .catch((error) => {
        console.error('[SW] アクティベートエラー:', error);
      })
  );
});

各キャッシュ戦略を実装します。

javascript// ===== キャッシュ戦略 =====

// Cache First: 静的アセット用
async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) {
    console.log('[SW] キャッシュヒット:', request.url);
    return cached;
  }

  try {
    const response = await fetch(request);
    if (response && response.status === 200) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch (error) {
    console.error('[SW] Fetch エラー:', error);
    return new Response('Network error', { status: 408 });
  }
}

// Network First: API 用
async function networkFirst(request) {
  try {
    const response = await fetch(request);
    if (response && response.status === 200) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch (error) {
    console.log(
      '[SW] ネットワーク失敗、キャッシュ確認:',
      request.url
    );
    const cached = await caches.match(request);
    if (cached) return cached;

    return new Response(
      JSON.stringify({ error: 'オフラインです' }),
      {
        status: 503,
        headers: { 'Content-Type': 'application/json' },
      }
    );
  }
}

// Stale While Revalidate: HTML ページ用
async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME);
  const cached = await caches.match(request);

  const fetchPromise = fetch(request)
    .then((response) => {
      if (response && response.status === 200) {
        cache.put(request, response.clone());
      }
      return response;
    })
    .catch(() => cached);

  return cached || fetchPromise;
}

Fetch イベントで戦略を振り分けます。

javascript// ===== Fetch イベント =====
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // 同一オリジンのみ処理
  if (url.origin !== location.origin) {
    return;
  }

  // 戦略の選択
  let strategy;

  if (isApiRequest(url)) {
    strategy = networkFirst(request);
  } else if (isStaticAsset(url)) {
    strategy = cacheFirst(request);
  } else if (isHtmlPage(url)) {
    strategy = staleWhileRevalidate(request);
  } else {
    // デフォルトは Network First
    strategy = networkFirst(request);
  }

  event.respondWith(strategy);
});

メッセージイベントで即時更新を処理します。

javascript// ===== メッセージイベント =====
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    console.log('[SW] 即時アクティベート要求');
    self.skipWaiting();
  }

  if (event.data && event.data.type === 'CLEAR_CACHE') {
    console.log('[SW] キャッシュクリア要求');
    event.waitUntil(
      caches.delete(CACHE_NAME).then(() => {
        console.log('[SW] キャッシュクリア完了');
      })
    );
  }
});

ポイント:この実装により、オフライン対応、効率的なキャッシュ、柔軟な更新制御が実現できます。

メインスクリプトでの Service Worker 管理

次に、メインスクリプト側で Service Worker を登録・管理するコードを見ていきましょう。

登録と更新検知の実装です。

javascript// main.js - Service Worker 管理スクリプト

// ===== Service Worker の登録 =====
async function registerServiceWorker() {
  if (!('serviceWorker' in navigator)) {
    console.warn(
      'このブラウザは Service Worker に対応していません'
    );
    return null;
  }

  try {
    const registration =
      await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      });

    console.log(
      'Service Worker 登録成功:',
      registration.scope
    );

    // 更新チェック
    setupUpdateDetection(registration);

    return registration;
  } catch (error) {
    console.error('Service Worker 登録失敗:', error);
    return null;
  }
}

更新検知と通知の実装です。

javascript// ===== 更新検知の設定 =====
function setupUpdateDetection(registration) {
  // 新しいバージョンの検知
  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing;
    console.log('新しい Service Worker を検出');

    newWorker.addEventListener('statechange', () => {
      if (newWorker.state === 'installed') {
        if (navigator.serviceWorker.controller) {
          // 更新あり
          console.log('新しいバージョンが利用可能です');
          showUpdateNotification(registration);
        } else {
          // 初回インストール
          console.log(
            'Service Worker が初めてインストールされました'
          );
        }
      }
    });
  });

  // 定期的な更新チェック(1時間ごと)
  setInterval(() => {
    registration.update();
  }, 3600000);
}

更新通知 UI の実装です。

javascript// ===== 更新通知の表示 =====
function showUpdateNotification(registration) {
  // 既存の通知があれば削除
  const existing = document.getElementById(
    'sw-update-banner'
  );
  if (existing) existing.remove();

  // 通知バナーを作成
  const banner = document.createElement('div');
  banner.id = 'sw-update-banner';
  banner.style.cssText = `
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    background: #2196F3;
    color: white;
    padding: 16px;
    text-align: center;
    z-index: 9999;
    box-shadow: 0 2px 8px rgba(0,0,0,0.2);
  `;

  banner.innerHTML = `
    <span style="margin-right: 16px;">新しいバージョンが利用可能です</span>
    <button id="sw-update-btn" style="background: white; color: #2196F3; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
      更新する
    </button>
    <button id="sw-dismiss-btn" style="background: transparent; color: white; border: 1px solid white; padding: 8px 16px; border-radius: 4px; cursor: pointer; margin-left: 8px;">
      後で
    </button>
  `;

  document.body.appendChild(banner);

  // 更新ボタン
  document
    .getElementById('sw-update-btn')
    .addEventListener('click', () => {
      applyUpdate(registration);
    });

  // 閉じるボタン
  document
    .getElementById('sw-dismiss-btn')
    .addEventListener('click', () => {
      banner.remove();
    });
}

更新の適用処理です。

javascript// ===== 更新の適用 =====
function applyUpdate(registration) {
  if (registration.waiting) {
    // 待機中の Service Worker に即時アクティベートを指示
    registration.waiting.postMessage({
      type: 'SKIP_WAITING',
    });
  }

  // Controller が変わったらリロード
  let refreshing = false;
  navigator.serviceWorker.addEventListener(
    'controllerchange',
    () => {
      if (refreshing) return;
      refreshing = true;
      window.location.reload();
    }
  );
}

ページ読み込み時の初期化です。

javascript// ===== 初期化 =====
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', () => {
    registerServiceWorker();
  });
} else {
  registerServiceWorker();
}

// オンライン/オフライン状態の監視
window.addEventListener('online', () => {
  console.log('オンラインに復帰しました');
  document.body.classList.remove('offline');
});

window.addEventListener('offline', () => {
  console.log('オフラインになりました');
  document.body.classList.add('offline');
});

ポイント:ユーザーフレンドリーな更新通知と、オンライン/オフライン状態の可視化により、優れたユーザー体験を提供します。

キャッシュ戦略の使い分け実例

実際のアプリケーションでのキャッシュ戦略の使い分けを表でまとめましょう。

#リソースタイプ推奨戦略理由具体例
1アプリシェル(HTML/CSS/JS)Cache First頻繁に変更されず、高速表示が重要index.html, main.css, app.js
2画像・フォントCache Firstサイズが大きく、変更頻度が低いlogo.png, icon.svg, font.woff2
3API データNetwork First常に最新のデータが必要​/​api​/​users, ​/​api​/​posts
4ユーザー設定Network Firstリアルタイム同期が重要​/​api​/​settings, ​/​api​/​preferences
5記事コンテンツStale While Revalidate即座に表示しつつ更新も欲しい​/​articles​/​123, ​/​blog​/​post-title
6ダッシュボードStale While RevalidateUX と鮮度のバランス​/​dashboard, ​/​overview

この表を参考に、リソースの性質に応じて最適な戦略を選択できますね。

エラーハンドリングとフォールバック

オフライン時やエラー時のフォールバック処理を実装しましょう。

まず、オフライン用のフォールバックページを作成します。

html<!-- offline.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>オフライン</title>
    <style>
      body {
        font-family: -apple-system, BlinkMacSystemFont,
          'Segoe UI', sans-serif;
        display: flex;
        justify-content: center;
        align-items: center;
        min-height: 100vh;
        margin: 0;
        background: linear-gradient(
          135deg,
          #667eea 0%,
          #764ba2 100%
        );
        color: white;
      }
      .container {
        text-align: center;
        padding: 2rem;
      }
      h1 {
        font-size: 3rem;
        margin-bottom: 1rem;
      }
      p {
        font-size: 1.2rem;
        opacity: 0.9;
      }
      button {
        margin-top: 2rem;
        padding: 1rem 2rem;
        font-size: 1rem;
        background: white;
        color: #667eea;
        border: none;
        border-radius: 8px;
        cursor: pointer;
        font-weight: bold;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <h1>📱 オフラインです</h1>
      <p>インターネット接続を確認してください</p>
      <button onclick="location.reload()">再試行</button>
    </div>
  </body>
</html>

Service Worker でのフォールバック処理です。

javascript// sw.js でのエラーフォールバック

// HTML ページのフォールバック
async function handleHtmlFallback(request) {
  try {
    const response = await staleWhileRevalidate(request);
    if (response.status === 200) {
      return response;
    }
  } catch (error) {
    console.error('[SW] HTML 取得エラー:', error);
  }

  // フォールバックページを返す
  return caches.match('/offline.html');
}

// 画像のフォールバック
async function handleImageFallback(request) {
  try {
    return await cacheFirst(request);
  } catch (error) {
    // プレースホルダー画像を返す(SVG データ URI)
    const svg = `
      <svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
        <rect width="400" height="300" fill="#ddd"/>
        <text x="50%" y="50%" text-anchor="middle" fill="#999" font-size="24">
          画像を読み込めません
        </text>
      </svg>
    `;
    return new Response(svg, {
      headers: { 'Content-Type': 'image/svg+xml' },
    });
  }
}

ポイント:適切なフォールバック処理により、オフライン時でもユーザーに情報を提供できます。

パフォーマンス最適化のテクニック

Service Worker のパフォーマンスを最適化するためのテクニックをいくつか紹介します。

リクエストのフィルタリングを実装します。

javascript// 不要なリクエストをスキップする

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // スキップする条件
  const shouldSkip =
    // 外部リソース
    url.origin !== location.origin ||
    // アナリティクス
    url.pathname.includes('/analytics') ||
    // WebSocket
    url.protocol === 'ws:' ||
    url.protocol === 'wss:' ||
    // POST/PUT/DELETE リクエスト(GET のみキャッシュ)
    request.method !== 'GET';

  if (shouldSkip) {
    return; // デフォルトのネットワークリクエストに任せる
  }

  // キャッシュ戦略を適用
  event.respondWith(selectStrategy(request));
});

効率的なキャッシュキーの生成です。

javascript// クエリパラメータを正規化してキャッシュキーを生成

function normalizeRequest(request) {
  const url = new URL(request.url);

  // 不要なパラメータを除外(例:アナリティクスパラメータ)
  const paramsToRemove = [
    'utm_source',
    'utm_medium',
    'utm_campaign',
    'fbclid',
  ];
  paramsToRemove.forEach((param) =>
    url.searchParams.delete(param)
  );

  // 正規化された URL で新しいリクエストを作成
  return new Request(url.toString(), {
    method: request.method,
    headers: request.headers,
    mode: request.mode,
    credentials: request.credentials,
  });
}

// 使用例
self.addEventListener('fetch', (event) => {
  const normalizedRequest = normalizeRequest(event.request);
  event.respondWith(cacheFirst(normalizedRequest));
});

ポイント:不要なリクエストをフィルタリングし、キャッシュキーを正規化することで、パフォーマンスとキャッシュ効率が向上します。

デバッグとモニタリング

Service Worker の動作を監視するためのログ機能を実装しましょう。

javascript// sw.js でのデバッグログ

// 環境に応じたログレベル
const LOG_LEVEL =
  self.location.hostname === 'localhost'
    ? 'debug'
    : 'error';

// ロガーユーティリティ
const logger = {
  debug: (...args) => {
    if (LOG_LEVEL === 'debug') {
      console.log('[SW Debug]', ...args);
    }
  },
  info: (...args) => {
    console.log('[SW Info]', ...args);
  },
  error: (...args) => {
    console.error('[SW Error]', ...args);
  },
};

// 使用例
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  logger.debug('Fetch:', url.pathname);

  // ... 戦略の実行
});

キャッシュ統計の収集です。

javascript// キャッシュヒット率の追跡

let cacheStats = {
  hits: 0,
  misses: 0,
  errors: 0,
};

// 統計を更新する関数
function updateStats(type) {
  cacheStats[type]++;

  // 100 リクエストごとにログ出力
  const total =
    cacheStats.hits + cacheStats.misses + cacheStats.errors;
  if (total % 100 === 0) {
    const hitRate = (
      (cacheStats.hits / total) *
      100
    ).toFixed(2);
    logger.info(
      'キャッシュヒット率:',
      `${hitRate}%`,
      cacheStats
    );
  }
}

// 使用例
async function trackingCacheFirst(request) {
  const cached = await caches.match(request);

  if (cached) {
    updateStats('hits');
    return cached;
  }

  try {
    const response = await fetch(request);
    updateStats('misses');
    return response;
  } catch (error) {
    updateStats('errors');
    throw error;
  }
}

ポイント:適切なログとモニタリングにより、Service Worker の動作を把握し、問題を早期に発見できます。

まとめ

Service Worker を活用した PWA の運用について、オフライン対応、更新管理、キャッシュ戦略の実装方法を解説してきました。

重要なポイントをまとめますと、まずキャッシュ戦略の適切な選択が成功の鍵となります。静的アセットには Cache First、API データには Network First、HTML ページには Stale While Revalidate を使い分けることで、パフォーマンスとデータの鮮度を両立できるのです。

次に、ユーザーフレンドリーな更新メカニズムの実装が重要でしょう。新しいバージョンが利用可能になったときに通知し、ユーザーが適切なタイミングで更新を適用できるようにすることで、UX を損なわずにアプリケーションを最新の状態に保てます。

さらに、適切なエラーハンドリングとフォールバックにより、オフライン時でも基本的な機能を提供できることも大切ですね。ユーザーがネットワーク接続を失っても、キャッシュされたコンテンツやフォールバックページを表示することで、アプリケーションの継続性を維持できます。

最後に、継続的なモニタリングと最適化を行うことで、Service Worker のパフォーマンスを維持・改善できます。キャッシュヒット率の追跡、ログの記録、定期的なキャッシュクリーンアップなどを実施しましょう。

Service Worker は強力な技術ですが、適切な設計と運用が必要です。本記事で紹介したパターンとベストプラクティスを参考に、ユーザーにとって快適なオフライン対応アプリケーションを構築してください。

関連リンク