mcpField
P
pwa-development

pwa-development

github.com/travisjneuman開発107146 回インストール1 日前に更新
リポジトリ

pwa-development について

インストール可能でオフライン対応のプログレッシブウェブアプリを作るための開発ガイドです。マニフェスト、Service Worker、キャッシュ、インストール促進、通知の実装を扱います。

主な機能

  1. 01Web App Manifest の設定
  2. 02Service Worker の登録
  3. 03オフラインキャッシュと同期
  4. 04インストール促進の追加
  5. 05プッシュ通知の実装

活用シーン

  1. 01アプリへのオフラインアクセス追加
  2. 02インストール可能な Web アプリの構築
  3. 03ブラウザー対応に応じた PWA 機能の設定

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/travisjneuman/.claude --skill pwa-development

インストール後、Claude Code で/pwa-developmentを入力して読み込まれたか確認します。

詳細

プログレッシブウェブアプリ(PWA)の開発

すべてのプラットフォームで動作する、インストール可能でオフライン対応のウェブアプリケーションを構築します。

PWA の機能

機能対応ブラウザー
オフラインアクセスすべての最新ブラウザー
インストールプロンプトChrome、Edge、Samsung
プッシュ通知iOS Safari 以外のすべて
バックグラウンド同期Chrome、Edge
ファイル処理Chrome、Edge
共有先Chrome、Edge、Safari

主な要件

1. ウェブアプリマニフェスト
// public/manifest.json
{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A progressive web application",
  "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-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "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 Document",
      "short_name": "New",
      "url": "/new",
      "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
    }
  ],
  "share_target": {
    "action": "/share",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url",
      "files": [
        {
          "name": "files",
          "accept": ["image/*", "text/*"]
        }
      ]
    }
  }
}
2. HTML メタタグ
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    
    <link rel="manifest" href="/manifest.json" />
    <meta name="theme-color" content="#3b82f6" />
    <meta name="description" content="My Progressive Web App" />

    
    <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" />

    <title>My PWA</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

サービスワーカー

基本的なサービスワーカー
// public/sw.js
const CACHE_NAME = "my-pwa-v1";
const STATIC_ASSETS = [
  "/",
  "/index.html",
  "/manifest.json",
  "/icons/icon-192x192.png",
];

// Install - cache static assets
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(STATIC_ASSETS);
    }),
  );
  self.skipWaiting();
});

// Activate - clean old caches
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name)),
      );
    }),
  );
  self.clients.claim();
});

// Fetch - serve from cache, fallback to network
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      // Cache-first strategy
      if (cached) {
        return cached;
      }

      // Network fallback
      return fetch(event.request).then((response) => {
        // Don't cache non-GET or failed requests
        if (event.request.method !== "GET" || !response.ok) {
          return response;
        }

        // Cache successful responses
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });

        return response;
      });
    }),
  );
});
Workbox(推奨)
// src/sw.ts
import { precacheAndRoute } from "workbox-precaching";
import { registerRoute } from "workbox-routing";
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";

// Precache static assets
precacheAndRoute(self.__WB_MANIFEST);

// Cache images
registerRoute(
  ({ request }) => request.destination === "image",
  new CacheFirst({
    cacheName: "images",
    plugins: [
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  }),
);

// Network-first for API calls
registerRoute(
  ({ url }) => url.pathname.startsWith("/api/"),
  new NetworkFirst({
    cacheName: "api-cache",
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 minutes
      }),
    ],
  }),
);

// Stale-while-revalidate for pages
registerRoute(
  ({ request }) => request.mode === "navigate",
  new StaleWhileRevalidate({
    cacheName: "pages",
  }),
);
サービスワーカーの登録
// src/registerSW.ts
export async function registerServiceWorker() {
  if ("serviceWorker" in navigator) {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js", {
        scope: "/",
      });

      registration.addEventListener("updatefound", () => {
        const newWorker = registration.installing;
        if (newWorker) {
          newWorker.addEventListener("statechange", () => {
            if (newWorker.state === "installed") {
              if (navigator.serviceWorker.controller) {
                // New update available
                dispatchEvent(new CustomEvent("sw:update"));
              }
            }
          });
        }
      });

      console.log("Service Worker registered:", registration.scope);
    } catch (error) {
      console.error("Service Worker registration failed:", error);
    }
  }
}

インストールプロンプト

カスタムインストールボタン
import { useState, useEffect } from "react";

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

function InstallButton() {
  const [deferredPrompt, setDeferredPrompt] =
    useState<BeforeInstallPromptEvent | null>(null);
  const [isInstalled, setIsInstalled] = useState(false);

  useEffect(() => {
    // Check if already installed
    if (window.matchMedia("(display-mode: standalone)").matches) {
      setIsInstalled(true);
      return;
    }

    const handler = (e: Event) => {
      e.preventDefault();
      setDeferredPrompt(e as BeforeInstallPromptEvent);
    };

    window.addEventListener("beforeinstallprompt", handler);
    return () => window.removeEventListener("beforeinstallprompt", handler);
  }, []);

  const handleInstall = async () => {
    if (!deferredPrompt) return;

    await deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;

    if (outcome === "accepted") {
      setIsInstalled(true);
    }
    setDeferredPrompt(null);
  };

  if (isInstalled || !deferredPrompt) return null;

  return (
    <button onClick={handleInstall} className="install-button">
      Install App
    </button>
  );
}

オフライン対応

オフラインの検出
import { useState, useEffect } from "react";

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  return isOnline;
}

// Usage
function App() {
  const isOnline = useOnlineStatus();

  return (
    <div>
      {!isOnline && (
        <div className="offline-banner">
          You're offline. Some features may be unavailable.
        </div>
      )}
    </div>
  );
}
バックグラウンド同期
// In Service Worker
self.addEventListener("sync", (event) => {
  if (event.tag === "sync-data") {
    event.waitUntil(syncData());
  }
});

async function syncData() {
  const db = await openDB("pending-requests", 1);
  const requests = await db.getAll("requests");

  for (const request of requests) {
    try {
      await fetch(request.url, request.options);
      await db.delete("requests", request.id);
    } catch {
      // Will retry on next sync
    }
  }
}

// Register sync from app
async function queueRequest(url: string, options: RequestInit) {
  await navigator.serviceWorker.ready;

  if ("sync" in window.ServiceWorkerRegistration.prototype) {
    // Store request and trigger sync
    const db = await openDB("pending-requests", 1);
    await db.add("requests", { url, options, id: Date.now() });
    await navigator.serviceWorker.ready.then((reg) =>
      reg.sync.register("sync-data"),
    );
  } else {
    // Fallback to immediate fetch
    await fetch(url, options);
  }
}

プッシュ通知

許可のリクエスト
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  if (permission === "granted") {
    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",
      body: JSON.stringify(subscription),
    });
  }
}
サービスワーカーでプッシュを処理する
self.addEventListener("push", (event) => {
  const data = event.data?.json() ?? {};

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: "/icons/icon-192x192.png",
      badge: "/icons/badge.png",
      data: data.url,
    }),
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();

  if (event.notification.data) {
    event.waitUntil(clients.openWindow(event.notification.data));
  }
});

Vite PWA プラグイン

// vite.config.ts
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: "autoUpdate",
      includeAssets: ["favicon.ico", "robots.txt", "icons/*.png"],
      manifest: {
        name: "My PWA",
        short_name: "MyPWA",
        theme_color: "#3b82f6",
        icons: [
          // ... icon definitions
        ],
      },
      workbox: {
        globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: "NetworkFirst",
            options: {
              cacheName: "api-cache",
              expiration: {
                maxEntries: 100,
                maxAgeSeconds: 60 * 60 * 24, // 24 hours
              },
            },
          },
        ],
      },
    }),
  ],
});

PWA のテスト

ユーザーから依頼された場合にのみ、次のチェックを実行してください。

Lighthouse 監査

Lighthouse 12 以降には PWA カテゴリーがありません。DevTools > Application > Manifest でインストール可能か確認してください。

目標スコア
  • パフォーマンス:90 以上
  • アクセシビリティ:90 以上
  • ベストプラクティス:90 以上

ベストプラクティス

実施すること:
  • HTTPS を使用する(必須)
  • オフライン時の代替表示を用意する
  • 静的アセットをキャッシュする
  • 更新通知を表示する
  • すべての画面サイズに対応する
避けること:
  • すべてを永久にキャッシュする
  • SW の更新中にアプリをブロックする
  • iOS の制限を無視する
  • マニフェストアイコンを省略する
  • オフライン時の状態を忘れる

コメント