mcpField
P
pwa-development

pwa-development

github.com/travisjneuman开发107146 次安装1 天前更新
代码仓库

关于 pwa-development

面向 Web 开发者的 PWA 开发指南,涵盖可安装、离线运行的渐进式 Web 应用实现。内容包括清单、Service Worker、缓存、安装提示及通知等实践。

主要功能

  1. 01配置 Web App Manifest
  2. 02注册 Service 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确认已加载。

详细介绍

渐进式 Web 应用(PWA)开发

构建可安装、支持离线且可在所有平台上运行的 Web 应用。

PWA 功能

功能支持情况
离线访问所有现代浏览器
安装提示Chrome、Edge、Samsung
推送通知除 iOS Safari 外的所有平台
后台同步Chrome、Edge
文件处理Chrome、Edge
分享目标Chrome、Edge、Safari

核心要求

1. Web 应用清单
// 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>

Service Worker

基本 Service Worker
// 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",
  }),
);
注册 Service Worker
// 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),
    });
  }
}
在 Service Worker 中处理推送
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 的限制
  • 遗漏清单图标
  • 忘记离线状态

评论

支持 Markdown