PWAとは
PWA(Progressive Web App)は、Webアプリケーションにネイティブアプリのような体験を提供する技術です。オフライン動作、プッシュ通知、ホーム画面へのインストールが可能になります。
| 特徴 | 機能 |
|---|
| インストール可能 | ホーム画面にアイコンを追加、アプリストア不要、フルスクリーン表示 |
| オフライン対応 | Service Workerによるキャッシュ、ネットワークなしで動作、バックグラウンド同期 |
| プッシュ通知 | ブラウザ閉じても通知受信、エンゲージメント向上、リエンゲージメント |
Web App Manifest
{
"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"
}
]
}
<!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>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#3b82f6">
<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">
<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
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;
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登録
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);
} catch (error) {
console.error('Service Worker registration failed:', error);
}
}
function showUpdateNotification(): void {
if (confirm('新しいバージョンが利用可能です。更新しますか?')) {
window.location.reload();
}
}
キャッシュ戦略
| 戦略 | フロー | 用途 |
|---|
| Cache First | Request → Cache → (miss) → Network → Cache | 静的アセット、変更頻度が低いコンテンツ |
| Network First | Request → Network → (fail) → Cache | APIリクエスト、最新データが必要な場合 |
| Stale While Revalidate | Request → Cache (即座に返却) + Network更新 | ユーザーアバター、設定データ |
| Cache Only | Request → Cache のみ | オフライン専用コンテンツ |
| Network Only | Request → Network のみ | 認証、決済、リアルタイムデータ |
Stale While Revalidate実装
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)
import webpush from 'web-push';
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');
}
throw error;
}
}
クライアント側
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での受信
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);
})
);
});
インストールプロンプト
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();
});
バックグラウンド同期
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncMessages());
}
});
async function syncMessages(): Promise<void> {
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簡易化ライブラリ)
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,
}),
],
})
);
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,
}),
],
})
);
registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com',
new StaleWhileRevalidate({
cacheName: 'google-fonts-stylesheets',
})
);
PWAチェックリスト
| カテゴリ | チェック項目 |
|---|
| 必須 | HTTPS対応 |
| 必須 | Service Worker登録 |
| 必須 | Web App Manifest |
| 推奨 | オフラインフォールバック |
| 推奨 | 192x192以上のアイコン |
| 推奨 | テーマカラー設定 |
| オプション | プッシュ通知 |
| オプション | バックグラウンド同期 |
参考リンク
← 一覧に戻る