Skip to content

PWA (Progressive Web App)

渐进式 Web 应用开发相关工具和框架。

Vite PWA

基本信息

特点

  • ✅ 零配置,开箱即用
  • ✅ 自动生成 Service Worker
  • ✅ 自动生成 Web App Manifest
  • ✅ 支持离线访问
  • ✅ 支持预缓存
  • ✅ 支持 Workbox
  • ✅ TypeScript 支持

安装

bash
pnpm add -D vite-plugin-pwa
bash
bun add -D vite-plugin-pwa
bash
npm install -D vite-plugin-pwa
bash
yarn add -D vite-plugin-pwa

基础配置

typescript
// vite.config.ts
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
      manifest: {
        name: '我的应用',
        short_name: '应用',
        description: '我的 PWA 应用',
        theme_color: '#ffffff',
        background_color: '#ffffff',
        display: 'standalone',
        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,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 10,
                maxAgeSeconds: 60 * 60 * 24 // 24 hours
              },
              cacheableResponse: {
                statuses: [0, 200]
              }
            }
          }
        ]
      }
    })
  ]
})

Vue 3 集成

vue
<script setup lang="ts">
import { useRegisterSW } from 'virtual:pwa-register/vue'

const {
  needRefresh,
  updateServiceWorker,
} = useRegisterSW({
  onRegistered(r) {
    console.log('SW Registered:', r)
  },
  onRegisterError(error) {
    console.log('SW registration error', error)
  },
})

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

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

<template>
  <div v-if="needRefresh" class="pwa-toast">
    <div class="message">
      <span>发现新版本,点击更新</span>
    </div>
    <div class="buttons">
      <button @click="update">更新</button>
      <button @click="close">关闭</button>
    </div>
  </div>
</template>

<style scoped>
.pwa-toast {
  position: fixed;
  right: 0;
  bottom: 0;
  margin: 16px;
  padding: 12px;
  border: 1px solid #8885;
  border-radius: 4px;
  z-index: 1000;
  background: white;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.buttons {
  margin-top: 8px;
  display: flex;
  gap: 8px;
}

button {
  padding: 4px 12px;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
}
</style>

离线页面

typescript
// vite.config.ts
VitePWA({
  workbox: {
    navigateFallback: '/offline.html',
    navigateFallbackDenylist: [/^\/api/]
  }
})
html
<!-- public/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>离线页面</title>
  <style>
    body {
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100vh;
      margin: 0;
      font-family: system-ui, -apple-system, sans-serif;
      background: #f5f5f5;
    }
    .container {
      text-align: center;
      padding: 2rem;
    }
    h1 {
      color: #333;
      margin-bottom: 1rem;
    }
    p {
      color: #666;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>📡 当前离线</h1>
    <p>请检查网络连接后重试</p>
  </div>
</body>
</html>

缓存策略

typescript
// vite.config.ts
VitePWA({
  workbox: {
    runtimeCaching: [
      // 网络优先
      {
        urlPattern: /^https:\/\/api\.example\.com\/.*/i,
        handler: 'NetworkFirst',
        options: {
          cacheName: 'api-cache',
          networkTimeoutSeconds: 10,
          expiration: {
            maxEntries: 50,
            maxAgeSeconds: 60 * 60 * 24 // 1 day
          }
        }
      },
      // 缓存优先
      {
        urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
        handler: 'CacheFirst',
        options: {
          cacheName: 'image-cache',
          expiration: {
            maxEntries: 100,
            maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
          }
        }
      },
      // 仅网络
      {
        urlPattern: /^https:\/\/analytics\.example\.com\/.*/i,
        handler: 'NetworkOnly'
      },
      // 仅缓存
      {
        urlPattern: /^https:\/\/cdn\.example\.com\/static\/.*/i,
        handler: 'CacheOnly'
      },
      // 优先缓存,网络回退
      {
        urlPattern: /\.(?:js|css)$/,
        handler: 'StaleWhileRevalidate',
        options: {
          cacheName: 'static-resources'
        }
      }
    ]
  }
})

Workbox

基本信息

安装

bash
pnpm add workbox-window
bash
bun add workbox-window
bash
npm install workbox-window
bash
yarn add workbox-window

基础用法

typescript
import { Workbox } from 'workbox-window'

if ('serviceWorker' in navigator) {
  const wb = new Workbox('/sw.js')

  wb.addEventListener('installed', (event) => {
    if (event.isUpdate) {
      console.log('New service worker installed')
    }
  })

  wb.addEventListener('activated', (event) => {
    console.log('Service worker activated')
  })

  wb.register()
}

PWA Asset Generator

基本信息

安装

bash
npm install -g pwa-asset-generator

使用

bash
# 从单个图标生成所有尺寸
pwa-asset-generator logo.svg ./public/icons

# 生成带背景的图标
pwa-asset-generator logo.svg ./public/icons --background "#ffffff"

# 生成 iOS 启动画面
pwa-asset-generator logo.svg ./public/icons --splash-only

通知推送

Web Push

typescript
// 请求通知权限
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission()
  
  if (permission === 'granted') {
    console.log('Notification permission granted')
    
    // 订阅推送
    const registration = await navigator.serviceWorker.ready
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: 'YOUR_PUBLIC_VAPID_KEY'
    })
    
    // 发送订阅信息到服务器
    await fetch('/api/subscribe', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(subscription)
    })
  }
}

// 显示通知
function showNotification(title: string, options?: NotificationOptions) {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready.then((registration) => {
      registration.showNotification(title, {
        body: options?.body || '',
        icon: options?.icon || '/icon.png',
        badge: options?.badge || '/badge.png',
        vibrate: [200, 100, 200],
        tag: options?.tag || 'notification',
        requireInteraction: false,
        ...options
      })
    })
  }
}

Service Worker 中处理推送

typescript
// sw.js
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {}
  
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: data.icon || '/icon.png',
      badge: '/badge.png',
      data: data.url
    })
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  
  event.waitUntil(
    clients.openWindow(event.notification.data || '/')
  )
})

安装提示

自定义安装提示

typescript
let deferredPrompt: any

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 response: ${outcome}`)
  
  // 清除保存的事件
  deferredPrompt = null
  
  // 隐藏安装按钮
  hideInstallButton()
}

// 监听安装完成
window.addEventListener('appinstalled', () => {
  console.log('PWA installed successfully')
})

Vue 3 安装组件

vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'

const showInstall = ref(false)
let deferredPrompt: any = null

onMounted(() => {
  window.addEventListener('beforeinstallprompt', (e) => {
    e.preventDefault()
    deferredPrompt = e
    showInstall.value = true
  })
  
  window.addEventListener('appinstalled', () => {
    showInstall.value = false
    console.log('PWA installed')
  })
})

const install = async () => {
  if (!deferredPrompt) return
  
  deferredPrompt.prompt()
  const { outcome } = await deferredPrompt.userChoice
  
  if (outcome === 'accepted') {
    console.log('User accepted the install prompt')
  }
  
  deferredPrompt = null
  showInstall.value = false
}

const dismiss = () => {
  showInstall.value = false
}
</script>

<template>
  <div v-if="showInstall" class="install-banner">
    <div class="content">
      <p>安装应用到主屏幕,获得更好的体验</p>
      <div class="buttons">
        <button @click="install" class="install-btn">安装</button>
        <button @click="dismiss" class="dismiss-btn">稍后</button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.install-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  background: white;
  box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
  padding: 1rem;
  z-index: 1000;
}

.content {
  max-width: 600px;
  margin: 0 auto;
}

.buttons {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.5rem;
}

.install-btn {
  flex: 1;
  padding: 0.5rem 1rem;
  background: #0abab5;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.dismiss-btn {
  padding: 0.5rem 1rem;
  background: transparent;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
}
</style>

最佳实践

1. Manifest 配置

json
{
  "name": "我的应用",
  "short_name": "应用",
  "description": "应用描述",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#0abab5",
  "background_color": "#ffffff",
  "orientation": "portrait",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

2. 性能优化

  • 预缓存关键资源
  • 使用合适的缓存策略
  • 压缩静态资源
  • 懒加载非关键资源
  • 使用 CDN

3. 离线体验

  • 提供离线页面
  • 缓存 API 响应
  • 显示离线状态
  • 同步离线数据

4. 更新策略

  • 自动更新 Service Worker
  • 提示用户刷新
  • 优雅降级

检测工具

Lighthouse

Chrome DevTools 内置的 PWA 审计工具。

  1. 打开 Chrome DevTools
  2. 切换到 Lighthouse 标签
  3. 选择 "Progressive Web App"
  4. 点击 "Generate report"

PWA Builder

在线 PWA 检测和构建工具。

相关资源