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

中級 | 40分 で読める | 2025.12.02

公式ドキュメント

今回やること

この記事では、Web App Manifest、Service Worker、オフライン対応、PWAとして確認すべき項目を順番に扱います。

PWAはファイルを追加するだけでは完成しません。インストール可能性、HTTPS、キャッシュ更新、オフライン時の表示まで確認する必要があります。

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はブラウザとサーバーの間に位置するプロキシです。ネットワークリクエストを傍受してキャッシュから返すことで、オフラインでも動作するアプリが実現できます。

基本的な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();
 }
}

実践メモ: Service Workerの登録時にupdatefoundイベントを監視することで、ユーザーに新バージョンの通知を出し、スムーズなアップデート体験を提供できます。

キャッシュ戦略

戦略フロー用途
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;
}

プッシュ通知

注意: VAPID鍵は一度生成したら安全に保管してください。鍵を紛失すると、既存の購読者へのプッシュ通知が送信できなくなります。

サーバー側(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を使うと、キャッシュ戦略の実装を大幅に簡素化できます。

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環境でのみ動作します(localhost除く)。Service Workerの登録は、セキュアなコンテキストが必須です

PWAチェックリスト

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

動作確認

PWA対応後は、Chrome DevToolsのApplicationタブで確認します。

確認項目見る場所OKの状態
ManifestApplication > Manifestアイコン、名前、start_urlが表示される
Service WorkerApplication > Service Workersregistered / activated になる
Cache StorageApplication > Cache Storage意図したファイルが保存される
OfflineNetwork offlineフォールバック画面が表示される
LighthousePWA監査重大な設定漏れがない

キャッシュが更新されない時は、Service Workerのバージョン更新と古いキャッシュの削除処理を確認します。

練習

次を学習用サイトで実装してください。

  1. manifest.json を追加する
  2. 192px以上のアイコンを設定する
  3. Service Workerを登録する
  4. オフライン用HTMLを返す
  5. DevToolsでオフライン表示を確認する

次のステップ

デプロイ環境でHTTPSを確認する場合は Vercelデプロイ入門 が役立ちます。パフォーマンス観点は Core Web VitalsLighthouseスコアの読み方 を確認してください。

まとめ

この記事では、手順を追って動かしながら重要な確認ポイントを押さえることが大切です。まずは学習用の最小構成で動作確認し、本番利用する前にセキュリティ、権限、エラー処理、運用手順を見直してください。

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR