pwa-development

pwa-development

热门

渐进式Web应用 - Service Worker、缓存策略、离线支持、Workbox

704Star
56Fork
更新于 2026/7/14
SKILL.md
readonly只读
name
pwa-development
description

Progressive Web Apps - service workers, caching strategies, offline, Workbox

PWA 开发技能

目的: 构建能够离线工作、像原生应用一样安装,并在所有设备上提供快速、可靠体验的渐进式Web应用。


PWA 核心要求

┌─────────────────────────────────────────────────────────────────┐
│  PWA 三大支柱                                                    │
│  ─────────────────────────────────────────────────────────────  │
│                                                                 │
│  1. HTTPS                                                       │
│     需要 Service Worker 和安全性。                                │
│     开发环境允许使用 localhost。                                  │
│                                                                 │
│  2. SERVICE WORKER                                              │
│     在后台运行的 JavaScript。                                    │
│     支持离线、缓存、推送通知。                                    │
│                                                                 │
│  3. WEB APP MANIFEST                                            │
│     描述应用元数据的 JSON 文件。                                  │
│     支持安装和类似应用的使用体验。                                │
├─────────────────────────────────────────────────────────────────┤
│  可安装性标准(Chrome)                                           │
│  ─────────────────────────────────────────────────────────────  │
│  • HTTPS(或 localhost)                                         │
│  • 带有 fetch 处理程序的 Service Worker                          │
│  • Web App Manifest 包含:name、icons(192px + 512px)、          │
│    start_url、display: standalone/fullscreen/minimal-ui         │
└─────────────────────────────────────────────────────────────────┘

Web App Manifest

必填字段

{
  "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"
    }
  ]
}

增强 Manifest(完整功能)

{
  "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"]
      }
    }
  ]
}

Manifest 检查清单

  • [ ] 定义了 nameshort_name
  • [ ] 设置了 start_url(使用查询参数用于分析)
  • [ ] display 设置为 standalonefullscreen
  • [ ] 图标:至少 192x192 和 512x512
  • [ ] 包含 Maskable 图标用于 Android 自适应图标
  • [ ] theme_color 与应用设计匹配
  • [ ] background_color 用于启动画面
  • [ ] 截图用于更丰富的安装界面(可选)
  • [ ] 快捷方式用于快速操作(可选)

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: 缓存静态资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(STATIC_ASSETS))
      .then(() => self.skipWaiting())
  );
});

// Activate: 清理旧缓存
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: 优先从缓存响应,回退到网络
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);
    }
  });
}

缓存策略

策略选择指南

策略 使用场景 描述
Cache First 静态资源(CSS、JS、图片) 检查缓存,回退到网络
Network First API 响应、动态内容 尝试网络,回退到缓存
Stale While Revalidate 半静态内容(头像、文章) 立即返回缓存,后台更新
Network Only 不可缓存的请求(分析) 始终使用网络
Cache Only 仅离线资源 仅从缓存提供

Cache First(离线优先)

// 最佳适用:很少变化的静态资源
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;
          });
        })
    );
  }
});

Network First(新鲜优先)

// 最佳适用:API 数据、频繁更新的内容
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))
    );
  }
});

Stale While Revalidate

// 最佳适用:可以接受稍微过时的内容
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

Workbox 与 Vite

// 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';

// 预缓存静态资源(由构建工具生成)
precacheAndRoute(self.__WB_MANIFEST);

// 缓存图片
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
      })
    ]
  })
);

// 缓存 API 响应
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
      })
    ]
  })
);

// 缓存页面导航
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] })
    ]
  })
);

离线体验

离线页面

<!-- offline.html -->
<!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>

离线检测

// 在线/离线状态处理
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 - 队列表单提交
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) {
    // 在线时由后台同步重试
    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;
});

检测独立模式

// 检查是否以已安装 PWA 方式运行
function isInstalledPWA() {
  return window.matchMedia('(display-mode: standalone)').matches ||
         window.navigator.standalone === true; // iOS
}

// 监听显示模式变化
window.matchMedia('(display-mode: standalone)')
  .addEventListener('change', (e) => {
    console.log('Display mode:', e.matches ? 'standalone' : 'browser');
  });

推送通知

// 请求权限
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  if (permission === 'granted') {
    await subscribeToPush();
  }
  return permission;
}

// 订阅推送
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
  });

  // 将订阅发送到服务器
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
}

// sw.js - 处理推送事件
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 }
    })
  );
});

// 处理通知点击
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  );
});

分享目标

// sw.js - 处理分享目标
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');

      // 存储或处理分享的内容
      // 重定向到带有分享数据的应用
      return Response.redirect(`/?shared=true&title=${encodeURIComponent(title)}`);
    })());
  }
});

性能优化

关键渲染路径

<!-- 内联关键 CSS -->
<style>
  /* 首屏关键样式 */
</style>

<!-- 预加载重要资源 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/scripts/app.js" as="script">

<!-- 延迟非关键 CSS -->
<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>

代码分割

// 基于路由的动态导入
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 审计

# 从命令行运行 Lighthouse
npx lighthouse https://your-app.com --view

# 关键检查指标:
# - PWA 徽章(可安装、离线就绪)
# - 性能评分
# - 最佳实践
# - 无障碍

手动测试检查清单

  • [ ] 可安装性

    • [ ] 桌面 Chrome 出现安装提示
    • [ ] 移动端可添加到主屏幕
    • [ ] 安装后应用以独立模式打开
  • [ ] 离线支持

    • [ ] 离线时(飞行模式)应用加载
    • [ ] 缓存的页面正确显示
    • [ ] 未缓存的路由显示离线回退页面
    • [ ] 恢复在线时后台同步正常工作
  • [ ] 性能

    • [ ] 首次内容绘制 < 1.8s
    • [ ] 最大内容绘制 < 2.5s
    • [ ] 可交互时间 < 3.8s
    • [ ] 累积布局偏移 < 0.1
  • [ ] Service Worker

    • [ ] SW 注册成功
    • [ ] 安装时缓存静态资源
    • [ ] SW 正确更新(新版本)
    • [ ] 无过期缓存问题
  • [ ] Manifest

    • [ ] 所有必填字段存在
    • [ ] 图标正确显示
    • [ ] 主题色已应用
    • [ ] 启动时显示启动画面

测试 Service Worker 更新

// 强制检查更新
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.ready.then((registration) => {
    registration.update();
  });
}

// 监听更新
navigator.serviceWorker.addEventListener('controllerchange', () => {
  // 新的 Service Worker 已激活
  window.location.reload();
});

项目结构

project/
├── public/
│   ├── manifest.json           # Web app manifest
│   ├── sw.js                   # Service worker(如果未打包)
│   ├── offline.html            # 离线回退页面
│   ├── 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   # 用于自适应图标
│       ├── apple-touch-icon.png
│       └── favicon.ico
├── src/
│   ├── sw.js                   # Service worker 源码(如果打包)
│   ├── pwa/
│   │   ├── install.js          # 安装提示处理
│   │   ├── offline.js          # 离线检测
│   │   └── push.js             # 推送通知处理
│   └── ...
└── tests/
    └── pwa/
        ├── manifest.test.js
        ├── sw.test.js
        └── offline.test.js

常见错误

错误 修复
缺少 maskable 图标 添加带有 "purpose": "maskable" 的图标
无离线回退 创建 offline.html 并缓存它
缓存永不过期 使用 Workbox 的 ExpirationPlugin
SW 缓存过于激进 根据资源类型使用适当的策略
无更新机制 实现 skipWaiting() + 重新加载提示
安装提示失效 确保 manifest 满足所有标准
生产环境无 HTTPS 配置 SSL 证书
缓存过大 设置 maxEntriesmaxAgeSeconds
API 响应过时 对动态数据使用 NetworkFirst
缺少 start_url 跟踪 添加查询参数:/?source=pwa

PWA 开发检查清单

发布前

  • [ ] 配置 HTTPS(生产环境)
  • [ ] Manifest 完整,包含所有必填字段
  • [ ] 所有必需尺寸的图标(192、512、maskable)
  • [ ] Service Worker 已注册并正常工作
  • [ ] 离线页面已创建并缓存
  • [ ] 为所有资源类型定义了缓存策略
  • [ ] 实现了安装提示处理
  • [ ] Lighthouse PWA 审计通过

发布后

  • [ ] 监控缓存大小
  • [ ] 测试 SW 更新不会破坏应用
  • [ ] 通过分析跟踪 PWA 安装
  • [ ] 在多个设备/浏览器上测试
  • [ ] 监控核心 Web 指标
  • [ ] 设置推送通知流程(如果需要)

框架特定指南

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+ 内置 PWA 支持
npx create-react-app my-pwa --template cra-template-pwa

Vite(任何框架)

npm install vite-plugin-pwa -D

配置请参见上面的 Workbox 与 Vite 部分。


快速参考

缓存策略速查表

静态资源(CSS、JS、图片)     → Cache First
API 响应                        → Network First
用户生成的内容                  → Stale While Revalidate
分析、不可缓存                 → Network Only
仅离线资源                     → Cache Only

Manifest 最低要求

{
  "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" }
  ]
}

Service Worker 生命周期

1. 注册 → 2. 安装 → 3. 激活 → 4. 请求
     ↓              ↓            ↓           ↓
  加载应用    缓存资源    清理旧缓存   从缓存/网络
                           缓存        提供请求