mcpField
P
progressive-web-app

progressive-web-app

github.com/sickn33开发49.2k72 次安装1 天前更新
代码仓库

关于 progressive-web-app

帮助开发者构建渐进式 Web 应用的领域技能,介绍清单、Service Worker、缓存策略和离线支持。适用于需要安装到主屏幕或应对不稳定网络的 Web 项目。

主要功能

  1. 01配置 Web App Manifest
  2. 02注册与管理 Service Worker
  3. 03实现应用外壳预缓存
  4. 04选择资源缓存策略
  5. 05处理离线页面与缓存更新
  6. 06说明浏览器安装差异

适用场景

  1. 01让 Web 应用支持离线访问
  2. 02为移动端网站添加安装体验
  3. 03设计静态与动态资源缓存
  4. 04排查 Service Worker 部署问题

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/sickn33/agentic-awesome-skills --skill progressive-web-app

安装后在 Claude Code 中输入/progressive-web-app确认已加载。

详细介绍

渐进式 Web 应用(PWA)

概述

渐进式 Web 应用是一种利用现代浏览器功能,提供快速、可靠且可安装体验的 Web 应用,即使在网络不稳定时也能如此。常见的构成要素(安装条件因浏览器而异)包括:

  1. HTTPS — 生产环境中,服务工作线程必须通过它才能注册(开发时 localhost 除外)。
  2. Web 应用清单(manifest.json)— 让应用可安装,并定义应用在设备主屏幕上的外观。
  3. 服务工作线程(sw.js)— 在后台运行的脚本,可拦截网络请求、管理缓存并启用离线功能。

何时使用此技能

  • 用户希望其 Web 应用在离线或网络不稳定时也能使用时。
  • 构建移动优先的 Web 项目,且用户应能将应用安装到主屏幕时。
  • 用户询问缓存策略、服务工作线程,或如何提升 Web 应用性能和弹性时。
  • 用户提到 Workbox、Web 应用清单、后台同步或 Web 推送通知时。
  • 用户询问“我的网站能像应用一样安装吗?”或“如何让网站离线可用?”,即使他们没有使用 PWA 一词时。

交付物检查清单

此示例布局使用以下文件;这些确切的文件名并非所有 PWA 都必须采用:

  • index.html — 链接清单并注册服务工作线程
  • manifest.json — 完整的应用元数据和图标集
  • sw.js — 包含 install、activate 和 fetch 处理程序的服务工作线程
  • app.js — 包含 SW 注册和安装提示处理逻辑的应用主逻辑
  • offline.html — 导航在离线时失败后显示的备用页面(这是此示例的 cache.addAll 列表所需,并非 PWA 标准要求)

步骤 1:Web 应用清单(manifest.json)

定义应用安装后的外观。必须通过 <link rel="manifest"> 从 <head> 中链接。

{
  "name": "My Awesome PWA",
  "short_name": "MyPWA",
  "description": "A fast, offline-capable Progressive Web App.",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "background_color": "#ffffff",
  "theme_color": "#0055ff",
  "icons": [
    {
      "src": "/assets/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/assets/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/assets/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    }
  ]
}

关键字段:

  • display:standalone 会隐藏浏览器 UI;minimal-ui 会显示最少的控件;browser 表示标准标签页。
  • 图标上的 purpose: "maskable" 可在 Android 上启用自适应图标(安全区域很重要,请将内容保持在中央 80% 区域内)。
  • screenshots 为可选项,但要在桌面版 Chrome 的增强型安装对话框中显示则必须提供。

步骤 2:HTML 外壳(index.html)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>My Awesome PWA</title>

  
  <link rel="manifest" href="/manifest.json">

  
  <meta name="theme-color" content="#0055ff">

  
  <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="/assets/icons/icon-192x192.png">

  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <div id="app">
    <header><h1>My PWA</h1></header>
    <main id="content">Loading...</main>
    
    <button id="install-btn" hidden>Install App</button>
  </div>
  <script src="/app.js"></script>
</body>
</html>

步骤 3:服务工作线程注册与安装提示(app.js)

// ─── Service Worker Registration ───────────────────────────────────────────
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('[App] SW registered, scope:', registration.scope);
    } catch (err) {
      console.error('[App] SW registration failed:', err);
    }
  });
}

// ─── Install Prompt (Add to Home Screen) ───────────────────────────────────
let deferredPrompt;
const installBtn = document.getElementById('install-btn'); // may be null if omitted

// Capture the browser's install prompt — it fires before the browser's own UI
window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault(); // Stop automatic mini-infobar on mobile
  deferredPrompt = e;
  if (installBtn) installBtn.hidden = false; // Show your custom install button
});

if (installBtn) {
  installBtn.addEventListener('click', async () => {
    if (!deferredPrompt) return;
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    console.log('[App] Install outcome:', outcome);
    deferredPrompt = null;
    installBtn.hidden = true;
  });
}
// Fires when the app is installed (via browser or your button)
window.addEventListener('appinstalled', () => {
  console.log('[App] PWA installed successfully');
  if (installBtn) installBtn.hidden = true;
});

步骤 4:服务工作线程(sw.js)

缓存版本控制(至关重要 — 每次部署都要递增)
const CACHE_VERSION = 'v1';
const CACHE_PREFIX = 'my-pwa-'; // Unique to this app and deployment scope.
const STATIC_CACHE = `${CACHE_PREFIX}static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `${CACHE_PREFIX}dynamic-${CACHE_VERSION}`;

// Files to pre-cache during install (the "App Shell")
const APP_SHELL = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/assets/icons/icon-192x192.png',
  '/offline.html', // Fallback page shown when network is unavailable
];

手写的策略只是用于教学的示例。在调用 skipWaiting/clients.claim 之前协调更新,以避免旧页面与新工作线程不兼容。限制缓存大小和保留时间,保留其他应用的缓存,并将后台重新验证与 event.waitUntil 关联,同时处理拒绝情况。

安装 — 预缓存应用外壳
self.addEventListener('install', (event) => {
  console.log('[SW] Installing...');
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) => {
      console.log('[SW] Pre-caching app shell');
      return cache.addAll(APP_SHELL);
    })
  );
  // Activate immediately without waiting for old SW to die
  self.skipWaiting();
});
激活 — 清理旧缓存
self.addEventListener('activate', (event) => {
  console.log('[SW] Activating...');
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name.startsWith(CACHE_PREFIX) && name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
          .map((name) => {
            console.log('[SW] Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    })
  );
  // Take control of all pages immediately
  self.clients.claim();
});
获取 — 缓存策略

根据资源类型选择合适的策略:

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Only handle GET requests from our own origin
  if (request.method !== 'GET' || url.origin !== location.origin) return;

  // Explicit public resources only. Never cache authenticated/user-specific
  // HTML or API responses by URL; cache contents can survive logout/account changes.
  if (!APP_SHELL.includes(url.pathname)) return;

  // Strategy A: Cache-First (for static assets — fast, tolerates stale)
  if (url.pathname.match(/\.(css|js|png|jpg|svg|woff2)$/)) {
    event.respondWith(cacheFirst(request));
    return;
  }

  // Strategy B: Network-First (for HTML pages — fresh, falls back to cache)
  if (request.headers.get('Accept')?.includes('text/html')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // API caching is intentionally omitted; default to network-only for private data.
});

// ─── Strategy Implementations ──────────────────────────────────────────────

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  try {
    const response = await fetch(request);
    const cache = await caches.open(STATIC_CACHE);
    if (response.ok && response.type !== "opaque") await cache.put(request, response.clone());
    return response;
  } catch {
    // Nothing useful to fall back to for assets
    return new Response('Asset unavailable offline', { status: 503 });
  }
}

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(DYNAMIC_CACHE);
    if (response.ok && response.type !== "opaque") await cache.put(request, response.clone());
    return response;
  } catch {
    const cached = await caches.match(request);
    return cached || await caches.match('/offline.html') || new Response("Offline", { status: 503 });
  }
}

async function staleWhileRevalidate(request) {
  const cache = await caches.open(DYNAMIC_CACHE);
  const cached = await cache.match(request);
  const fetchPromise = fetch(request).then(async (response) => {
    if (response.ok && response.type !== "opaque") await cache.put(request, response.clone());
    return response;
  });
  return cached || fetchPromise;
}

边缘情况与平台说明

iOS / Safari 的特殊情况
  • Safari 支持清单和服务工作线程,但不支持 beforeinstallprompt — 用户必须手动通过“分享”→“添加到主屏幕”菜单安装。
  • 使用 apple-mobile-web-app-* 元标签(如上方 index.html 所示)以正确集成 iOS。
  • Safari 可能会在约 7 天无活动后清除服务工作线程缓存(智能跟踪预防)。
HTTPS 要求
  • 服务工作线程只能在 https:// 来源上注册。开发时唯一的例外是 http://localhost。
  • 如果需要在本地使用自定义主机名配置 HTTPS,可使用 mkcert 或 ngrok 等工具。
部署时清除缓存
  • 部署新资源时,务必递增 sw.js 中的 CACHE_VERSION。这样 activate 会清除旧缓存,用户也能获取最新文件。
  • 一种常见做法是通过构建工具(例如 Vite、Webpack)自动注入版本号。
不透明响应(跨源请求)
  • 跨源 no-cors 响应可能是不透明的,状态码为 0,且响应正文不可读;启用 CORS 的响应不一定是不透明的。不要将状态码 0 解读为成功。
  • 对于跨源资源,优先使用 staleWhileRevalidate,或者使用 Workbox 等能够安全处理此类情况的库。

Workbox(可选:生产环境捷径)

对于生产环境应用,可以考虑使用 Workbox(Google 的 PWA 库),而不是手动实现各种策略。它会自动处理边缘情况、缓存过期和版本管理。

// With Workbox (via CDN for simplicity — use npm + bundler in production)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');

const { registerRoute } = workbox.routing;
const { CacheFirst, NetworkFirst, StaleWhileRevalidate } = workbox.strategies;
const { precacheAndRoute } = workbox.precaching;

precacheAndRoute(self.__WB_MANIFEST || []); // Injected by build plugin

registerRoute(({ request }) => request.destination === 'image', new CacheFirst());
registerRoute(({ request }) => request.mode === 'navigate', new NetworkFirst());
registerRoute(({ request }) => request.destination === 'script', new StaleWhileRevalidate());

发布前检查清单

  • 网站通过 HTTPS 提供服务
  • manifest.json 包含 name、short_name、start_url、display、icons(192 + 512)
  • 图标包含 purpose: "any maskable"
  • sw.js 在 DevTools → Application → Service Workers 中注册成功,且无错误
  • 在 DevTools 中将网络限制为 “Offline” 时,应用 shell 可从缓存加载
  • offline.html 后备页面已缓存,并且在离线导航失败时提供
  • 当前浏览器的可安装性、离线、无障碍和性能检查均通过;不要依赖已弃用的 Lighthouse PWA 类别
  • 已在 iOS Safari(手动安装流程)和 Android Chrome(安装提示)上进行测试

限制

  • 仅当任务明确符合上述范围时,才使用此技能。
  • 不要将输出视为环境专属验证、测试或专家审查的替代品。
  • 如果缺少必要的输入、权限、安全边界或成功标准,请暂停并请求澄清。

评论

支持 Markdown