Skip to content

渐进式 Web 应用(PWA)开发指南

渐进式 Web 应用(Progressive Web App, PWA)结合了 Web 和原生应用的优势,为用户提供快速、可靠、引人入胜的体验。本文将深入探讨 PWA 的核心技术和开发实践。

🎯 PWA 核心特性

PWA 的三大支柱

1. 可靠性 (Reliable)

  • 即使在网络不稳定的情况下也能快速加载
  • 离线功能支持
  • 缓存策略优化

2. 快速性 (Fast)

  • 快速响应用户交互
  • 流畅的动画和导航
  • 优化的性能表现

3. 引人入胜 (Engaging)

  • 类似原生应用的用户体验
  • 推送通知功能
  • 可安装到设备主屏幕

PWA 检查清单

javascript
// PWA 核心要求检查
const PWAChecklist = {
  // 基础要求
  basic: {
    https: true,                    // 必须使用 HTTPS
    serviceWorker: true,            // 注册 Service Worker
    webAppManifest: true,           // 提供 Web App Manifest
    responsiveDesign: true,         // 响应式设计
    offlineSupport: true           // 离线功能支持
  },
  
  // 增强功能
  enhanced: {
    pushNotifications: false,       // 推送通知
    backgroundSync: false,          // 后台同步
    installPrompt: false,          // 安装提示
    appShell: false,               // 应用外壳架构
    cacheFirst: false              // 缓存优先策略
  },
  
  // 性能指标
  performance: {
    firstContentfulPaint: 1.5,     // FCP < 1.5s
    largestContentfulPaint: 2.5,   // LCP < 2.5s
    firstInputDelay: 100,          // FID < 100ms
    cumulativeLayoutShift: 0.1     // CLS < 0.1
  }
}

🔧 Service Worker 实现

基础 Service Worker

javascript
// sw.js - Service Worker 主文件
const CACHE_NAME = 'pwa-cache-v1'
const STATIC_CACHE = 'static-cache-v1'
const DYNAMIC_CACHE = 'dynamic-cache-v1'

// 需要缓存的静态资源
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/css/styles.css',
  '/js/app.js',
  '/images/icon-192.png',
  '/images/icon-512.png',
  '/offline.html'
]

// 安装事件 - 缓存静态资源
self.addEventListener('install', (event) => {
  console.log('Service Worker: Installing...')
  
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then((cache) => {
        console.log('Service Worker: Caching static assets')
        return cache.addAll(STATIC_ASSETS)
      })
      .then(() => {
        console.log('Service Worker: Static assets cached')
        return self.skipWaiting() // 立即激活新的 Service Worker
      })
      .catch((error) => {
        console.error('Service Worker: Cache failed', error)
      })
  )
})

// 激活事件 - 清理旧缓存
self.addEventListener('activate', (event) => {
  console.log('Service Worker: Activating...')
  
  event.waitUntil(
    caches.keys()
      .then((cacheNames) => {
        return Promise.all(
          cacheNames.map((cacheName) => {
            if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
              console.log('Service Worker: Deleting old cache', cacheName)
              return caches.delete(cacheName)
            }
          })
        )
      })
      .then(() => {
        console.log('Service Worker: Activated')
        return self.clients.claim() // 立即控制所有页面
      })
  )
})

// 拦截网络请求
self.addEventListener('fetch', (event) => {
  const { request } = event
  const url = new URL(request.url)
  
  // 只处理同源请求
  if (url.origin !== location.origin) {
    return
  }
  
  // 不同类型的请求使用不同的缓存策略
  if (request.destination === 'document') {
    // HTML 文档 - 网络优先策略
    event.respondWith(networkFirstStrategy(request))
  } else if (request.destination === 'image') {
    // 图片 - 缓存优先策略
    event.respondWith(cacheFirstStrategy(request))
  } else if (request.url.includes('/api/')) {
    // API 请求 - 网络优先,支持离线
    event.respondWith(networkFirstWithOfflineStrategy(request))
  } else {
    // 其他静态资源 - 缓存优先策略
    event.respondWith(cacheFirstStrategy(request))
  }
})

// 缓存优先策略
async function cacheFirstStrategy(request) {
  try {
    const cachedResponse = await caches.match(request)
    if (cachedResponse) {
      return cachedResponse
    }
    
    const networkResponse = await fetch(request)
    
    // 缓存成功的响应
    if (networkResponse.ok) {
      const cache = await caches.open(DYNAMIC_CACHE)
      cache.put(request, networkResponse.clone())
    }
    
    return networkResponse
  } catch (error) {
    console.error('Cache first strategy failed:', error)
    
    // 返回离线页面
    if (request.destination === 'document') {
      return caches.match('/offline.html')
    }
    
    // 返回默认图片
    if (request.destination === 'image') {
      return caches.match('/images/offline-image.png')
    }
    
    throw error
  }
}

// 网络优先策略
async function networkFirstStrategy(request) {
  try {
    const networkResponse = await fetch(request)
    
    // 缓存成功的响应
    if (networkResponse.ok) {
      const cache = await caches.open(DYNAMIC_CACHE)
      cache.put(request, networkResponse.clone())
    }
    
    return networkResponse
  } catch (error) {
    console.error('Network first strategy failed:', error)
    
    // 网络失败时从缓存获取
    const cachedResponse = await caches.match(request)
    if (cachedResponse) {
      return cachedResponse
    }
    
    // 返回离线页面
    return caches.match('/offline.html')
  }
}

// 网络优先策略(支持离线)
async function networkFirstWithOfflineStrategy(request) {
  try {
    const networkResponse = await fetch(request)
    
    // 缓存 API 响应
    if (networkResponse.ok) {
      const cache = await caches.open(DYNAMIC_CACHE)
      cache.put(request, networkResponse.clone())
    }
    
    return networkResponse
  } catch (error) {
    console.error('API request failed:', error)
    
    // 尝试从缓存获取
    const cachedResponse = await caches.match(request)
    if (cachedResponse) {
      return cachedResponse
    }
    
    // 返回离线 API 响应
    return new Response(
      JSON.stringify({
        error: 'Offline',
        message: '当前处于离线状态,请稍后重试'
      }),
      {
        status: 503,
        statusText: 'Service Unavailable',
        headers: {
          'Content-Type': 'application/json'
        }
      }
    )
  }
}

// 后台同步
self.addEventListener('sync', (event) => {
  console.log('Service Worker: Background sync', event.tag)
  
  if (event.tag === 'background-sync') {
    event.waitUntil(doBackgroundSync())
  }
})

// 执行后台同步
async function doBackgroundSync() {
  try {
    // 获取待同步的数据
    const pendingData = await getPendingData()
    
    // 同步数据到服务器
    for (const data of pendingData) {
      await syncDataToServer(data)
    }
    
    console.log('Background sync completed')
  } catch (error) {
    console.error('Background sync failed:', error)
  }
}

// 推送通知
self.addEventListener('push', (event) => {
  console.log('Service Worker: Push received')
  
  const options = {
    body: event.data ? event.data.text() : '您有新的消息',
    icon: '/images/icon-192.png',
    badge: '/images/badge-72.png',
    vibrate: [100, 50, 100],
    data: {
      dateOfArrival: Date.now(),
      primaryKey: 1
    },
    actions: [
      {
        action: 'explore',
        title: '查看详情',
        icon: '/images/checkmark.png'
      },
      {
        action: 'close',
        title: '关闭',
        icon: '/images/xmark.png'
      }
    ]
  }
  
  event.waitUntil(
    self.registration.showNotification('PWA 通知', options)
  )
})

// 通知点击事件
self.addEventListener('notificationclick', (event) => {
  console.log('Notification clicked:', event.action)
  
  event.notification.close()
  
  if (event.action === 'explore') {
    // 打开应用
    event.waitUntil(
      clients.openWindow('/')
    )
  }
})

Service Worker 注册

javascript
// app.js - 主应用文件
class PWAManager {
  constructor() {
    this.swRegistration = null
    this.isOnline = navigator.onLine
    this.init()
  }
  
  async init() {
    // 注册 Service Worker
    await this.registerServiceWorker()
    
    // 监听网络状态变化
    this.setupNetworkListeners()
    
    // 设置安装提示
    this.setupInstallPrompt()
    
    // 请求通知权限
    this.requestNotificationPermission()
    
    // 设置后台同步
    this.setupBackgroundSync()
  }
  
  // 注册 Service Worker
  async registerServiceWorker() {
    if ('serviceWorker' in navigator) {
      try {
        this.swRegistration = await navigator.serviceWorker.register('/sw.js')
        console.log('Service Worker registered:', this.swRegistration)
        
        // 监听 Service Worker 更新
        this.swRegistration.addEventListener('updatefound', () => {
          const newWorker = this.swRegistration.installing
          
          newWorker.addEventListener('statechange', () => {
            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
              // 有新版本可用
              this.showUpdateAvailable()
            }
          })
        })
        
      } catch (error) {
        console.error('Service Worker registration failed:', error)
      }
    }
  }
  
  // 显示更新可用提示
  showUpdateAvailable() {
    const updateBanner = document.createElement('div')
    updateBanner.className = 'update-banner'
    updateBanner.innerHTML = `
      <div class="update-content">
        <span>新版本可用</span>
        <button id="update-btn">更新</button>
        <button id="dismiss-btn">稍后</button>
      </div>
    `
    
    document.body.appendChild(updateBanner)
    
    // 更新按钮事件
    document.getElementById('update-btn').addEventListener('click', () => {
      window.location.reload()
    })
    
    // 关闭按钮事件
    document.getElementById('dismiss-btn').addEventListener('click', () => {
      updateBanner.remove()
    })
  }
  
  // 设置网络状态监听
  setupNetworkListeners() {
    window.addEventListener('online', () => {
      this.isOnline = true
      this.showNetworkStatus('已连接到网络', 'success')
      this.syncPendingData()
    })
    
    window.addEventListener('offline', () => {
      this.isOnline = false
      this.showNetworkStatus('已离线,部分功能可能受限', 'warning')
    })
  }
  
  // 显示网络状态
  showNetworkStatus(message, type) {
    const statusBar = document.getElementById('network-status')
    if (statusBar) {
      statusBar.textContent = message
      statusBar.className = `network-status ${type}`
      statusBar.style.display = 'block'
      
      // 3秒后隐藏
      setTimeout(() => {
        statusBar.style.display = 'none'
      }, 3000)
    }
  }
  
  // 设置安装提示
  setupInstallPrompt() {
    let deferredPrompt
    
    window.addEventListener('beforeinstallprompt', (event) => {
      // 阻止默认的安装提示
      event.preventDefault()
      deferredPrompt = event
      
      // 显示自定义安装按钮
      this.showInstallButton(deferredPrompt)
    })
    
    // 监听应用安装
    window.addEventListener('appinstalled', () => {
      console.log('PWA was installed')
      this.hideInstallButton()
      this.trackEvent('pwa_installed')
    })
  }
  
  // 显示安装按钮
  showInstallButton(deferredPrompt) {
    const installBtn = document.getElementById('install-btn')
    if (installBtn) {
      installBtn.style.display = 'block'
      
      installBtn.addEventListener('click', async () => {
        // 显示安装提示
        deferredPrompt.prompt()
        
        // 等待用户响应
        const { outcome } = await deferredPrompt.userChoice
        console.log(`User response to the install prompt: ${outcome}`)
        
        // 清除 deferredPrompt
        deferredPrompt = null
        this.hideInstallButton()
        
        // 跟踪用户选择
        this.trackEvent('install_prompt_response', { outcome })
      })
    }
  }
  
  // 隐藏安装按钮
  hideInstallButton() {
    const installBtn = document.getElementById('install-btn')
    if (installBtn) {
      installBtn.style.display = 'none'
    }
  }
  
  // 请求通知权限
  async requestNotificationPermission() {
    if ('Notification' in window && 'serviceWorker' in navigator) {
      const permission = await Notification.requestPermission()
      
      if (permission === 'granted') {
        console.log('Notification permission granted')
        await this.subscribeToPushNotifications()
      } else {
        console.log('Notification permission denied')
      }
    }
  }
  
  // 订阅推送通知
  async subscribeToPushNotifications() {
    try {
      const subscription = await this.swRegistration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: this.urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
      })
      
      console.log('Push subscription:', subscription)
      
      // 发送订阅信息到服务器
      await this.sendSubscriptionToServer(subscription)
      
    } catch (error) {
      console.error('Failed to subscribe to push notifications:', error)
    }
  }
  
  // 发送订阅信息到服务器
  async sendSubscriptionToServer(subscription) {
    try {
      await fetch('/api/push-subscription', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(subscription)
      })
    } catch (error) {
      console.error('Failed to send subscription to server:', error)
    }
  }
  
  // 设置后台同步
  setupBackgroundSync() {
    if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
      // 监听表单提交
      document.addEventListener('submit', (event) => {
        if (!this.isOnline) {
          event.preventDefault()
          this.saveDataForSync(event.target)
          this.registerBackgroundSync()
        }
      })
    }
  }
  
  // 保存数据用于后台同步
  saveDataForSync(form) {
    const formData = new FormData(form)
    const data = Object.fromEntries(formData.entries())
    
    // 保存到 IndexedDB 或 localStorage
    const pendingData = JSON.parse(localStorage.getItem('pendingSync') || '[]')
    pendingData.push({
      id: Date.now(),
      data: data,
      timestamp: new Date().toISOString()
    })
    
    localStorage.setItem('pendingSync', JSON.stringify(pendingData))
    
    this.showMessage('数据已保存,将在网络恢复时自动同步', 'info')
  }
  
  // 注册后台同步
  async registerBackgroundSync() {
    try {
      await this.swRegistration.sync.register('background-sync')
      console.log('Background sync registered')
    } catch (error) {
      console.error('Background sync registration failed:', error)
    }
  }
  
  // 同步待处理数据
  async syncPendingData() {
    const pendingData = JSON.parse(localStorage.getItem('pendingSync') || '[]')
    
    if (pendingData.length > 0) {
      try {
        for (const item of pendingData) {
          await this.syncDataToServer(item.data)
        }
        
        // 清除已同步的数据
        localStorage.removeItem('pendingSync')
        this.showMessage('离线数据已同步', 'success')
        
      } catch (error) {
        console.error('Data sync failed:', error)
      }
    }
  }
  
  // 同步数据到服务器
  async syncDataToServer(data) {
    const response = await fetch('/api/sync', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    })
    
    if (!response.ok) {
      throw new Error(`Sync failed: ${response.statusText}`)
    }
    
    return response.json()
  }
  
  // 显示消息
  showMessage(message, type = 'info') {
    const messageEl = document.createElement('div')
    messageEl.className = `message ${type}`
    messageEl.textContent = message
    
    document.body.appendChild(messageEl)
    
    setTimeout(() => {
      messageEl.remove()
    }, 5000)
  }
  
  // 跟踪事件
  trackEvent(eventName, data = {}) {
    // 发送到分析服务
    if ('gtag' in window) {
      gtag('event', eventName, data)
    }
  }
  
  // 工具方法:转换 VAPID 密钥
  urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4)
    const base64 = (base64String + padding)
      .replace(/-/g, '+')
      .replace(/_/g, '/')
    
    const rawData = window.atob(base64)
    const outputArray = new Uint8Array(rawData.length)
    
    for (let i = 0; i < rawData.length; ++i) {
      outputArray[i] = rawData.charCodeAt(i)
    }
    
    return outputArray
  }
}

// VAPID 公钥(需要替换为实际的密钥)
const VAPID_PUBLIC_KEY = 'your-vapid-public-key-here'

// 初始化 PWA 管理器
const pwaManager = new PWAManager()

📱 Web App Manifest

完整的 Manifest 配置

json
{
  "name": "我的 PWA 应用",
  "short_name": "PWA App",
  "description": "一个功能完整的渐进式 Web 应用示例",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "theme_color": "#2196F3",
  "background_color": "#ffffff",
  "lang": "zh-CN",
  "dir": "ltr",
  
  "icons": [
    {
      "src": "/images/icon-72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/images/icon-96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/images/icon-128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/images/icon-144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/images/icon-152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/images/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/images/icon-384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/images/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  
  "screenshots": [
    {
      "src": "/images/screenshot-mobile.png",
      "sizes": "375x812",
      "type": "image/png",
      "form_factor": "narrow"
    },
    {
      "src": "/images/screenshot-desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    }
  ],
  
  "categories": ["productivity", "utilities"],
  "iarc_rating_id": "e84b072d-71b3-4d3e-86ae-31a8ce4e53b7",
  
  "shortcuts": [
    {
      "name": "新建文档",
      "short_name": "新建",
      "description": "快速创建新文档",
      "url": "/new",
      "icons": [
        {
          "src": "/images/shortcut-new.png",
          "sizes": "96x96"
        }
      ]
    },
    {
      "name": "搜索",
      "short_name": "搜索",
      "description": "搜索文档和内容",
      "url": "/search",
      "icons": [
        {
          "src": "/images/shortcut-search.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/*"]
        }
      ]
    }
  },
  
  "protocol_handlers": [
    {
      "protocol": "web+pwa",
      "url": "/handle?url=%s"
    }
  ],
  
  "prefer_related_applications": false,
  "related_applications": [
    {
      "platform": "play",
      "url": "https://play.google.com/store/apps/details?id=com.example.app",
      "id": "com.example.app"
    }
  ]
}

🎨 应用外壳架构

App Shell 实现

html
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>PWA 应用</title>
  
  <!-- PWA Meta Tags -->
  <meta name="theme-color" content="#2196F3">
  <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="PWA App">
  
  <!-- Icons -->
  <link rel="icon" type="image/png" sizes="32x32" href="/images/icon-32.png">
  <link rel="apple-touch-icon" href="/images/icon-180.png">
  
  <!-- Manifest -->
  <link rel="manifest" href="/manifest.json">
  
  <!-- Styles -->
  <link rel="stylesheet" href="/css/app-shell.css">
</head>
<body>
  <!-- App Shell -->
  <div id="app-shell">
    <!-- Header -->
    <header class="app-header">
      <button class="menu-btn" id="menu-btn" aria-label="菜单">
        <span class="hamburger"></span>
      </button>
      <h1 class="app-title">PWA 应用</h1>
      <button class="install-btn" id="install-btn" style="display: none;">
        安装应用
      </button>
    </header>
    
    <!-- Navigation -->
    <nav class="app-nav" id="app-nav">
      <ul class="nav-list">
        <li><a href="/" class="nav-link active">首页</a></li>
        <li><a href="/features" class="nav-link">功能</a></li>
        <li><a href="/settings" class="nav-link">设置</a></li>
        <li><a href="/about" class="nav-link">关于</a></li>
      </ul>
    </nav>
    
    <!-- Main Content -->
    <main class="app-main" id="app-main">
      <div class="loading-spinner" id="loading-spinner">
        <div class="spinner"></div>
        <p>加载中...</p>
      </div>
      
      <div class="content-container" id="content-container">
        <!-- 动态内容将在这里加载 -->
      </div>
    </main>
    
    <!-- Network Status -->
    <div class="network-status" id="network-status" style="display: none;"></div>
  </div>
  
  <!-- Scripts -->
  <script src="/js/app.js"></script>
</body>
</html>

App Shell 样式

css
/* app-shell.css */
:root {
  --primary-color: #2196F3;
  --primary-dark: #1976D2;
  --secondary-color: #FFC107;
  --background-color: #f5f5f5;
  --surface-color: #ffffff;
  --text-primary: #212121;
  --text-secondary: #757575;
  --border-color: #e0e0e0;
  --shadow: 0 2px 4px rgba(0,0,0,0.1);
  --header-height: 56px;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: var(--background-color);
  color: var(--text-primary);
  line-height: 1.6;
}

/* App Shell Layout */
#app-shell {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

/* Header */
.app-header {
  display: flex;
  align-items: center;
  height: var(--header-height);
  background-color: var(--primary-color);
  color: white;
  padding: 0 16px;
  box-shadow: var(--shadow);
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1000;
}

.menu-btn {
  background: none;
  border: none;
  color: white;
  padding: 8px;
  margin-right: 16px;
  cursor: pointer;
  border-radius: 4px;
  transition: background-color 0.2s;
}

.menu-btn:hover {
  background-color: rgba(255, 255, 255, 0.1);
}

.hamburger {
  display: block;
  width: 20px;
  height: 2px;
  background-color: white;
  position: relative;
}

.hamburger::before,
.hamburger::after {
  content: '';
  position: absolute;
  width: 20px;
  height: 2px;
  background-color: white;
  transition: transform 0.3s;
}

.hamburger::before {
  top: -6px;
}

.hamburger::after {
  top: 6px;
}

.app-title {
  flex: 1;
  font-size: 20px;
  font-weight: 500;
}

.install-btn {
  background-color: var(--secondary-color);
  color: var(--text-primary);
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: background-color 0.2s;
}

.install-btn:hover {
  background-color: #FFB300;
}

/* Navigation */
.app-nav {
  background-color: var(--surface-color);
  border-right: 1px solid var(--border-color);
  position: fixed;
  top: var(--header-height);
  left: -250px;
  width: 250px;
  height: calc(100vh - var(--header-height));
  transition: left 0.3s;
  z-index: 999;
  overflow-y: auto;
}

.app-nav.open {
  left: 0;
}

.nav-list {
  list-style: none;
  padding: 16px 0;
}

.nav-link {
  display: block;
  padding: 12px 24px;
  color: var(--text-primary);
  text-decoration: none;
  transition: background-color 0.2s;
}

.nav-link:hover,
.nav-link.active {
  background-color: var(--background-color);
  color: var(--primary-color);
}

/* Main Content */
.app-main {
  flex: 1;
  margin-top: var(--header-height);
  padding: 16px;
  transition: margin-left 0.3s;
}

.loading-spinner {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 200px;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid var(--border-color);
  border-top: 4px solid var(--primary-color);
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.content-container {
  max-width: 800px;
  margin: 0 auto;
  background-color: var(--surface-color);
  border-radius: 8px;
  padding: 24px;
  box-shadow: var(--shadow);
}

/* Network Status */
.network-status {
  position: fixed;
  top: var(--header-height);
  left: 0;
  right: 0;
  padding: 8px 16px;
  text-align: center;
  font-size: 14px;
  z-index: 998;
}

.network-status.success {
  background-color: #4CAF50;
  color: white;
}

.network-status.warning {
  background-color: #FF9800;
  color: white;
}

.network-status.error {
  background-color: #F44336;
  color: white;
}

/* 响应式设计 */
@media (min-width: 768px) {
  .menu-btn {
    display: none;
  }
  
  .app-nav {
    position: static;
    width: 250px;
    height: auto;
    border-right: 1px solid var(--border-color);
  }
  
  #app-shell {
    flex-direction: row;
  }
  
  .app-main {
    margin-top: 0;
    margin-left: 0;
  }
  
  .app-header {
    position: static;
  }
}

/* PWA 特定样式 */
@media (display-mode: standalone) {
  .app-header {
    padding-top: env(safe-area-inset-top);
  }
  
  .install-btn {
    display: none !important;
  }
}

/* 消息样式 */
.message {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  padding: 12px 24px;
  border-radius: 4px;
  color: white;
  font-weight: 500;
  z-index: 1001;
  animation: slideUp 0.3s ease-out;
}

.message.info {
  background-color: var(--primary-color);
}

.message.success {
  background-color: #4CAF50;
}

.message.warning {
  background-color: #FF9800;
}

.message.error {
  background-color: #F44336;
}

@keyframes slideUp {
  from {
    transform: translateX(-50%) translateY(100%);
    opacity: 0;
  }
  to {
    transform: translateX(-50%) translateY(0);
    opacity: 1;
  }
}

/* 更新横幅 */
.update-banner {
  position: fixed;
  top: var(--header-height);
  left: 0;
  right: 0;
  background-color: var(--secondary-color);
  color: var(--text-primary);
  padding: 12px 16px;
  z-index: 1000;
  animation: slideDown 0.3s ease-out;
}

.update-content {
  display: flex;
  align-items: center;
  justify-content: space-between;
  max-width: 800px;
  margin: 0 auto;
}

.update-content button {
  background: none;
  border: 1px solid var(--text-primary);
  color: var(--text-primary);
  padding: 6px 12px;
  margin-left: 8px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.update-content button:hover {
  background-color: var(--text-primary);
  color: var(--secondary-color);
}

@keyframes slideDown {
  from {
    transform: translateY(-100%);
  }
  to {
    transform: translateY(0);
  }
}

🚀 性能优化策略

缓存策略优化

javascript
// 高级缓存策略
class AdvancedCacheStrategy {
  constructor() {
    this.CACHE_VERSIONS = {
      static: 'static-v2',
      dynamic: 'dynamic-v2',
      api: 'api-v1'
    }
    
    this.CACHE_EXPIRY = {
      static: 30 * 24 * 60 * 60 * 1000,    // 30天
      dynamic: 7 * 24 * 60 * 60 * 1000,    // 7天
      api: 60 * 60 * 1000                  // 1小时
    }
  }
  
  // 智能缓存策略
  async handleRequest(request) {
    const url = new URL(request.url)
    
    // 根据请求类型选择策略
    if (this.isStaticAsset(url)) {
      return this.cacheFirstWithRefresh(request)
    } else if (this.isAPIRequest(url)) {
      return this.networkFirstWithCache(request)
    } else if (this.isHTMLDocument(request)) {
      return this.staleWhileRevalidate(request)
    } else {
      return this.networkOnly(request)
    }
  }
  
  // 缓存优先 + 后台刷新
  async cacheFirstWithRefresh(request) {
    const cache = await caches.open(this.CACHE_VERSIONS.static)
    const cachedResponse = await cache.match(request)
    
    if (cachedResponse && !this.isExpired(cachedResponse)) {
      // 后台刷新缓存
      this.refreshCache(request, cache)
      return cachedResponse
    }
    
    try {
      const networkResponse = await fetch(request)
      if (networkResponse.ok) {
        cache.put(request, networkResponse.clone())
      }
      return networkResponse
    } catch (error) {
      if (cachedResponse) {
        return cachedResponse
      }
      throw error
    }
  }
  
  // 网络优先 + 缓存备用
  async networkFirstWithCache(request) {
    const cache = await caches.open(this.CACHE_VERSIONS.api)
    
    try {
      const networkResponse = await fetch(request)
      
      if (networkResponse.ok) {
        // 添加时间戳用于过期检查
        const responseWithTimestamp = new Response(networkResponse.body, {
          status: networkResponse.status,
          statusText: networkResponse.statusText,
          headers: {
            ...networkResponse.headers,
            'sw-cache-timestamp': Date.now().toString()
          }
        })
        
        cache.put(request, responseWithTimestamp.clone())
        return networkResponse
      }
      
      throw new Error(`Network response not ok: ${networkResponse.status}`)
    } catch (error) {
      const cachedResponse = await cache.match(request)
      
      if (cachedResponse && !this.isExpired(cachedResponse)) {
        return cachedResponse
      }
      
      throw error
    }
  }
  
  // 过期时重新验证
  async staleWhileRevalidate(request) {
    const cache = await caches.open(this.CACHE_VERSIONS.dynamic)
    const cachedResponse = await cache.match(request)
    
    // 立即返回缓存的响应(如果存在)
    const responsePromise = cachedResponse || fetch(request)
    
    // 同时在后台更新缓存
    const updatePromise = fetch(request).then(response => {
      if (response.ok) {
        cache.put(request, response.clone())
      }
      return response
    }).catch(() => {
      // 网络失败时忽略错误
    })
    
    return responsePromise
  }
  
  // 检查响应是否过期
  isExpired(response) {
    const timestamp = response.headers.get('sw-cache-timestamp')
    if (!timestamp) return false
    
    const age = Date.now() - parseInt(timestamp)
    const maxAge = this.CACHE_EXPIRY.api // 默认使用 API 过期时间
    
    return age > maxAge
  }
  
  // 后台刷新缓存
  async refreshCache(request, cache) {
    try {
      const response = await fetch(request)
      if (response.ok) {
        cache.put(request, response)
      }
    } catch (error) {
      // 后台刷新失败时忽略错误
      console.log('Background cache refresh failed:', error)
    }
  }
  
  // 判断是否为静态资源
  isStaticAsset(url) {
    const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2']
    return staticExtensions.some(ext => url.pathname.endsWith(ext))
  }
  
  // 判断是否为 API 请求
  isAPIRequest(url) {
    return url.pathname.startsWith('/api/')
  }
  
  // 判断是否为 HTML 文档
  isHTMLDocument(request) {
    return request.destination === 'document'
  }
  
  // 仅网络策略
  async networkOnly(request) {
    return fetch(request)
  }
}

预缓存和预加载

javascript
// 预缓存管理器
class PrecacheManager {
  constructor() {
    this.precacheList = [
      // 关键资源
      { url: '/', revision: 'v1.0.0' },
      { url: '/css/app.css', revision: 'v1.2.1' },
      { url: '/js/app.js', revision: 'v1.3.0' },
      
      // 字体文件
      { url: '/fonts/roboto-regular.woff2', revision: 'v1.0.0' },
      { url: '/fonts/roboto-bold.woff2', revision: 'v1.0.0' },
      
      // 图标
      { url: '/images/icon-192.png', revision: 'v1.0.0' },
      { url: '/images/icon-512.png', revision: 'v1.0.0' },
      
      // 离线页面
      { url: '/offline.html', revision: 'v1.1.0' }
    ]
  }
  
  // 安装预缓存
  async installPrecache() {
    const cache = await caches.open('precache-v1')
    const urlsToCache = this.precacheList.map(item => item.url)
    
    try {
      await cache.addAll(urlsToCache)
      console.log('Precache installation successful')
    } catch (error) {
      console.error('Precache installation failed:', error)
      
      // 逐个缓存,跳过失败的资源
      for (const item of this.precacheList) {
        try {
          await cache.add(item.url)
        } catch (itemError) {
          console.warn(`Failed to cache ${item.url}:`, itemError)
        }
      }
    }
  }
  
  // 清理过期的预缓存
  async cleanupPrecache() {
    const cache = await caches.open('precache-v1')
    const cachedRequests = await cache.keys()
    
    for (const request of cachedRequests) {
      const url = new URL(request.url)
      const precacheItem = this.precacheList.find(item => item.url === url.pathname)
      
      if (!precacheItem) {
        // 删除不在预缓存列表中的资源
        await cache.delete(request)
        console.log(`Removed outdated precache: ${url.pathname}`)
      }
    }
  }
  
  // 预加载关键资源
  preloadCriticalResources() {
    const criticalResources = [
      '/css/critical.css',
      '/js/critical.js',
      '/images/hero-image.jpg'
    ]
    
    criticalResources.forEach(url => {
      const link = document.createElement('link')
      link.rel = 'preload'
      link.href = url
      
      if (url.endsWith('.css')) {
        link.as = 'style'
      } else if (url.endsWith('.js')) {
        link.as = 'script'
      } else if (url.match(/\.(jpg|jpeg|png|webp)$/)) {
        link.as = 'image'
      }
      
      document.head.appendChild(link)
    })
  }
  
  // 预取下一页资源
  prefetchNextPage() {
    const links = document.querySelectorAll('a[href]')
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const link = entry.target
          const href = link.getAttribute('href')
          
          if (href && !href.startsWith('#') && !href.startsWith('mailto:')) {
            this.prefetchResource(href)
            observer.unobserve(link)
          }
        }
      })
    }, { rootMargin: '100px' })
    
    links.forEach(link => observer.observe(link))
  }
  
  // 预取资源
  async prefetchResource(url) {
    try {
      const cache = await caches.open('prefetch-cache')
      const response = await fetch(url)
      
      if (response.ok) {
        cache.put(url, response)
        console.log(`Prefetched: ${url}`)
      }
    } catch (error) {
      console.warn(`Prefetch failed for ${url}:`, error)
    }
  }
}

📊 PWA 分析和监控

性能监控

javascript
// PWA 性能监控
class PWAAnalytics {
  constructor() {
    this.metrics = {}
    this.init()
  }
  
  init() {
    this.trackInstallation()
    this.trackServiceWorkerEvents()
    this.trackOfflineUsage()
    this.trackPerformanceMetrics()
  }
  
  // 跟踪安装事件
  trackInstallation() {
    window.addEventListener('beforeinstallprompt', (event) => {
      this.track('pwa_install_prompt_shown', {
        timestamp: Date.now()
      })
    })
    
    window.addEventListener('appinstalled', () => {
      this.track('pwa_installed', {
        timestamp: Date.now(),
        userAgent: navigator.userAgent
      })
    })
  }
  
  // 跟踪 Service Worker 事件
  trackServiceWorkerEvents() {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.addEventListener('controllerchange', () => {
        this.track('sw_controller_changed', {
          timestamp: Date.now()
        })
      })
      
      navigator.serviceWorker.ready.then(registration => {
        this.track('sw_ready', {
          timestamp: Date.now(),
          scope: registration.scope
        })
      })
    }
  }
  
  // 跟踪离线使用情况
  trackOfflineUsage() {
    let offlineStartTime = null
    
    window.addEventListener('offline', () => {
      offlineStartTime = Date.now()
      this.track('went_offline', {
        timestamp: offlineStartTime
      })
    })
    
    window.addEventListener('online', () => {
      if (offlineStartTime) {
        const offlineDuration = Date.now() - offlineStartTime
        this.track('came_online', {
          timestamp: Date.now(),
          offlineDuration
        })
        offlineStartTime = null
      }
    })
  }
  
  // 跟踪性能指标
  trackPerformanceMetrics() {
    // 页面加载性能
    window.addEventListener('load', () => {
      setTimeout(() => {
        const navigation = performance.getEntriesByType('navigation')[0]
        
        this.track('page_load_performance', {
          loadTime: navigation.loadEventEnd - navigation.loadEventStart,
          domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
          firstByte: navigation.responseStart - navigation.requestStart,
          timestamp: Date.now()
        })
      }, 0)
    })
    
    // Core Web Vitals
    this.trackWebVitals()
  }
  
  // 跟踪 Web Vitals
  trackWebVitals() {
    // 这里可以集成 web-vitals 库
    // import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'
    
    // getCLS(metric => this.track('cls', metric))
    // getFID(metric => this.track('fid', metric))
    // getFCP(metric => this.track('fcp', metric))
    // getLCP(metric => this.track('lcp', metric))
    // getTTFB(metric => this.track('ttfb', metric))
  }
  
  // 发送分析数据
  track(eventName, data) {
    const payload = {
      event: eventName,
      data: {
        ...data,
        url: location.href,
        userAgent: navigator.userAgent,
        timestamp: Date.now()
      }
    }
    
    // 发送到分析服务
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/analytics', JSON.stringify(payload))
    } else {
      fetch('/api/analytics', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(payload)
      }).catch(error => {
        console.error('Analytics tracking failed:', error)
      })
    }
  }
}

// 初始化分析
const pwaAnalytics = new PWAAnalytics()

🎯 最佳实践总结

开发建议

  1. 渐进增强

    • 确保基础功能在所有浏览器中可用
    • PWA 功能作为增强体验
    • 优雅降级处理
  2. 性能优化

    • 使用应用外壳架构
    • 实施智能缓存策略
    • 预加载关键资源
    • 代码分割和懒加载
  3. 用户体验

    • 提供离线功能
    • 快速响应用户交互
    • 清晰的加载状态指示
    • 网络状态反馈
  4. 安全性

    • 强制使用 HTTPS
    • 验证 Service Worker 更新
    • 保护敏感数据
    • 实施内容安全策略

测试策略

  1. 功能测试

    • 离线功能测试
    • 安装流程测试
    • 推送通知测试
    • 后台同步测试
  2. 性能测试

    • Lighthouse PWA 审计
    • 网络节流测试
    • 缓存效果验证
    • 内存使用监控
  3. 兼容性测试

    • 多浏览器测试
    • 移动设备测试
    • 不同网络条件测试
    • 操作系统集成测试

PWA 技术让 Web 应用具备了接近原生应用的能力,通过合理的架构设计和优化策略,可以为用户提供快速、可靠、引人入胜的体验。


拥抱 PWA,让 Web 应用无处不在!

vitepress开发指南