mobile

PWA実装ガイド - Progressive Web Appsでネイティブ体験を実現

2025.12.02

PWAとは

PWA(Progressive Web App)は、Webアプリケーションにネイティブアプリのような体験を提供する技術です。オフライン動作、プッシュ通知、ホーム画面へのインストールが可能になります。

特徴機能
インストール可能ホーム画面にアイコンを追加、アプリストア不要、フルスクリーン表示
オフライン対応Service Workerによるキャッシュ、ネットワークなしで動作、バックグラウンド同期
プッシュ通知ブラウザ閉じても通知受信、エンゲージメント向上、リエンゲージメント

Web App Manifest

// public/manifest.json
{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "An example Progressive Web App",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "orientation": "portrait-primary",
  "scope": "/",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "shortcuts": [
    {
      "name": "新規作成",
      "short_name": "作成",
      "description": "新しいアイテムを作成",
      "url": "/new",
      "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ]
}
<!-- index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My PWA</title>

  <!-- PWA必須タグ -->
  <link rel="manifest" href="/manifest.json">
  <meta name="theme-color" content="#3b82f6">

  <!-- iOS Safari対応 -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="default">
  <meta name="apple-mobile-web-app-title" content="MyPWA">
  <link rel="apple-touch-icon" href="/icons/icon-192x192.png">

  <!-- Windows対応 -->
  <meta name="msapplication-TileColor" content="#3b82f6">
  <meta name="msapplication-TileImage" content="/icons/icon-144x144.png">
</head>
<body>
  <div id="app"></div>
  <script src="/app.js" type="module"></script>
</body>
</html>

Service Worker

基本的なService Worker

// public/sw.js
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/app.js',
  '/styles.css',
  '/icons/icon-192x192.png',
  '/offline.html',
];

// インストール時にキャッシュ
self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('Caching static assets');
      return cache.addAll(STATIC_ASSETS);
    })
  );
  // 即座にアクティブ化
  self.skipWaiting();
});

// アクティベート時に古いキャッシュを削除
self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
  // 全クライアントを即座に制御
  self.clients.claim();
});

// フェッチ戦略
self.addEventListener('fetch', (event: FetchEvent) => {
  const { request } = event;

  // APIリクエストはネットワークファースト
  if (request.url.includes('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // 静的アセットはキャッシュファースト
  event.respondWith(cacheFirst(request));
});

// キャッシュファースト戦略
async function cacheFirst(request: Request): Promise<Response> {
  const cached = await caches.match(request);
  if (cached) {
    return cached;
  }

  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    // オフラインフォールバック
    const offlinePage = await caches.match('/offline.html');
    return offlinePage || new Response('Offline', { status: 503 });
  }
}

// ネットワークファースト戦略
async function networkFirst(request: Request): Promise<Response> {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) {
      return cached;
    }
    return new Response(JSON.stringify({ error: 'Offline' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

Service Worker登録

// src/registerSW.ts
export async function registerServiceWorker(): Promise<void> {
  if (!('serviceWorker' in navigator)) {
    console.log('Service Worker not supported');
    return;
  }

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

    console.log('Service Worker registered:', registration.scope);

    // 更新チェック
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      if (!newWorker) return;

      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // 新しいバージョンが利用可能
          showUpdateNotification();
        }
      });
    });

    // 定期的に更新をチェック
    setInterval(() => {
      registration.update();
    }, 60 * 60 * 1000); // 1時間ごと

  } catch (error) {
    console.error('Service Worker registration failed:', error);
  }
}

function showUpdateNotification(): void {
  if (confirm('新しいバージョンが利用可能です。更新しますか?')) {
    window.location.reload();
  }
}

キャッシュ戦略

戦略フロー用途
Cache FirstRequest → Cache → (miss) → Network → Cache静的アセット、変更頻度が低いコンテンツ
Network FirstRequest → Network → (fail) → CacheAPIリクエスト、最新データが必要な場合
Stale While RevalidateRequest → Cache (即座に返却) + Network更新ユーザーアバター、設定データ
Cache OnlyRequest → Cache のみオフライン専用コンテンツ
Network OnlyRequest → Network のみ認証、決済、リアルタイムデータ

Stale While Revalidate実装

// sw.js
async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);

  // バックグラウンドで更新
  const fetchPromise = fetch(request).then((response) => {
    cache.put(request, response.clone());
    return response;
  });

  // キャッシュがあれば即座に返す、なければネットワークを待つ
  return cached || fetchPromise;
}

プッシュ通知

サーバー側(Node.js)

// server/push.ts
import webpush from 'web-push';

// VAPID鍵の生成(初回のみ)
// const vapidKeys = webpush.generateVAPIDKeys();

const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY!;
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY!;

webpush.setVapidDetails(
  'mailto:admin@example.com',
  VAPID_PUBLIC_KEY,
  VAPID_PRIVATE_KEY
);

interface PushSubscription {
  endpoint: string;
  keys: {
    p256dh: string;
    auth: string;
  };
}

export async function sendPushNotification(
  subscription: PushSubscription,
  payload: {
    title: string;
    body: string;
    icon?: string;
    badge?: string;
    url?: string;
  }
): Promise<void> {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify(payload)
    );
    console.log('Push notification sent');
  } catch (error: any) {
    if (error.statusCode === 410) {
      // 購読が無効化された
      console.log('Subscription expired');
      // DBから削除
    }
    throw error;
  }
}

クライアント側

// src/pushNotifications.ts
const VAPID_PUBLIC_KEY = 'YOUR_VAPID_PUBLIC_KEY';

export async function subscribeToPush(): Promise<PushSubscription | null> {
  if (!('PushManager' in window)) {
    console.log('Push notifications not supported');
    return null;
  }

  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.log('Notification permission denied');
    return null;
  }

  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

  // サーバーに購読情報を送信
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}

Service Workerでの受信

// sw.js
self.addEventListener('push', (event: PushEvent) => {
  const data = event.data?.json() ?? {
    title: '新着通知',
    body: '新しいメッセージがあります',
  };

  const options: NotificationOptions = {
    body: data.body,
    icon: data.icon || '/icons/icon-192x192.png',
    badge: data.badge || '/icons/badge-72x72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/',
    },
    actions: [
      { action: 'open', title: '開く' },
      { action: 'close', title: '閉じる' },
    ],
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

self.addEventListener('notificationclick', (event: NotificationEvent) => {
  event.notification.close();

  if (event.action === 'close') {
    return;
  }

  const url = event.notification.data?.url || '/';

  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((windowClients) => {
      // 既存のウィンドウがあればフォーカス
      for (const client of windowClients) {
        if (client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      // なければ新しいウィンドウを開く
      return clients.openWindow(url);
    })
  );
});

インストールプロンプト

// src/installPrompt.ts
let deferredPrompt: BeforeInstallPromptEvent | null = null;

interface BeforeInstallPromptEvent extends Event {
  prompt(): Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

window.addEventListener('beforeinstallprompt', (e: Event) => {
  // デフォルトのプロンプトを防止
  e.preventDefault();
  deferredPrompt = e as BeforeInstallPromptEvent;

  // カスタムインストールボタンを表示
  showInstallButton();
});

function showInstallButton(): void {
  const installButton = document.getElementById('install-button');
  if (installButton) {
    installButton.style.display = 'block';
    installButton.addEventListener('click', handleInstallClick);
  }
}

async function handleInstallClick(): Promise<void> {
  if (!deferredPrompt) return;

  // インストールプロンプトを表示
  deferredPrompt.prompt();

  const { outcome } = await deferredPrompt.userChoice;
  console.log(`User ${outcome} the install prompt`);

  deferredPrompt = null;
  hideInstallButton();
}

function hideInstallButton(): void {
  const installButton = document.getElementById('install-button');
  if (installButton) {
    installButton.style.display = 'none';
  }
}

// インストール完了検知
window.addEventListener('appinstalled', () => {
  console.log('PWA was installed');
  hideInstallButton();
});

バックグラウンド同期

// sw.js
self.addEventListener('sync', (event: SyncEvent) => {
  if (event.tag === 'sync-messages') {
    event.waitUntil(syncMessages());
  }
});

async function syncMessages(): Promise<void> {
  // IndexedDBからオフライン時に保存したデータを取得
  const pendingMessages = await getPendingMessages();

  for (const message of pendingMessages) {
    try {
      await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message),
      });
      await removePendingMessage(message.id);
    } catch (error) {
      console.error('Sync failed:', error);
      throw error; // リトライさせる
    }
  }
}

// クライアント側での同期登録
async function registerBackgroundSync(): Promise<void> {
  const registration = await navigator.serviceWorker.ready;

  if ('sync' in registration) {
    await registration.sync.register('sync-messages');
    console.log('Background sync registered');
  }
}

Workbox(Service Worker簡易化ライブラリ)

// sw.ts (Workboxを使用)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// ビルド時に生成されるプリキャッシュマニフェスト
precacheAndRoute(self.__WB_MANIFEST);

// 画像のキャッシュ
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30日
      }),
    ],
  })
);

// APIリクエスト
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api',
    networkTimeoutSeconds: 10,
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 24 * 60 * 60, // 1日
      }),
    ],
  })
);

// Google Fontsのキャッシュ
registerRoute(
  ({ url }) => url.origin === 'https://fonts.googleapis.com',
  new StaleWhileRevalidate({
    cacheName: 'google-fonts-stylesheets',
  })
);

PWAチェックリスト

カテゴリチェック項目
必須HTTPS対応
必須Service Worker登録
必須Web App Manifest
推奨オフラインフォールバック
推奨192x192以上のアイコン
推奨テーマカラー設定
オプションプッシュ通知
オプションバックグラウンド同期

参考リンク

← 一覧に戻る