PWA 应用完整实现指南

HTMLPAGE 团队
22分钟 分钟阅读

系统讲解渐进式 Web 应用(PWA)的核心技术与实现方案,涵盖 Service Worker、Web App Manifest、缓存策略、离线支持、推送通知、安装体验,以及在 Nuxt/Vite 项目中的实践。

#PWA #Service Worker #离线应用 #Web Manifest #缓存策略

PWA 应用完整实现指南

渐进式 Web 应用(Progressive Web App,PWA)结合了 Web 和原生应用的优势:可通过浏览器访问,又能安装到桌面,支持离线使用和推送通知。

对于希望以较低成本覆盖多平台的团队来说,PWA 是极具性价比的选择。本文将系统讲解 PWA 的核心技术和实现方法。


PWA 核心概念

什么是 PWA

PWA 不是单一技术,而是一组技术和最佳实践的集合:

特性实现技术作用
可安装Web App Manifest添加到主屏幕
离线可用Service Worker缓存资源和请求
推送通知Push API + Notification API用户重新激活
安全HTTPS必要条件
响应式CSS Media Queries适配各种设备

PWA 的优势

相比传统网站:

  • 可安装到主屏幕,像原生应用一样启动
  • 离线或弱网环境下仍可使用
  • 可发送推送通知

相比原生应用:

  • 无需应用商店审核
  • 一套代码多平台运行
  • 更新即时生效
  • 更小的安装体积

浏览器支持

功能ChromeSafariFirefoxEdge
Service Worker
Web App Manifest
Push Notifications⚠️ (iOS 16.4+)
Background Sync

Web App Manifest

Manifest 是一个 JSON 文件,定义应用的元数据。

基础配置

// public/manifest.json
{
  "name": "HTMLPAGE 应用",
  "short_name": "HTMLPAGE",
  "description": "让网页构建更简单",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4f46e5",
  "orientation": "portrait-primary",
  "scope": "/",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-maskable-512x512.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": "640x1136",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ],
  "shortcuts": [
    {
      "name": "新建项目",
      "short_name": "新建",
      "url": "/projects/new",
      "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
    },
    {
      "name": "我的模板",
      "short_name": "模板",
      "url": "/templates",
      "icons": [{ "src": "/icons/templates.png", "sizes": "96x96" }]
    }
  ]
}

关键属性说明

display 模式:

说明
fullscreen全屏,隐藏所有浏览器 UI
standalone独立窗口,像原生应用
minimal-ui保留最小导航控件
browser普通浏览器标签页

引用 Manifest:

<head>
  <link rel="manifest" href="/manifest.json">
  
  <!-- iOS 支持 -->
  <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="HTMLPAGE">
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
  
  <!-- 主题色 -->
  <meta name="theme-color" content="#4f46e5">
</head>

Service Worker

Service Worker 是 PWA 的核心,运行在独立线程中,可以拦截网络请求、管理缓存。

生命周期

注册 → 安装(install) → 等待(waiting) → 激活(activate) → 运行
                           ↓
                      刷新页面后激活

基础 Service Worker

// public/sw.js

const CACHE_NAME = 'app-cache-v1'
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/scripts/app.js',
  '/icons/icon-192x192.png'
]

// 安装事件:缓存静态资源
self.addEventListener('install', (event) => {
  console.log('[SW] Installing...')
  
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('[SW] Caching static assets')
        return cache.addAll(STATIC_ASSETS)
      })
      .then(() => {
        // 跳过等待,立即激活
        return self.skipWaiting()
      })
  )
})

// 激活事件:清理旧缓存
self.addEventListener('activate', (event) => {
  console.log('[SW] Activating...')
  
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => {
            console.log('[SW] Deleting old cache:', name)
            return caches.delete(name)
          })
      )
    }).then(() => {
      // 接管所有客户端
      return self.clients.claim()
    })
  )
})

// 请求拦截:实现缓存策略
self.addEventListener('fetch', (event) => {
  const { request } = event
  const url = new URL(request.url)
  
  // 只处理同源请求
  if (url.origin !== location.origin) {
    return
  }
  
  // API 请求:网络优先
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request))
    return
  }
  
  // 静态资源:缓存优先
  event.respondWith(cacheFirst(request))
})

// 缓存优先策略
async function cacheFirst(request) {
  const cached = await caches.match(request)
  if (cached) {
    return cached
  }
  
  try {
    const response = await fetch(request)
    // 缓存成功的响应
    if (response.ok) {
      const cache = await caches.open(CACHE_NAME)
      cache.put(request, response.clone())
    }
    return response
  } catch (error) {
    // 离线时返回离线页面
    return caches.match('/offline.html')
  }
}

// 网络优先策略
async function networkFirst(request) {
  try {
    const response = await fetch(request)
    // 缓存 API 响应
    if (response.ok) {
      const cache = await caches.open(CACHE_NAME)
      cache.put(request, response.clone())
    }
    return response
  } catch (error) {
    // 网络失败,尝试缓存
    const cached = await caches.match(request)
    if (cached) {
      return cached
    }
    // 返回错误响应
    return new Response(JSON.stringify({ error: 'Offline' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' }
    })
  }
}

注册 Service Worker

// scripts/register-sw.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)
      
      // 检测更新
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing
        
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            // 有新版本可用
            showUpdateNotification()
          }
        })
      })
      
    } catch (error) {
      console.error('SW registration failed:', error)
    }
  })
}

function showUpdateNotification() {
  // 显示更新提示
  const notification = document.createElement('div')
  notification.className = 'update-notification'
  notification.innerHTML = `
    <p>新版本可用</p>
    <button onclick="location.reload()">立即更新</button>
  `
  document.body.appendChild(notification)
}

缓存策略

常用缓存策略

策略说明适用场景
Cache First优先缓存,缓存没有再请求网络静态资源(CSS、JS、图片)
Network First优先网络,网络失败用缓存API 数据、动态内容
Stale While Revalidate立即返回缓存,同时后台更新需要快速响应但要保持新鲜
Network Only只用网络实时数据、表单提交
Cache Only只用缓存离线页面、预缓存资源

实现各种策略

// 策略工厂
const strategies = {
  // 缓存优先
  async cacheFirst(request, cacheName) {
    const cached = await caches.match(request)
    if (cached) return cached
    
    const response = await fetch(request)
    if (response.ok) {
      const cache = await caches.open(cacheName)
      cache.put(request, response.clone())
    }
    return response
  },
  
  // 网络优先
  async networkFirst(request, cacheName, timeout = 3000) {
    try {
      const response = await Promise.race([
        fetch(request),
        new Promise((_, reject) => 
          setTimeout(() => reject(new Error('Timeout')), timeout)
        )
      ])
      
      if (response.ok) {
        const cache = await caches.open(cacheName)
        cache.put(request, response.clone())
      }
      return response
    } catch {
      return caches.match(request)
    }
  },
  
  // 边返回边更新
  async staleWhileRevalidate(request, cacheName) {
    const cache = await caches.open(cacheName)
    const cached = await cache.match(request)
    
    // 后台更新
    const fetchPromise = fetch(request).then((response) => {
      if (response.ok) {
        cache.put(request, response.clone())
      }
      return response
    })
    
    // 有缓存立即返回,否则等待网络
    return cached || fetchPromise
  }
}

// 在 fetch 事件中使用
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  
  // 根据路径选择策略
  if (url.pathname.match(/\.(js|css|png|jpg|svg)$/)) {
    event.respondWith(strategies.cacheFirst(event.request, 'static-v1'))
  } else if (url.pathname.startsWith('/api/')) {
    event.respondWith(strategies.networkFirst(event.request, 'api-v1'))
  } else {
    event.respondWith(strategies.staleWhileRevalidate(event.request, 'pages-v1'))
  }
})

离线支持

离线页面

<!-- public/offline.html -->
<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>离线 - HTMLPAGE</title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      margin: 0;
      background: #f5f5f5;
    }
    .container {
      text-align: center;
      padding: 2rem;
    }
    .icon {
      font-size: 4rem;
      margin-bottom: 1rem;
    }
    h1 {
      color: #333;
      margin-bottom: 0.5rem;
    }
    p {
      color: #666;
      margin-bottom: 1.5rem;
    }
    button {
      padding: 0.75rem 1.5rem;
      background: #4f46e5;
      color: white;
      border: none;
      border-radius: 8px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="icon">📡</div>
    <h1>您当前处于离线状态</h1>
    <p>请检查网络连接后重试</p>
    <button onclick="location.reload()">重新加载</button>
  </div>
</body>
</html>

离线数据存储

// 使用 IndexedDB 存储离线数据
class OfflineStore {
  constructor(dbName, storeName) {
    this.dbName = dbName
    this.storeName = storeName
    this.db = null
  }
  
  async init() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1)
      
      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(this.storeName)) {
          db.createObjectStore(this.storeName, { keyPath: 'id' })
        }
      }
    })
  }
  
  async save(data) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction(this.storeName, 'readwrite')
      const store = tx.objectStore(this.storeName)
      const request = store.put(data)
      
      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    })
  }
  
  async get(id) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction(this.storeName, 'readonly')
      const store = tx.objectStore(this.storeName)
      const request = store.get(id)
      
      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    })
  }
  
  async getAll() {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction(this.storeName, 'readonly')
      const store = tx.objectStore(this.storeName)
      const request = store.getAll()
      
      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    })
  }
}

// 使用
const store = new OfflineStore('app-db', 'drafts')
await store.init()

// 保存草稿
await store.save({ id: 'draft-1', content: '...', updatedAt: Date.now() })

// 获取草稿
const draft = await store.get('draft-1')

安装体验

检测可安装性

// 保存安装提示事件
let deferredPrompt = null

window.addEventListener('beforeinstallprompt', (event) => {
  // 阻止默认的安装提示
  event.preventDefault()
  // 保存事件以便稍后使用
  deferredPrompt = event
  // 显示自定义安装按钮
  showInstallButton()
})

function showInstallButton() {
  const button = document.getElementById('install-button')
  if (button) {
    button.style.display = 'block'
    button.addEventListener('click', promptInstall)
  }
}

async function promptInstall() {
  if (!deferredPrompt) return
  
  // 显示安装提示
  deferredPrompt.prompt()
  
  // 等待用户响应
  const { outcome } = await deferredPrompt.userChoice
  console.log('Install outcome:', outcome)
  
  // 清理
  deferredPrompt = null
  hideInstallButton()
}

// 监听安装完成
window.addEventListener('appinstalled', () => {
  console.log('App was installed')
  // 可以发送分析事件
  analytics.track('pwa_installed')
})

检测运行模式

// 检测是否以 PWA 模式运行
function isPWA() {
  return (
    window.matchMedia('(display-mode: standalone)').matches ||
    window.navigator.standalone === true ||
    document.referrer.includes('android-app://')
  )
}

// 根据运行模式调整 UI
if (isPWA()) {
  // 隐藏"安装应用"提示
  document.getElementById('install-banner')?.remove()
  
  // 可能需要调整导航(PWA 没有浏览器返回按钮)
  showAppNavigation()
}

推送通知

请求通知权限

async function requestNotificationPermission() {
  // 检查浏览器支持
  if (!('Notification' in window)) {
    console.log('Browser does not support notifications')
    return false
  }
  
  // 已经有权限
  if (Notification.permission === 'granted') {
    return true
  }
  
  // 请求权限
  const permission = await Notification.requestPermission()
  return permission === 'granted'
}

订阅推送

async function subscribeToPush() {
  const hasPermission = await requestNotificationPermission()
  if (!hasPermission) return null
  
  const registration = await navigator.serviceWorker.ready
  
  // 获取推送订阅
  let subscription = await registration.pushManager.getSubscription()
  
  if (!subscription) {
    // 创建新订阅
    const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY'
    
    subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
    })
  }
  
  // 发送订阅信息到服务器
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  })
  
  return subscription
}

function 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
}

Service Worker 处理推送

// sw.js
self.addEventListener('push', (event) => {
  const data = event.data?.json() || {}
  
  const options = {
    body: data.body || '您有新消息',
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/'
    },
    actions: [
      { action: 'open', title: '查看' },
      { action: 'close', title: '关闭' }
    ]
  }
  
  event.waitUntil(
    self.registration.showNotification(data.title || '通知', options)
  )
})

// 处理通知点击
self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  
  if (event.action === 'close') return
  
  const url = event.notification.data.url
  
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((windowClients) => {
      // 如果已有窗口打开,聚焦并导航
      for (const client of windowClients) {
        if (client.url === url && 'focus' in client) {
          return client.focus()
        }
      }
      // 否则打开新窗口
      if (clients.openWindow) {
        return clients.openWindow(url)
      }
    })
  )
})

Nuxt 中实现 PWA

使用 @vite-pwa/nuxt

pnpm add @vite-pwa/nuxt -D
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@vite-pwa/nuxt'],
  
  pwa: {
    registerType: 'autoUpdate',
    
    manifest: {
      name: 'HTMLPAGE 应用',
      short_name: 'HTMLPAGE',
      description: '让网页构建更简单',
      theme_color: '#4f46e5',
      background_color: '#ffffff',
      display: 'standalone',
      icons: [
        {
          src: '/icons/icon-192x192.png',
          sizes: '192x192',
          type: 'image/png'
        },
        {
          src: '/icons/icon-512x512.png',
          sizes: '512x512',
          type: 'image/png'
        }
      ]
    },
    
    workbox: {
      // 预缓存的资源
      globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
      
      // 运行时缓存
      runtimeCaching: [
        {
          urlPattern: /^https:\/\/api\.example\.com\/.*/i,
          handler: 'NetworkFirst',
          options: {
            cacheName: 'api-cache',
            expiration: {
              maxEntries: 100,
              maxAgeSeconds: 60 * 60 * 24 // 1 天
            }
          }
        },
        {
          urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
          handler: 'CacheFirst',
          options: {
            cacheName: 'google-fonts',
            expiration: {
              maxEntries: 10,
              maxAgeSeconds: 60 * 60 * 24 * 365 // 1 年
            }
          }
        }
      ]
    },
    
    // 开发环境也启用
    devOptions: {
      enabled: true,
      type: 'module'
    }
  }
})

更新提示组件

<!-- components/PWAUpdatePrompt.vue -->
<script setup lang="ts">
const { needRefresh, updateServiceWorker } = useRegisterSW({
  onRegistered(registration) {
    // 定期检查更新
    setInterval(() => {
      registration?.update()
    }, 60 * 60 * 1000) // 每小时
  }
})

const close = () => {
  needRefresh.value = false
}

const update = () => {
  updateServiceWorker()
}
</script>

<template>
  <Teleport to="body">
    <Transition name="slide-up">
      <div v-if="needRefresh" class="pwa-update-prompt">
        <div class="content">
          <span>🔄 有新版本可用</span>
          <div class="actions">
            <button class="secondary" @click="close">稍后</button>
            <button class="primary" @click="update">立即更新</button>
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.pwa-update-prompt {
  position: fixed;
  bottom: 1rem;
  left: 50%;
  transform: translateX(-50%);
  background: white;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  padding: 1rem 1.5rem;
  z-index: 9999;
}

.content {
  display: flex;
  align-items: center;
  gap: 1rem;
}

.actions {
  display: flex;
  gap: 0.5rem;
}

button {
  padding: 0.5rem 1rem;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.875rem;
}

.primary {
  background: #4f46e5;
  color: white;
  border: none;
}

.secondary {
  background: transparent;
  border: 1px solid #e5e7eb;
}

.slide-up-enter-active,
.slide-up-leave-active {
  transition: all 0.3s ease;
}

.slide-up-enter-from,
.slide-up-leave-to {
  opacity: 0;
  transform: translateX(-50%) translateY(100%);
}
</style>

最佳实践

性能优化

  1. 精选预缓存资源
    // 只预缓存核心资源
    globPatterns: ['index.html', 'main.*.js', 'main.*.css']
    // 避免:globPatterns: ['**/*']
    
  2. 设置合理的缓存过期
    expiration: {
      maxEntries: 50,      // 限制条目数
      maxAgeSeconds: 86400 // 限制时间
    }
    
  3. 使用合适的缓存策略
    • 静态资源 → Cache First
    • API 数据 → Network First / Stale While Revalidate
    • 用户数据 → Network Only

用户体验

  1. 提供离线反馈
    window.addEventListener('online', () => showToast('已恢复网络连接'))
    window.addEventListener('offline', () => showToast('您已离线'))
    
  2. 优雅的更新提示
    • 不强制刷新,让用户选择
    • 说明更新内容
  3. 渐进增强
    • PWA 功能失败时不影响基本使用
    • 检测功能支持再启用

检查清单

  • HTTPS 已启用
  • Manifest 文件完整
  • 多尺寸图标(192x192、512x512、maskable)
  • Service Worker 正常注册
  • 离线页面可用
  • 缓存策略合理
  • 更新机制正常
  • Lighthouse PWA 审计通过

总结

PWA 让 Web 应用具备了类原生的体验:

  1. Manifest:定义应用元数据,支持安装
  2. Service Worker:实现离线缓存和推送
  3. 缓存策略:根据资源类型选择合适策略
  4. 安装体验:自定义安装提示,检测运行模式
  5. 推送通知:重新激活用户

PWA 不是全有或全无的选择,你可以根据需求逐步添加功能。从简单的离线支持开始,逐步完善到完整的 PWA 体验。