- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに
- 1. PWAとは何(なに)か?
- 2. Service Workerのライフサイクル
- 3. 5つのキャッシング戦略(せんりゃく)
- 4. Web App Manifest
- 5. WorkboxによるService Worker
- 6. プッシュ通知(つうち)
- 7. オフラインファーストアーキテクチャ
- 8. Next.js PWA統合(とうごう)
- 9. アプリストア配布(はいふ)
- 10. PWAパフォーマンス最適化(さいてきか)
- 11. 実践(じっせん)クイズ
- 12. 参考資料(さんこうしりょう)
はじめに
2025年(ねん)、PWA(Progressive Web App)はもはや実験的(じっけんてき)な技術(ぎじゅつ)ではありません。Twitter(X)、Starbucks、Pinterest、Spotifyなど多数(たすう)の大手(おおて)サービスがPWAを導入(どうにゅう)し、ネイティブアプリに匹敵(ひってき)するユーザー体験(たいけん)を提供(ていきょう)しています。Google PlayストアとMicrosoftストアではPWAを直接(ちょくせつ)登録(とうろく)でき、iOSもPWAサポートを段階的(だんかいてき)に拡大(かくだい)しています。
このガイドでは、Service Workerのライフサイクルから5つのキャッシング戦略(せんりゃく)、Workboxの活用(かつよう)、プッシュ通知(つうち)、オフラインファーストアーキテクチャ、Next.js PWA統合(とうごう)、そしてアプリストア配布(はいふ)まで体系的(たいけいてき)にカバーします。
キーフレーズ整理(せいり)
表現(ひょうげん) 意味(いみ) Service Worker ブラウザバックグラウンドで実行(じっこう)されるJavaScriptプロキシ。ネットワークリクエストの傍受(ぼうじゅ)、キャッシング、プッシュ通知処理(つうちしょり) Cache-First Strategy キャッシュから先(さき)に応答(おうとう)し、なければネットワークから取得(しゅとく)する戦略(せんりゃく)。オフラインサポートの核心(かくしん) Web App Manifest PWAのメタデータを定義(ていぎ)するJSONファイル。アプリ名(めい)、アイコン、開始(かいし)URL、ディスプレイモードなど Workbox Googleが作(つく)ったService Workerライブラリ。キャッシング戦略(せんりゃく)、プリキャッシング、ルーティングを簡単(かんたん)に実装(じっそう)
1. PWAとは何(なに)か?
1.1 PWAの核心的特性(かくしんてきとくせい)
PWAは、Web技術(ぎじゅつ)で構築(こうちく)されながらもネイティブアプリのような体験(たいけん)を提供(ていきょう)するアプリケーションです。
PWAの3つの核心要素:
┌─────────────────────────────────────────┐
│ Progressive Web App │
├─────────────┬─────────────┬─────────────┤
│ Reliable │ Fast │ Engaging │
│ (信頼性) │ (高速) │ (没入感) │
│ │ │ │
│ - オフライン│ - 即時ロード│ - ホーム画面│
│ - 安定した │ - スムーズ │ - 全画面 │
│ ネットワーク│ スクロール│ - プッシュ │
│ │ - 60fps │ - インストール│
└─────────────┴─────────────┴─────────────┘
1.2 PWA vs ネイティブアプリ vs 通常(つうじょう)のWeb
| 機能(きのう) | 通常(つうじょう)のWeb | PWA | ネイティブアプリ |
|---|---|---|---|
| インストール | 不要(ふよう) | 任意(にんい) | 必須(ひっす) |
| オフラインサポート | なし | あり | あり |
| プッシュ通知(つうち) | なし | あり | あり |
| アプリストア | 不可(ふか) | 可能(かのう)(TWA/PWABuilder) | デフォルト |
| アップデート | 即時(そくじ) | 即時(そくじ)/バックグラウンド | ストア審査(しんさ)が必要(ひつよう) |
| ファイルサイズ | 0(URL接続(せつぞく)) | 数(すう)KB~数(すう)MB | 数十(すうじゅう)~数百(すうひゃく)MB |
| ハードウェアアクセス | 限定的(げんていてき) | 拡大中(かくだいちゅう) | 完全(かんぜん) |
| 開発(かいはつ)コスト | 低(ひく)い | 低(ひく)い | 高(たか)い(プラットフォーム別(べつ)) |
| SEO | 優秀(ゆうしゅう) | 優秀(ゆうしゅう) | 不可(ふか) |
1.3 PWA導入成果(どうにゅうせいか)事例(じれい)
主要企業のPWA成果:
Twitter Lite:
- ページあたりのデータ消費70%削減
- ツイート送信75%増加
- 直帰率20%減少
Pinterest:
- 広告収益44%増加
- コアエンゲージメント指標60%増加
- ログインユーザー40%増加
Starbucks:
- ネイティブアプリ比99.84%小さいサイズ
- 日次アクティブユーザー2倍増加
Uber:
- 3Gで3秒以内にロード
- コアアプリサイズ50KB
2. Service Workerのライフサイクル
2.1 Service Workerとは?
Service Workerは、ブラウザとネットワークの間(あいだ)で動作(どうさ)するプロキシスクリプトです。メインスレッドとは別(べつ)のスレッドで実行(じっこう)され、DOMに直接(ちょくせつ)アクセスできません。
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Web App │────▶│Service Worker│────▶│ Network │
│ (Main │ │ (Proxy) │ │ (Server) │
│ Thread) │◀────│ │◀────│ │
└─────────────┘ │ ┌──────┐ │ └─────────────┘
│ │Cache │ │
│ │ API │ │
│ └──────┘ │
└──────────────┘
2.2 ライフサイクルの段階(だんかい)
Service Workerライフサイクル:
1. Registration(登録)
└─▶ navigator.serviceWorker.register('/sw.js')
2. Installation(インストール)
└─▶ 'install'イベント発生
└─▶ 静的リソースのプリキャッシング
3. Waiting(待機)
└─▶ 前のSWがアクティブなら待機
4. Activation(アクティベーション)
└─▶ 'activate'イベント発生
└─▶ 古いキャッシュの削除
5. Fetch(リクエスト傍受)
└─▶ 'fetch'イベントでネットワークリクエスト制御
6. Idle / Terminated(アイドル/終了)
└─▶ イベントがなければブラウザが終了可能
2.3 基本的(きほんてき)なService Worker実装(じっそう)
// sw.js - Service Workerファイル
const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png',
'/offline.html',
];
// Installイベント: 静的リソースのプリキャッシング
self.addEventListener('install', (event) => {
console.log('[SW] Installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('[SW] Pre-caching static assets');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting())
);
});
// Activateイベント: 古いキャッシュの削除
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => self.clients.claim())
);
});
// Fetchイベント: リクエストの傍受
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request);
})
.catch(() => {
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
})
);
});
2.4 登録(とうろく)コード
// main.js - アプリエントリポイント
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('SW registered:', registration.scope);
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
showUpdateNotification();
}
}
});
});
} catch (error) {
console.error('SW registration failed:', error);
}
});
}
function showUpdateNotification() {
const banner = document.createElement('div');
banner.innerHTML = '新しいバージョンがあります。<button id="reload">更新</button>';
banner.className = 'update-banner';
document.body.appendChild(banner);
document.getElementById('reload').addEventListener('click', () => {
window.location.reload();
});
}
3. 5つのキャッシング戦略(せんりゃく)
3.1 Cache First(キャッシュ優先(ゆうせん))
キャッシュにあればキャッシュから、なければネットワークから取得(しゅとく)します。静的(せいてき)リソースに最適(さいてき)です。
// Cache First戦略
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
});
})
);
});
リクエストフロー:
Client ──▶ Cache ──(hit)──▶ レスポンス返却
│
(miss)
│
▼
Network ──▶ レスポンス返却 + キャッシュ保存
3.2 Network First(ネットワーク優先(ゆうせん))
ネットワークを先(さき)に試(ため)し、失敗(しっぱい)したらキャッシュから取得(しゅとく)します。APIデータに適(てき)しています。
// Network First戦略
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => caches.match(event.request))
);
});
3.3 Stale While Revalidate(古(ふる)いものを使用後(しようご)に更新(こうしん))
キャッシュから即座(そくざ)に応答(おうとう)しつつ、バックグラウンドでネットワークからキャッシュを更新(こうしん)します。
// Stale While Revalidate戦略
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return cached || fetchPromise;
});
})
);
});
3.4 Cache Only / Network Only
// Cache Only - プリキャッシュされたリソースのみ
self.addEventListener('fetch', (event) => {
event.respondWith(caches.match(event.request));
});
// Network Only - キャッシュ不要なリクエスト(例:分析データ)
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
3.5 戦略選択(せんりゃくせんたく)ガイド
| 戦略(せんりゃく) | ユースケース | メリット | デメリット |
|---|---|---|---|
| Cache First | 静的リソース(CSS, JS, 画像) | 高速応答、オフライン | 更新遅延 |
| Network First | APIデータ、ニュースフィード | 常に最新 | 低速ネットワークで遅延 |
| Stale While Revalidate | 頻繁に変わる静的リソース | 高速かつ最新 | 最初のリクエストは古いデータ |
| Cache Only | バージョン管理されたリソース | 完全オフライン | 更新不可 |
| Network Only | 決済、認証 | 常に最新 | オフライン不可 |
4. Web App Manifest
4.1 Manifestの作成(さくせい)
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "An awesome PWA that works offline",
"start_url": "/?source=pwa",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"orientation": "portrait-primary",
"scope": "/",
"lang": "ja",
"dir": "ltr",
"categories": ["productivity", "utilities"],
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Desktop view"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile view"
}
],
"shortcuts": [
{
"name": "New Task",
"short_name": "Task",
"url": "/tasks/new",
"icons": [{ "src": "/icons/task.png", "sizes": "96x96" }]
}
]
}
4.2 HTMLでのManifestリンク
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Manifestリンク -->
<link rel="manifest" href="/manifest.json">
<!-- iOSサポート -->
<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-152x152.png">
<!-- テーマカラー -->
<meta name="theme-color" content="#2196F3">
<title>My PWA</title>
</head>
<body>
<!-- アプリコンテンツ -->
</body>
</html>
4.3 Displayモード比較(ひかく)
| モード | 説明(せつめい) | ブラウザUI |
|---|---|---|
fullscreen | 全画面(ぜんがめん)、ステータスバー非表示(ひひょうじ) | なし |
standalone | 独立(どくりつ)アプリとして表示(ひょうじ) | ステータスバーのみ |
minimal-ui | 最小限(さいしょうげん)のブラウザUI | 戻(もど)る/更新(こうしん)ボタン |
browser | 通常(つうじょう)のブラウザタブ | 完全(かんぜん)なブラウザUI |
5. WorkboxによるService Worker
5.1 Workboxの紹介(しょうかい)
WorkboxはGoogleが作(つく)ったService Workerライブラリで、キャッシング戦略(せんりゃく)を宣言的(せんげんてき)に実装(じっそう)できます。
# Workbox CLIインストール
npm install workbox-cli --save-dev
# またはWebpackプラグイン
npm install workbox-webpack-plugin --save-dev
5.2 Workboxでキャッシング戦略(せんりゃく)を実装(じっそう)
// sw.js with 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);
// 画像: Cache First + 有効期限設定
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images-cache',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30日
}),
],
})
);
// CSS/JS: Stale While Revalidate
registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script',
new StaleWhileRevalidate({
cacheName: 'static-resources',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
})
);
// API呼び出し: Network First
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5分
}),
],
})
);
// Google Fonts: Cache First
registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com' ||
url.origin === 'https://fonts.gstatic.com',
new CacheFirst({
cacheName: 'google-fonts',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1年
}),
],
})
);
5.3 バックグラウンド同期(どうき)
// Background Syncでオフラインフォーム送信
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {
maxRetentionTime: 24 * 60, // 24時間(分単位)
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request);
console.log('[BG Sync] Replayed:', entry.request.url);
} catch (error) {
console.error('[BG Sync] Failed:', error);
await queue.unshiftRequest(entry);
throw error;
}
}
},
});
// POSTリクエストにBackground Syncを適用
registerRoute(
({ url, request }) =>
url.pathname === '/api/submit' && request.method === 'POST',
new NetworkOnly({
plugins: [bgSyncPlugin],
}),
'POST'
);
6. プッシュ通知(つうち)
6.1 プッシュ通知(つうち)アーキテクチャ
プッシュ通知フロー:
┌──────────┐ サブスクライブ ┌──────────────┐
│ Client │──────────────▶│ Push Server │
│ (Browser)│ │ (FCM/APNS) │
└────┬─────┘ └──────┬───────┘
│ サブスクリプション情報 │
│ 送信 │
▼ │
┌──────────┐ プッシュ送信 ◀───┘
│ App │
│ Server │
└──────────┘
6.2 サブスクリプション実装(じっそう)
// クライアント: プッシュ通知のサブスクライブ
async function subscribePush() {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission denied');
return;
}
const registration = await navigator.serviceWorker.ready;
const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
const convertedKey = urlBase64ToUint8Array(vapidPublicKey);
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedKey,
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
console.log('Push subscription successful');
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
6.3 サーバーからプッシュ送信(そうしん)
// Node.jsサーバー: web-pushライブラリ
const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:admin@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
async function sendPushNotification(subscription, payload) {
try {
await webpush.sendNotification(
subscription,
JSON.stringify({
title: '新しいメッセージ',
body: '田中さんがメッセージを送りました。',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
data: {
url: '/messages/123',
timestamp: Date.now(),
},
actions: [
{ action: 'reply', title: '返信', icon: '/icons/reply.png' },
{ action: 'dismiss', title: '閉じる', icon: '/icons/close.png' },
],
})
);
console.log('Push sent successfully');
} catch (error) {
if (error.statusCode === 410) {
await removeSubscription(subscription.endpoint);
}
console.error('Push failed:', error);
}
}
6.4 Service Workerでのプッシュ受信(じゅしん)
// sw.js: プッシュイベント処理
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
const options = {
body: data.body || 'New notification',
icon: data.icon || '/icons/icon-192x192.png',
badge: data.badge || '/icons/badge-72x72.png',
vibrate: [200, 100, 200],
data: data.data || {},
actions: data.actions || [],
tag: data.tag || 'default',
renotify: true,
};
event.waitUntil(
self.registration.showNotification(data.title || 'Notification', options)
);
});
// 通知クリック処理
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const urlToOpen = event.notification.data.url || '/';
if (event.action === 'reply') {
event.waitUntil(clients.openWindow(urlToOpen + '?action=reply'));
} else {
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
return clients.openWindow(urlToOpen);
})
);
}
});
7. オフラインファーストアーキテクチャ
7.1 IndexedDBを活用(かつよう)したローカルデータ保存(ほぞん)
// IndexedDBラッパー(idbライブラリ使用)
import { openDB } from 'idb';
class LocalStore {
constructor(dbName, storeName) {
this.dbName = dbName;
this.storeName = storeName;
}
async getDB() {
return openDB(this.dbName, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('tasks')) {
const store = db.createObjectStore('tasks', {
keyPath: 'id',
autoIncrement: true,
});
store.createIndex('status', 'status');
store.createIndex('syncStatus', 'syncStatus');
store.createIndex('updatedAt', 'updatedAt');
}
},
});
}
async getAll() {
const db = await this.getDB();
return db.getAll(this.storeName);
}
async add(item) {
const db = await this.getDB();
item.syncStatus = 'pending';
item.updatedAt = Date.now();
return db.add(this.storeName, item);
}
async update(item) {
const db = await this.getDB();
item.syncStatus = 'pending';
item.updatedAt = Date.now();
return db.put(this.storeName, item);
}
async getPendingSync() {
const db = await this.getDB();
const tx = db.transaction(this.storeName, 'readonly');
const index = tx.store.index('syncStatus');
return index.getAll('pending');
}
}
const taskStore = new LocalStore('myApp', 'tasks');
await taskStore.add({
title: '新しいタスク',
status: 'active',
createdAt: Date.now(),
});
7.2 同期(どうき)戦略(せんりゃく)
// オンライン復帰時の同期
class SyncManager {
constructor(localStore, apiUrl) {
this.localStore = localStore;
this.apiUrl = apiUrl;
this.setupListeners();
}
setupListeners() {
window.addEventListener('online', () => this.sync());
}
async sync() {
const pendingItems = await this.localStore.getPendingSync();
for (const item of pendingItems) {
try {
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
});
if (response.ok) {
item.syncStatus = 'synced';
await this.localStore.update(item);
}
} catch (error) {
console.error('Sync failed for item:', item.id, error);
}
}
}
}
8. Next.js PWA統合(とうごう)
8.1 next-pwaセットアップ
npm install @ducanh2912/next-pwa
// next.config.js
const withPWA = require('@ducanh2912/next-pwa').default({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
cacheOnFrontEndNav: true,
workboxOptions: {
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60,
},
},
},
{
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-image-assets',
expiration: {
maxEntries: 64,
maxAgeSeconds: 24 * 60 * 60,
},
},
},
{
urlPattern: /\/api\/.*$/i,
handler: 'NetworkFirst',
options: {
cacheName: 'apis',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 16,
maxAgeSeconds: 24 * 60 * 60,
},
},
},
],
},
});
module.exports = withPWA({
reactStrictMode: true,
});
8.2 App RouterでのManifest
// app/manifest.ts (Next.js App Router)
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'My Next.js PWA',
short_name: 'NextPWA',
description: 'A Progressive Web App built with Next.js',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
};
}
9. アプリストア配布(はいふ)
9.1 Google Playストア(TWA)
Trusted Web Activity(TWA)を使用(しよう)して、PWAをGoogle Playストアに配布(はいふ)できます。
# BubblewrapでTWA作成
npx @nicolo-ribaudo/bubblewrap init --manifest https://myapp.com/manifest.json
npx @nicolo-ribaudo/bubblewrap build
Google Play配布の手順:
1. Digital Asset Linksの設定
- /.well-known/assetlinks.jsonファイルを作成
- アプリ署名証明書のSHA-256フィンガープリントを含める
2. TWAプロジェクト作成
- BubblewrapまたはPWABuilderを使用
- Androidプロジェクトを自動生成
3. 署名とビルド
- Android keystoreを生成
- APKまたはAABをビルド
4. Play Consoleにアップロード
- 内部/非公開/本番トラックを選択
- ストアリスティングを作成
9.2 Microsoftストア(PWABuilder)
Microsoftストア配布の手順:
1. PWABuilder (https://www.pwabuilder.com/) にアクセス
2. PWAのURLを入力
3. スコアを確認しパッケージをダウンロード
4. MSIXパッケージを生成
5. Partner Centerでアプリを登録
6. MSIXパッケージをアップロード
9.3 iOS(Safariサポート)
iOS PWAの制限事項(2025年基準):
サポート:
- ホーム画面への追加
- オフラインキャッシング
- Web App Manifest(一部)
- 基本プッシュ通知(iOS 16.4+)
制限:
- バックグラウンド同期は未サポート
- 50MBストレージ制限
- サードパーティブラウザからのPWAインストール不可
- 一部Web APIは未サポート
10. PWAパフォーマンス最適化(さいてきか)
10.1 Lighthouse PWAチェックリスト
Lighthouse PWAチェックリスト:
[必須]
- HTTPSを使用
- Service Workerが登録済み
- オフラインで200レスポンス
- Web App Manifestが存在
- start_urlが設定済み
- アイコン(192px、512px)
- viewportメタタグ
- theme-colorメタタグ
[推奨]
- HTTPをHTTPSにリダイレクト
- 3Gで高速ロード(10秒以内)
- カスタムスプラッシュスクリーン
- アドレスバーのテーマカラー
- ビューポートに合ったコンテンツ
- apple-touch-iconの設定
10.2 キャッシュサイズ管理(かんり)
// キャッシュサイズの監視
async function getCacheSize() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
const usedMB = (estimate.usage / (1024 * 1024)).toFixed(2);
const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(2);
console.log('Storage used:', usedMB, 'MB of', quotaMB, 'MB');
return estimate;
}
}
// 永続ストレージのリクエスト
async function requestPersistentStorage() {
if (navigator.storage && navigator.storage.persist) {
const granted = await navigator.storage.persist();
console.log('Persistent storage:', granted ? 'granted' : 'denied');
}
}
10.3 インストールプロンプトのカスタマイズ
// インストールプロンプトの制御
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallButton();
});
function showInstallButton() {
const installBtn = document.getElementById('install-btn');
installBtn.style.display = 'block';
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const result = await deferredPrompt.userChoice;
console.log('Install prompt result:', result.outcome);
deferredPrompt = null;
installBtn.style.display = 'none';
});
}
// インストール完了の検出
window.addEventListener('appinstalled', () => {
console.log('PWA was installed');
deferredPrompt = null;
analytics.track('pwa_installed');
});
11. 実践(じっせん)クイズ
各(かく)問題(もんだい)を解(と)いて答(こた)えを確認(かくにん)しましょう。
Q1. Service Workerのライフサイクル段階(だんかい)を順番(じゅんばん)に挙(あ)げてください。
Registration - Installation - Waiting - Activation - Fetch(Idle/Terminated)
登録(とうろく)(Registration)後(ご)、インストール(Installation)で静的(せいてき)リソースをプリキャッシングし、前(まえ)のSWがあれば待機(たいき)(Waiting)状態(じょうたい)に入(はい)ります。アクティベーション(Activation)で古(ふる)いキャッシュを削除(さくじょ)し、その後(ご)Fetchイベントでネットワークリクエストを制御(せいぎょ)します。
Q2. Cache-First戦略(せんりゃく)が最(もっと)も適(てき)したリソースタイプは?
静的(せいてき)リソース(CSS、JSバンドル、画像(がぞう)、フォント)
変更頻度(へんこうひんど)が低(ひく)く、バージョン管理(かんり)されているリソースに適(てき)しています。キャッシュから即座(そくざ)に応答(おうとう)するため高速(こうそく)で、オフラインでも動作(どうさ)します。APIデータのように頻繁(ひんぱん)に変(か)わるデータにはNetwork FirstやStale While Revalidateが適(てき)しています。
Q3. Web App Manifestでdisplay: standaloneの意味(いみ)は?
ブラウザのアドレスバーなしで独立(どくりつ)アプリとして表示(ひょうじ)されるモード
standaloneモードではステータスバーのみ表示(ひょうじ)され、通常(つうじょう)のアプリのように見(み)えます。fullscreenはステータスバーもなく、minimal-uiは最小限(さいしょうげん)のブラウザUI(戻(もど)るボタンなど)が表示(ひょうじ)され、browserは通常(つうじょう)のブラウザタブで開(ひら)きます。
Q4. WorkboxのBackgroundSyncPluginの用途(ようと)は?
オフライン状態(じょうたい)で失敗(しっぱい)したネットワークリクエストをキューに保存(ほぞん)し、オンライン復帰時(ふっきじ)に自動(じどう)リトライすること
例(たと)えば、オフライン状態(じょうたい)でフォーム送信(そうしん)をすると、リクエストをIndexedDBに保存(ほぞん)しておき、ネットワークが復旧(ふっきゅう)したら自動的(じどうてき)にサーバーに送信(そうしん)します。これにより、オフラインでもデータ損失(そんしつ)なく作業(さぎょう)できます。
Q5. PWAをGoogle Playストアに配布(はいふ)するための技術(ぎじゅつ)は?
TWA(Trusted Web Activity)
TWAはChrome Custom Tabsをベースに、PWAをネイティブAndroidアプリとしてラップします。Digital Asset Linksファイル(assetlinks.json)を通(つう)じてアプリとWebサイト間(かん)の所有権(しょゆうけん)を証明(しょうめい)します。BubblewrapやPWABuilderなどのツールでTWAプロジェクトを簡単(かんたん)に作成(さくせい)できます。
12. 参考資料(さんこうしりょう)
- web.dev PWAガイド - GoogleのPWA公式(こうしき)ガイド
- MDN Service Worker API - Service Worker APIドキュメント
- Workbox公式(こうしき)ドキュメント - Workboxライブラリドキュメント
- PWABuilder - PWAビルドおよびデプロイツール
- Web Push Protocol - Webプッシュ通知(つうち)の概要(がいよう)
- Lighthouse - PWA監査(かんさ)ツール
- next-pwa - Next.js PWAプラグイン
- idb - IndexedDB Promiseラッパー
- Project Fugu - WebプラットフォームAPIトラッカー
- Bubblewrap - TWAビルドツール
- What PWA Can Do Today - PWA機能(きのう)デモ
- Microsoft PWAドキュメント - Microsoft Edge PWAガイド
- Apple Developer - Web Apps - iOS PWAドキュメント