发布于

PWA 渐进式 Web 应用:打造原生应用体验的 Web 应用

作者

PWA 渐进式 Web 应用:打造原生应用体验的 Web 应用

PWA(Progressive Web App)是一种使用现代 Web 技术构建的应用程序,它结合了 Web 和原生应用的最佳特性。本文将深入探讨 PWA 的核心技术和实现方法。

PWA 核心概念

什么是 PWA?

PWA 是一种 Web 应用程序,它使用现代 Web 功能为用户提供类似原生应用的体验。PWA 具有以下特征:

  • 渐进式:适用于所有浏览器
  • 响应式:适配各种设备和屏幕尺寸
  • 离线功能:通过 Service Worker 实现离线访问
  • 类原生体验:具有应用外壳和全屏体验
  • 安全:通过 HTTPS 提供服务
  • 可安装:可以添加到主屏幕
  • 可更新:通过 Service Worker 自动更新

PWA 核心技术栈

// PWA 技术组成
const PWATechnologies = {
  serviceWorker: {
    purpose: '后台脚本,处理网络请求、缓存、推送通知',
    features: ['离线缓存', '后台同步', '推送通知', '网络代理'],
  },

  webAppManifest: {
    purpose: '应用元数据配置文件',
    features: ['应用图标', '启动画面', '显示模式', '主题颜色'],
  },

  cacheAPI: {
    purpose: '程序化缓存管理',
    features: ['缓存存储', '缓存更新', '缓存策略'],
  },

  webAPIs: {
    purpose: '现代浏览器 API',
    features: ['推送通知', '后台同步', '设备访问', '支付 API'],
  },
}

Service Worker 实现

1. Service Worker 注册

// main.js - 注册 Service Worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      })

      console.log('Service Worker 注册成功:', registration.scope)

      // 监听更新
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing

        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            // 有新版本可用
            showUpdateNotification()
          }
        })
      })
    } catch (error) {
      console.error('Service Worker 注册失败:', error)
    }
  })
}

// 显示更新通知
function showUpdateNotification() {
  const notification = document.createElement('div')
  notification.className = 'update-notification'
  notification.innerHTML = `
    <div class="notification-content">
      <p>发现新版本,是否立即更新?</p>
      <button onclick="updateApp()">立即更新</button>
      <button onclick="dismissUpdate()">稍后提醒</button>
    </div>
  `
  document.body.appendChild(notification)
}

// 更新应用
function updateApp() {
  if (navigator.serviceWorker.controller) {
    navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' })
  }
  window.location.reload()
}

2. Service Worker 核心逻辑

// sw.js - Service Worker 文件
const CACHE_NAME = 'pwa-app-v1.2.0'
const STATIC_CACHE = 'static-v1'
const DYNAMIC_CACHE = 'dynamic-v1'

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

// 安装事件 - 缓存静态资源
self.addEventListener('install', (event) => {
  console.log('Service Worker 安装中...')

  event.waitUntil(
    caches
      .open(STATIC_CACHE)
      .then((cache) => {
        console.log('缓存静态资源')
        return cache.addAll(STATIC_ASSETS)
      })
      .then(() => {
        // 强制激活新的 Service Worker
        return self.skipWaiting()
      })
  )
})

// 激活事件 - 清理旧缓存
self.addEventListener('activate', (event) => {
  console.log('Service Worker 激活中...')

  event.waitUntil(
    caches
      .keys()
      .then((cacheNames) => {
        return Promise.all(
          cacheNames.map((cacheName) => {
            if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
              console.log('删除旧缓存:', cacheName)
              return caches.delete(cacheName)
            }
          })
        )
      })
      .then(() => {
        // 立即控制所有客户端
        return self.clients.claim()
      })
  )
})

// 拦截网络请求
self.addEventListener('fetch', (event) => {
  const { request } = event
  const url = new URL(request.url)

  // 跳过非 HTTP 请求
  if (!url.protocol.startsWith('http')) {
    return
  }

  event.respondWith(handleRequest(request))
})

// 请求处理策略
async function handleRequest(request) {
  const url = new URL(request.url)

  // 静态资源 - Cache First 策略
  if (STATIC_ASSETS.includes(url.pathname)) {
    return cacheFirst(request)
  }

  // API 请求 - Network First 策略
  if (url.pathname.startsWith('/api/')) {
    return networkFirst(request)
  }

  // 页面请求 - Stale While Revalidate 策略
  if (request.mode === 'navigate') {
    return staleWhileRevalidate(request)
  }

  // 其他资源 - Cache First 策略
  return cacheFirst(request)
}

// Cache First 策略
async function cacheFirst(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) {
    // 网络失败,返回离线页面
    if (request.mode === 'navigate') {
      return caches.match('/offline.html')
    }
    throw error
  }
}

// Network First 策略
async function networkFirst(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) {
    // 网络失败,尝试从缓存获取
    const cachedResponse = await caches.match(request)
    if (cachedResponse) {
      return cachedResponse
    }
    throw error
  }
}

// Stale While Revalidate 策略
async function staleWhileRevalidate(request) {
  const cache = await caches.open(DYNAMIC_CACHE)
  const cachedResponse = await cache.match(request)

  // 后台更新缓存
  const networkResponsePromise = fetch(request)
    .then((networkResponse) => {
      if (networkResponse.ok) {
        cache.put(request, networkResponse.clone())
      }
      return networkResponse
    })
    .catch(() => cachedResponse)

  // 返回缓存的响应或等待网络响应
  return cachedResponse || networkResponsePromise
}

// 处理消息
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting()
  }
})

Web App Manifest

1. Manifest 配置

{
  "name": "PWA 示例应用",
  "short_name": "PWA App",
  "description": "一个功能完整的 PWA 示例应用",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "theme_color": "#1890ff",
  "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"],
  "shortcuts": [
    {
      "name": "新建任务",
      "short_name": "新建",
      "description": "快速创建新任务",
      "url": "/tasks/new",
      "icons": [
        {
          "src": "/images/shortcut-new.png",
          "sizes": "96x96"
        }
      ]
    },
    {
      "name": "我的任务",
      "short_name": "任务",
      "description": "查看我的任务列表",
      "url": "/tasks/my",
      "icons": [
        {
          "src": "/images/shortcut-tasks.png",
          "sizes": "96x96"
        }
      ]
    }
  ]
}

2. HTML 中的 Manifest 引用

<!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 Manifest -->
    <link rel="manifest" href="/manifest.json" />

    <!-- iOS Safari 支持 -->
    <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" />
    <link rel="apple-touch-icon" href="/images/icon-192.png" />

    <!-- 主题颜色 -->
    <meta name="theme-color" content="#1890ff" />
    <meta name="msapplication-TileColor" content="#1890ff" />

    <!-- 其他图标 -->
    <link rel="icon" type="image/png" sizes="32x32" href="/images/icon-32.png" />
    <link rel="icon" type="image/png" sizes="16x16" href="/images/icon-16.png" />
  </head>
  <body>
    <div id="app"></div>

    <!-- 安装提示 -->
    <div id="install-prompt" class="install-prompt hidden">
      <div class="prompt-content">
        <h3>安装应用</h3>
        <p>将此应用添加到主屏幕以获得更好的体验</p>
        <button id="install-button">安装</button>
        <button id="dismiss-button">稍后</button>
      </div>
    </div>

    <script src="/js/app.js"></script>
  </body>
</html>

推送通知

1. 推送通知注册

// 推送通知管理
class PushNotificationManager {
  constructor() {
    this.vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY'
  }

  // 请求通知权限
  async requestPermission() {
    if (!('Notification' in window)) {
      throw new Error('此浏览器不支持通知')
    }

    const permission = await Notification.requestPermission()

    if (permission !== 'granted') {
      throw new Error('用户拒绝了通知权限')
    }

    return permission
  }

  // 订阅推送通知
  async subscribe() {
    try {
      await this.requestPermission()

      const registration = await navigator.serviceWorker.ready

      const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
      })

      // 将订阅信息发送到服务器
      await this.sendSubscriptionToServer(subscription)

      return subscription
    } catch (error) {
      console.error('订阅推送通知失败:', error)
      throw error
    }
  }

  // 取消订阅
  async unsubscribe() {
    try {
      const registration = await navigator.serviceWorker.ready
      const subscription = await registration.pushManager.getSubscription()

      if (subscription) {
        await subscription.unsubscribe()
        await this.removeSubscriptionFromServer(subscription)
      }
    } catch (error) {
      console.error('取消订阅失败:', error)
      throw error
    }
  }

  // 发送订阅信息到服务器
  async sendSubscriptionToServer(subscription) {
    const response = await fetch('/api/push/subscribe', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(subscription),
    })

    if (!response.ok) {
      throw new Error('发送订阅信息失败')
    }
  }

  // 从服务器移除订阅信息
  async removeSubscriptionFromServer(subscription) {
    await fetch('/api/push/unsubscribe', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(subscription),
    })
  }

  // 转换 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
  }
}

// 使用推送通知
const pushManager = new PushNotificationManager()

document.getElementById('enable-notifications').addEventListener('click', async () => {
  try {
    await pushManager.subscribe()
    console.log('推送通知已启用')
  } catch (error) {
    console.error('启用推送通知失败:', error)
  }
})

2. Service Worker 中处理推送

// sw.js 中处理推送事件
self.addEventListener('push', (event) => {
  console.log('收到推送消息')

  let notificationData = {
    title: '新消息',
    body: '您有一条新消息',
    icon: '/images/icon-192.png',
    badge: '/images/badge.png',
    tag: 'default',
    requireInteraction: false,
    actions: [
      {
        action: 'view',
        title: '查看',
        icon: '/images/view-icon.png',
      },
      {
        action: 'dismiss',
        title: '忽略',
        icon: '/images/dismiss-icon.png',
      },
    ],
  }

  if (event.data) {
    try {
      const data = event.data.json()
      notificationData = { ...notificationData, ...data }
    } catch (error) {
      console.error('解析推送数据失败:', error)
    }
  }

  event.waitUntil(self.registration.showNotification(notificationData.title, notificationData))
})

// 处理通知点击
self.addEventListener('notificationclick', (event) => {
  console.log('通知被点击:', event.notification.tag)

  event.notification.close()

  if (event.action === 'view') {
    // 打开应用
    event.waitUntil(clients.openWindow('/'))
  } else if (event.action === 'dismiss') {
    // 忽略通知
    console.log('用户忽略了通知')
  } else {
    // 默认行为:打开应用
    event.waitUntil(clients.openWindow('/'))
  }
})

离线功能实现

1. 离线页面

<!-- offline.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 App</title>
    <style>
      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        margin: 0;
        padding: 20px;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        color: white;
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
      }

      .offline-container {
        text-align: center;
        max-width: 400px;
      }

      .offline-icon {
        font-size: 64px;
        margin-bottom: 20px;
      }

      .retry-button {
        background: rgba(255, 255, 255, 0.2);
        border: 2px solid white;
        color: white;
        padding: 12px 24px;
        border-radius: 8px;
        cursor: pointer;
        font-size: 16px;
        margin-top: 20px;
      }

      .retry-button:hover {
        background: rgba(255, 255, 255, 0.3);
      }
    </style>
  </head>
  <body>
    <div class="offline-container">
      <div class="offline-icon">📱</div>
      <h1>您当前处于离线状态</h1>
      <p>请检查您的网络连接,然后重试。</p>
      <button class="retry-button" onclick="window.location.reload()">重新连接</button>
    </div>
  </body>
</html>

2. 离线数据同步

// 离线数据管理
class OfflineDataManager {
  constructor() {
    this.dbName = 'PWAOfflineDB'
    this.dbVersion = 1
    this.db = null
  }

  // 初始化数据库
  async init() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.dbVersion)

      request.onerror = () => reject(request.error)
      request.onsuccess = () => {
        this.db = request.result
        resolve(this.db)
      }

      request.onupgradeneeded = (event) => {
        const db = event.target.result

        // 创建对象存储
        if (!db.objectStoreNames.contains('tasks')) {
          const taskStore = db.createObjectStore('tasks', { keyPath: 'id' })
          taskStore.createIndex('status', 'status', { unique: false })
          taskStore.createIndex('createdAt', 'createdAt', { unique: false })
        }

        if (!db.objectStoreNames.contains('syncQueue')) {
          db.createObjectStore('syncQueue', { keyPath: 'id', autoIncrement: true })
        }
      }
    })
  }

  // 保存数据到本地
  async saveData(storeName, data) {
    const transaction = this.db.transaction([storeName], 'readwrite')
    const store = transaction.objectStore(storeName)
    return store.put(data)
  }

  // 从本地获取数据
  async getData(storeName, key) {
    const transaction = this.db.transaction([storeName], 'readonly')
    const store = transaction.objectStore(storeName)
    return store.get(key)
  }

  // 获取所有数据
  async getAllData(storeName) {
    const transaction = this.db.transaction([storeName], 'readonly')
    const store = transaction.objectStore(storeName)
    return store.getAll()
  }

  // 添加到同步队列
  async addToSyncQueue(action, data) {
    const syncItem = {
      action,
      data,
      timestamp: Date.now(),
      retries: 0,
    }

    return this.saveData('syncQueue', syncItem)
  }

  // 处理同步队列
  async processSyncQueue() {
    const syncItems = await this.getAllData('syncQueue')

    for (const item of syncItems) {
      try {
        await this.syncItem(item)
        await this.removeFromSyncQueue(item.id)
      } catch (error) {
        console.error('同步失败:', error)
        item.retries++

        if (item.retries < 3) {
          await this.saveData('syncQueue', item)
        } else {
          await this.removeFromSyncQueue(item.id)
        }
      }
    }
  }

  // 同步单个项目
  async syncItem(item) {
    const { action, data } = item

    switch (action) {
      case 'CREATE_TASK':
        await fetch('/api/tasks', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(data),
        })
        break

      case 'UPDATE_TASK':
        await fetch(`/api/tasks/${data.id}`, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(data),
        })
        break

      case 'DELETE_TASK':
        await fetch(`/api/tasks/${data.id}`, {
          method: 'DELETE',
        })
        break
    }
  }

  // 从同步队列中移除
  async removeFromSyncQueue(id) {
    const transaction = this.db.transaction(['syncQueue'], 'readwrite')
    const store = transaction.objectStore('syncQueue')
    return store.delete(id)
  }
}

// 在 Service Worker 中处理后台同步
self.addEventListener('sync', (event) => {
  if (event.tag === 'background-sync') {
    event.waitUntil(
      (async () => {
        const offlineManager = new OfflineDataManager()
        await offlineManager.init()
        await offlineManager.processSyncQueue()
      })()
    )
  }
})

应用安装

1. 安装提示

// 应用安装管理
class InstallManager {
  constructor() {
    this.deferredPrompt = null
    this.init()
  }

  init() {
    // 监听安装提示事件
    window.addEventListener('beforeinstallprompt', (event) => {
      console.log('可以安装 PWA')

      // 阻止默认的安装提示
      event.preventDefault()

      // 保存事件以便后续使用
      this.deferredPrompt = event

      // 显示自定义安装按钮
      this.showInstallButton()
    })

    // 监听应用安装事件
    window.addEventListener('appinstalled', () => {
      console.log('PWA 已安装')
      this.hideInstallButton()
      this.deferredPrompt = null
    })
  }

  // 显示安装按钮
  showInstallButton() {
    const installPrompt = document.getElementById('install-prompt')
    const installButton = document.getElementById('install-button')
    const dismissButton = document.getElementById('dismiss-button')

    if (installPrompt) {
      installPrompt.classList.remove('hidden')

      installButton.addEventListener('click', () => {
        this.promptInstall()
      })

      dismissButton.addEventListener('click', () => {
        this.hideInstallButton()
      })
    }
  }

  // 隐藏安装按钮
  hideInstallButton() {
    const installPrompt = document.getElementById('install-prompt')
    if (installPrompt) {
      installPrompt.classList.add('hidden')
    }
  }

  // 提示安装
  async promptInstall() {
    if (!this.deferredPrompt) {
      return
    }

    // 显示安装提示
    this.deferredPrompt.prompt()

    // 等待用户响应
    const { outcome } = await this.deferredPrompt.userChoice

    console.log(`用户选择: ${outcome}`)

    // 清理
    this.deferredPrompt = null
    this.hideInstallButton()
  }

  // 检查是否已安装
  isInstalled() {
    return (
      window.matchMedia('(display-mode: standalone)').matches ||
      window.navigator.standalone === true
    )
  }
}

// 初始化安装管理器
const installManager = new InstallManager()

// 检查是否在独立模式下运行
if (installManager.isInstalled()) {
  console.log('应用运行在独立模式下')
  document.body.classList.add('standalone')
}

性能优化

1. 资源预加载

// 资源预加载策略
class ResourcePreloader {
  constructor() {
    this.criticalResources = ['/css/critical.css', '/js/critical.js', '/images/hero.jpg']

    this.preloadResources = ['/css/app.css', '/js/app.js', '/images/icon-192.png']
  }

  // 预加载关键资源
  preloadCriticalResources() {
    this.criticalResources.forEach((resource) => {
      const link = document.createElement('link')
      link.rel = 'preload'
      link.href = resource

      if (resource.endsWith('.css')) {
        link.as = 'style'
      } else if (resource.endsWith('.js')) {
        link.as = 'script'
      } else if (resource.match(/\.(jpg|jpeg|png|webp)$/)) {
        link.as = 'image'
      }

      document.head.appendChild(link)
    })
  }

  // 预获取非关键资源
  prefetchResources() {
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        this.preloadResources.forEach((resource) => {
          const link = document.createElement('link')
          link.rel = 'prefetch'
          link.href = resource
          document.head.appendChild(link)
        })
      })
    }
  }

  // 预连接到外部域名
  preconnectToDomains() {
    const domains = ['https://api.example.com', 'https://cdn.example.com']

    domains.forEach((domain) => {
      const link = document.createElement('link')
      link.rel = 'preconnect'
      link.href = domain
      document.head.appendChild(link)
    })
  }
}

// 初始化资源预加载
const preloader = new ResourcePreloader()
preloader.preloadCriticalResources()
preloader.prefetchResources()
preloader.preconnectToDomains()

2. 性能监控

// PWA 性能监控
class PWAPerformanceMonitor {
  constructor() {
    this.metrics = {}
    this.init()
  }

  init() {
    // 监控页面加载性能
    window.addEventListener('load', () => {
      this.measurePageLoad()
    })

    // 监控 Service Worker 性能
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.addEventListener('message', (event) => {
        if (event.data.type === 'CACHE_HIT') {
          this.recordCacheHit(event.data.url)
        }
      })
    }
  }

  // 测量页面加载性能
  measurePageLoad() {
    if ('performance' in window) {
      const navigation = performance.getEntriesByType('navigation')[0]

      this.metrics.pageLoad = {
        dns: navigation.domainLookupEnd - navigation.domainLookupStart,
        tcp: navigation.connectEnd - navigation.connectStart,
        request: navigation.responseStart - navigation.requestStart,
        response: navigation.responseEnd - navigation.responseStart,
        dom: navigation.domContentLoadedEventEnd - navigation.responseEnd,
        load: navigation.loadEventEnd - navigation.loadEventStart,
        total: navigation.loadEventEnd - navigation.navigationStart,
      }

      console.log('页面加载性能:', this.metrics.pageLoad)
      this.sendMetrics()
    }
  }

  // 记录缓存命中
  recordCacheHit(url) {
    if (!this.metrics.cacheHits) {
      this.metrics.cacheHits = {}
    }

    this.metrics.cacheHits[url] = (this.metrics.cacheHits[url] || 0) + 1
  }

  // 发送性能指标
  sendMetrics() {
    if (navigator.onLine) {
      fetch('/api/metrics', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          metrics: this.metrics,
          timestamp: Date.now(),
          userAgent: navigator.userAgent,
        }),
      }).catch((error) => {
        console.error('发送性能指标失败:', error)
      })
    }
  }
}

// 初始化性能监控
const performanceMonitor = new PWAPerformanceMonitor()

总结

PWA 技术为 Web 应用提供了接近原生应用的体验:

  1. 离线功能:通过 Service Worker 实现离线访问
  2. 可安装性:可以添加到主屏幕,像原生应用一样启动
  3. 推送通知:支持后台推送通知
  4. 响应式设计:适配各种设备和屏幕尺寸
  5. 安全性:必须通过 HTTPS 提供服务
  6. 渐进式增强:在支持的浏览器中提供更好的体验

掌握 PWA 技术,你就能构建出功能强大、用户体验优秀的现代 Web 应用!


PWA 是 Web 应用的未来发展方向,值得深入学习和实践。