pwa-development について
pwa-developmentは、オフライン対応、インストール、パフォーマンスを扱うPWA開発スキルです。マニフェスト、Service Worker、キャッシュ戦略、Workbox、テストの手順をまとめています。
主な機能
- 01Web App Manifestの設定
- 02Service Workerの登録と更新
- 03オフライン用キャッシュ戦略の選択
- 04Workboxによるキャッシュとバックグラウンド同期
- 05LighthouseとチェックリストでのPWAテスト
活用シーン
- 01Webアプリにオフライン用ページを追加
- 02インストールと起動時の動作を設定
- 03APIと静的アセットのキャッシュを設計
- 04公開前後にPWAの品質を確認
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ alinaqi/ maggy --skill pwa-developmentインストール後、Claude Code で/pwa-developmentを入力して読み込まれたか確認します。
詳細
PWA 開発スキル
目的: オフラインで動作し、ネイティブアプリのようにインストールでき、あらゆるデバイスで高速かつ信頼性の高いエクスペリエンスを提供するプログレッシブウェブアプリを構築します。
PWA の基本要件
┌─────────────────────────────────────────────────────────────────┐
│ THE THREE PILLARS OF PWA │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 1. HTTPS │
│ Required for service workers and security. │
│ localhost allowed for development. │
│ │
│ 2. SERVICE WORKER │
│ JavaScript that runs in background. │
│ Enables offline, caching, push notifications. │
│ │
│ 3. WEB APP MANIFEST │
│ JSON file describing app metadata. │
│ Enables installation and app-like experience. │
├─────────────────────────────────────────────────────────────────┤
│ INSTALLABILITY CRITERIA (Chrome) │
│ ───────────────────────────────────────────────────────────── │
│ • HTTPS (or localhost) │
│ • Service worker with fetch handler │
│ • Web app manifest with: name, icons (192px + 512px), │
│ start_url, display: standalone/fullscreen/minimal-ui │
└─────────────────────────────────────────────────────────────────┘
Web アプリマニフェスト
必須フィールド
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A description of what the app does",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
拡張マニフェスト(全機能)
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A full-featured PWA",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#3367D6",
"dir": "ltr",
"lang": "en",
"categories": ["productivity", "utilities"],
"icons": [
{ "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/icons/icon-144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "/icons/icon-152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"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"
}
],
"shortcuts": [
{
"name": "New Item",
"short_name": "New",
"description": "Create a new item",
"url": "/new?source=shortcut",
"icons": [{ "src": "/icons/shortcut-new.png", "sizes": "192x192" }]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [{ "name": "files", "accept": ["image/*"] }]
}
},
"protocol_handlers": [
{
"protocol": "web+myapp",
"url": "/handle?url=%s"
}
],
"file_handlers": [
{
"action": "/open-file",
"accept": {
"text/plain": [".txt"]
}
}
]
}
マニフェストのチェックリスト
-
nameとshort_nameを定義する -
start_urlを設定する(分析にはクエリパラメーターを使用) -
displayをstandaloneまたはfullscreenに設定する - アイコン:最低でも 192x192 と 512x512
- Android のアダプティブアイコン用にマスカブルアイコンを含める
-
theme_colorをアプリのデザインに合わせる - スプラッシュ画面用に
background_colorを設定する - より充実したインストール UI 用のスクリーンショット(任意)
- クイックアクション用のショートカット(任意)
Service Worker のパターン
基本的な Service Worker
// sw.js
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/offline.html'
];
// Install: Cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate: Clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
// Fetch: Serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((cached) => cached || fetch(event.request))
.catch(() => caches.match('/offline.html'))
);
});
登録
// 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);
} catch (error) {
console.error('SW registration failed:', error);
}
});
}
キャッシュ戦略
戦略選択ガイド
| 戦略 | 使用例 | 説明 |
|---|---|---|
| キャッシュ優先 | 静的アセット(CSS、JS、画像) | キャッシュを確認し、なければネットワークにフォールバックします |
| ネットワーク優先 | API レスポンス、動的コンテンツ | ネットワークを試し、利用できなければキャッシュにフォールバックします |
| 古いデータを表示しながら再検証 | 半静的コンテンツ(アバター、記事) | すぐにキャッシュを表示し、バックグラウンドで更新します |
| ネットワークのみ | キャッシュできないリクエスト(分析) | 常にネットワークを使用します |
| キャッシュのみ | オフライン専用アセット | キャッシュからのみ配信します |
キャッシュ優先(オフライン優先)
// Best for: Static assets that rarely change
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'image' ||
event.request.destination === 'style' ||
event.request.destination === 'script') {
event.respondWith(
caches.match(event.request)
.then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
});
})
);
}
});
ネットワーク優先(最新データ優先)
// Best for: API data, frequently updated content
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
})
.catch(() => caches.match(event.request))
);
}
});
古いデータを表示しながら再検証
// Best for: Content that's okay to be slightly outdated
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/articles/')) {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
})
);
}
});
Workbox(推奨)
Workbox を使う理由
- 実績のあるキャッシュ戦略
- リビジョン管理による事前キャッシュ
- オフラインフォーム用のバックグラウンド同期
- キャッシュの自動クリーンアップ
- TypeScript のサポート
インストール
npm install workbox-webpack-plugin # Webpack
npm install @vite-pwa/vite-plugin # Vite
Vite での Workbox
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
manifest: {
name: 'My App',
short_name: 'App',
theme_color: '#ffffff',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 24 hours
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
}
}
}
]
}
})
]
};
Workbox の手動 Service Worker
// sw.js
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';
// Precache static assets (generated by build tool)
precacheAndRoute(self.__WB_MANIFEST);
// Cache images
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
})
]
})
);
// Cache API responses
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-responses',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 24 * 60 * 60 // 24 hours
})
]
})
);
// Cache page navigations
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({
cacheName: 'pages',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] })
]
})
);
オフラインエクスペリエンス
オフラインページ
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - App Name</title>
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.offline-content {
text-align: center;
padding: 2rem;
}
.offline-icon { font-size: 4rem; }
h1 { color: #333; }
p { color: #666; }
button {
background: #3367D6;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
</style>
</head>
<body>
<div class="offline-content">
<div class="offline-icon">📡</div>
<h1>You're offline</h1>
<p>Check your connection and try again.</p>
<button onclick="location.reload()">Retry</button>
</div>
</body>
</html>
オフラインの検出
// Online/offline status handling
function updateOnlineStatus() {
const status = navigator.onLine ? 'online' : 'offline';
document.body.dataset.connectionStatus = status;
if (!navigator.onLine) {
showNotification('You are offline. Some features may be unavailable.');
}
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();
バックグラウンド同期(オフライン操作をキューに追加)
// sw.js with Workbox
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {
maxRetentionTime: 24 * 60 // Retry for 24 hours
});
registerRoute(
({ url }) => url.pathname === '/api/submit',
new NetworkOnly({
plugins: [bgSyncPlugin]
}),
'POST'
);
// main.js - Queue form submission
async function submitForm(data) {
try {
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
} catch (error) {
// Will be retried by background sync when online
showNotification('Saved offline. Will sync when connected.');
}
}
アプリのような機能
インストールプロンプト
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallButton();
});
async function installApp() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User ${outcome === 'accepted' ? 'accepted' : 'dismissed'} install`);
deferredPrompt = null;
hideInstallButton();
}
window.addEventListener('appinstalled', () => {
console.log('App installed');
deferredPrompt = null;
});
スタンドアロンモードの検出
// Check if running as installed PWA
function isInstalledPWA() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true; // iOS
}
// Listen for display mode changes
window.matchMedia('(display-mode: standalone)')
.addEventListener('change', (e) => {
console.log('Display mode:', e.matches ? 'standalone' : 'browser');
});
プッシュ通知
// Request permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
await subscribeToPush();
}
return permission;
}
// Subscribe to push
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}
// sw.js - Handle push events
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: data.url }
})
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
共有ターゲット
// sw.js - Handle share target
self.addEventListener('fetch', (event) => {
if (event.request.url.endsWith('/share') &&
event.request.method === 'POST') {
event.respondWith((async () => {
const formData = await event.request.formData();
const title = formData.get('title');
const text = formData.get('text');
const url = formData.get('url');
// Store or process shared content
// Redirect to app with shared data
return Response.redirect(`/?shared=true&title=${encodeURIComponent(title)}`);
})());
}
});
パフォーマンスの最適化
クリティカルレンダリングパス
<style>
/* Critical above-the-fold styles */
</style>
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/scripts/app.js" as="script">
<link rel="stylesheet" href="/styles/main.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
画像の最適化
<img
src="/images/hero-800.webp"
srcset="
/images/hero-400.webp 400w,
/images/hero-800.webp 800w,
/images/hero-1200.webp 1200w
"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
alt="Hero image"
loading="lazy"
decoding="async"
>
<picture>
<source srcset="/images/hero.avif" type="image/avif">
<source srcset="/images/hero.webp" type="image/webp">
<img src="/images/hero.jpg" alt="Hero image" loading="lazy">
</picture>
コード分割
// Dynamic imports for route-based splitting
const routes = {
'/': () => import('./pages/Home.js'),
'/about': () => import('./pages/About.js'),
'/settings': () => import('./pages/Settings.js')
};
async function loadPage(path) {
const loader = routes[path];
if (loader) {
const module = await loader();
return module.default;
}
}
PWA のテスト
Lighthouse の監査
# Run Lighthouse from CLI
npx lighthouse https://your-app.com --view
# Key metrics to check:
# - PWA badge (installable, offline-ready)
# - Performance score
# - Best practices
# - Accessibility
手動テストのチェックリスト
-
インストール可能性
- デスクトップ版 Chrome でインストールプロンプトが表示される
- モバイルのホーム画面に追加できる
- インストール後、アプリがスタンドアロンモードで開く
-
オフライン対応
- オフライン(機内モード)でもアプリが読み込まれる
- キャッシュされたページが正しく表示される
- キャッシュされていないルートではオフライン用のフォールバックページが表示される
- オンライン復帰時にバックグラウンド同期が機能する
-
パフォーマンス
- First Contentful Paint < 1.8s
- Largest Contentful Paint < 2.5s
- Time to Interactive < 3.8s
- Cumulative Layout Shift < 0.1
-
Service Worker
- SW が正常に登録される
- インストール時に静的アセットがキャッシュされる
- SW が正しく更新される(新しいバージョン)
- 古いキャッシュに関する問題がない
-
マニフェスト
- 必須フィールドがすべてそろっている
- アイコンが正しく表示される
- テーマカラーが適用される
- 起動時にスプラッシュ画面が表示される
Service Worker の更新テスト
// Force update check
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.update();
});
}
// Listen for updates
navigator.serviceWorker.addEventListener('controllerchange', () => {
// New service worker activated
window.location.reload();
});
プロジェクト構成
project/
├── public/
│ ├── manifest.json # Web app manifest
│ ├── sw.js # Service worker (if not bundled)
│ ├── offline.html # Offline fallback page
│ ├── robots.txt
│ └── icons/
│ ├── icon-72.png
│ ├── icon-96.png
│ ├── icon-128.png
│ ├── icon-144.png
│ ├── icon-152.png
│ ├── icon-192.png
│ ├── icon-384.png
│ ├── icon-512.png
│ ├── icon-maskable.png # For adaptive icons
│ ├── apple-touch-icon.png
│ └── favicon.ico
├── src/
│ ├── sw.js # Service worker source (if bundled)
│ ├── pwa/
│ │ ├── install.js # Install prompt handling
│ │ ├── offline.js # Offline detection
│ │ └── push.js # Push notification handling
│ └── ...
└── tests/
└── pwa/
├── manifest.test.js
├── sw.test.js
└── offline.test.js
よくある間違い
| 間違い | 修正方法 |
|---|---|
| マスカブルアイコンがない | "purpose": "maskable" を指定してアイコンを追加します |
| オフライン時のフォールバックがない | offline.html を作成してキャッシュします |
| キャッシュが期限切れにならない | Workbox で ExpirationPlugin を使用します |
| SW がキャッシュしすぎる | リソースの種類ごとに適切な戦略を使用します |
| 更新の仕組みがない | skipWaiting() と再読み込みのプロンプトを実装します |
| インストールのプロンプトが機能しない | マニフェストがすべての基準を満たしていることを確認します |
| 本番環境で HTTPS が設定されていない | SSL 証明書を設定します |
| キャッシュサイズが大きい | maxEntries と maxAgeSeconds を設定します |
| API の応答が古い | 動的データには NetworkFirst を使用します |
| start_url のトラッキングがない | クエリパラメーター /?source=pwa を追加します |
PWA 開発チェックリスト
リリース前
- HTTPS を設定済み(本番環境)
- 必須フィールドをすべて含むマニフェストを作成済み
- 必要なサイズ(192、512、マスカブル)のアイコンを用意済み
- サービスワーカーを登録し、動作を確認済み
- オフラインページを作成してキャッシュ済み
- すべてのリソースの種類にキャッシュ戦略を定義済み
- インストールプロンプトの処理を実装済み
- Lighthouse の PWA 監査に合格済み
リリース後
- キャッシュサイズを監視する
- SW の更新によってアプリが壊れないことをテストする
- 分析を通じて PWA のインストールを追跡する
- 複数のデバイス/ブラウザーでテストする
- Core Web Vitals を監視する
- プッシュ通知のフローを設定する(必要な場合)
フレームワーク別ガイド
Next.js
npm install next-pwa
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development'
});
module.exports = withPWA({
// Your Next.js config
});
Create React App
# CRA 4+ has PWA support built-in
npx create-react-app my-pwa --template cra-template-pwa
Vite(任意のフレームワーク)
npm install vite-plugin-pwa -D
設定については、上記の「Workbox と Vite」のセクションを参照してください。
クイックリファレンス
キャッシュ戦略の早見表
Static Assets (CSS, JS, images) → Cache First
API Responses → Network First
User-generated content → Stale While Revalidate
Analytics, non-cacheable → Network Only
Offline-only assets → Cache Only
マニフェストの最小要件
{
"name": "App Name",
"short_name": "App",
"start_url": "/",
"display": "standalone",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
サービスワーカーのライフサイクル
1. Register → 2. Install → 3. Activate → 4. Fetch
↓ ↓ ↓ ↓
Load app Cache assets Clean old Serve requests
caches from cache/network
コメント
Markdown 対応