Vue PWA开发踩坑

21.1 Vue PWA配置的常见错误

核心知识点

在配置Vue PWA时,常见的错误包括:

  1. manifest.json配置错误:Web App Manifest配置不当
  2. service-worker.js配置错误:Service Worker配置不当
  3. PWA插件配置错误:Vue PWA插件配置错误
  4. HTTPS配置:缺少HTTPS配置

实用案例分析

错误场景:manifest.json配置错误

// 错误示例:manifest.json配置不当
{
  "name": "Vue PWA App",
  "short_name": "PWA App",
  "description": "A Vue PWA application",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4CAF50",
  "icons": [
    {
      "src": "img/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ]
  // 错误:缺少必要的图标尺寸和方向配置
}

正确实现

// 正确示例:manifest.json配置
{
  "name": "Vue PWA App",
  "short_name": "PWA App",
  "description": "A Vue PWA application",
  "start_url": "/",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#ffffff",
  "theme_color": "#4CAF50",
  "icons": [
    {
      "src": "img/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "img/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "img/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "img/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "img/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "img/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "img/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "img/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "shortcuts": [
    {
      "name": "首页",
      "short_name": "首页",
      "description": "跳转到首页",
      "url": "/",
      "icons": [{ "src": "img/icons/icon-192x192.png", "sizes": "192x192" }]
    },
    {
      "name": "关于",
      "short_name": "关于",
      "description": "跳转到关于页面",
      "url": "/about",
      "icons": [{ "src": "img/icons/icon-192x192.png", "sizes": "192x192" }]
    }
  ]
}

错误场景:PWA插件配置错误

// 错误示例:Vue PWA插件配置错误
// vue.config.js
module.exports = {
  pwa: {
    // 错误:配置不完整
    name: 'Vue PWA App',
    themeColor: '#4CAF50'
  }
}

正确实现

// 正确示例:Vue PWA插件配置
// vue.config.js
module.exports = {
  pwa: {
    name: 'Vue PWA App',
    shortName: 'PWA App',
description: 'A Vue PWA application',
    themeColor: '#4CAF50',
    msTileColor: '#4CAF50',
    appleMobileWebAppCapable: 'yes',
    appleMobileWebAppStatusBarStyle: 'black-translucent',
    // 配置manifest.json
    manifestOptions: {
      name: 'Vue PWA App',
      short_name: 'PWA App',
description: 'A Vue PWA application',
      start_url: '/',
      display: 'standalone',
      background_color: '#ffffff',
      theme_color: '#4CAF50',
      icons: [
        {
          src: './img/icons/icon-192x192.png',
          sizes: '192x192',
          type: 'image/png'
        },
        {
          src: './img/icons/icon-512x512.png',
          sizes: '512x512',
          type: 'image/png'
        }
      ]
    },
    // 配置Service Worker
    workboxOptions: {
      cleanupOutdatedCaches: true,
      skipWaiting: true,
      clientsClaim: true,
      runtimeCaching: [
        {
          urlPattern: new RegExp('^https://api\.example\.com/'),
          handler: 'NetworkFirst',
          options: {
            cacheName: 'api-cache',
            networkTimeoutSeconds: 10,
            expiration: {
              maxEntries: 100,
              maxAgeSeconds: 60 * 60 * 24 * 7 // 7天
            },
            cacheableResponse: {
              statuses: [0, 200]
            }
          }
        }
      ]
    }
  }
}

21.2 Vue PWA缓存策略的陷阱

核心知识点

在配置Vue PWA缓存策略时,常见的陷阱包括:

  1. 缓存策略选择:选择不适合的缓存策略
  2. 缓存大小限制:未设置合理的缓存大小限制
  3. 缓存过期:缓存过期策略不当
  4. 缓存清理:未正确清理过期缓存

实用案例分析

错误场景:缓存策略选择错误

// 错误示例:缓存策略选择不当
// vue.config.js
module.exports = {
  pwa: {
    workboxOptions: {
      // 错误:对所有资源使用相同的缓存策略
      runtimeCaching: [
        {
          urlPattern: new RegExp('^https://'),
          handler: 'CacheFirst', // 错误:对API请求使用CacheFirst
          options: {
            cacheName: 'all-cache'
          }
        }
      ]
    }
  }
}

正确实现

// 正确示例:选择合适的缓存策略
// vue.config.js
module.exports = {
  pwa: {
    workboxOptions: {
      runtimeCaching: [
        // API请求:NetworkFirst策略
        {
          urlPattern: new RegExp('^https://api\.example\.com/'),
          handler: 'NetworkFirst',
          options: {
            cacheName: 'api-cache',
            networkTimeoutSeconds: 10,
            expiration: {
              maxEntries: 100,
              maxAgeSeconds: 60 * 60 * 24 * 7 // 7天
            }
          }
        },
        // 静态资源:CacheFirst策略
        {
          urlPattern: new RegExp('\\.(?:png|jpg|jpeg|svg|gif)$'),
          handler: 'CacheFirst',
          options: {
            cacheName: 'image-cache',
            expiration: {
              maxEntries: 60,
              maxAgeSeconds: 60 * 60 * 24 * 30 // 30天
            }
          }
        },
        // 字体:StaleWhileRevalidate策略
        {
          urlPattern: new RegExp('\\.(?:woff|woff2|ttf|otf|eot)$'),
          handler: 'StaleWhileRevalidate',
          options: {
            cacheName: 'font-cache',
            expiration: {
              maxEntries: 30,
              maxAgeSeconds: 60 * 60 * 24 * 365 // 1年
            }
          }
        }
      ]
    }
  }
}

错误场景:缓存大小限制和过期策略不当

// 错误示例:缓存大小和过期策略不当
// service-worker.js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('app-cache').then((cache) => {
      // 错误:缓存过多资源,无大小限制
      return cache.addAll([
        '/',
        '/index.html',
        '/css/app.css',
        '/js/app.js',
        '/img/large-image.jpg',
        '/font/large-font.woff2'
      ])
    })
  )
})

正确实现

// 正确示例:合理设置缓存大小和过期策略
// service-worker.js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('app-cache').then((cache) => {
      // 正确:只缓存必要的资源
      return cache.addAll([
        '/',
        '/index.html',
        '/css/app.css',
        '/js/app.js'
      ])
    })
  )
})

// 正确:清理过期缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== 'app-cache' && cacheName !== 'api-cache') {
            return caches.delete(cacheName)
          }
        })
      )
    })
  )
})

// 正确:实现缓存过期检查
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      if (response) {
        // 检查缓存是否过期
        const cacheControl = response.headers.get('Cache-Control')
        const maxAge = cacheControl ? parseInt(cacheControl.split('max-age=')[1]) : 86400
        const date = new Date(response.headers.get('Date'))
        const now = new Date()
        const age = (now - date) / 1000
        
        if (age > maxAge) {
          // 缓存过期,从网络获取
          return fetch(event.request).then((networkResponse) => {
            caches.open('app-cache').then((cache) => {
              cache.put(event.request, networkResponse.clone())
            })
            return networkResponse
          })
        }
        return response
      }
      return fetch(event.request).then((networkResponse) => {
        caches.open('app-cache').then((cache) => {
          cache.put(event.request, networkResponse.clone())
        })
        return networkResponse
      })
    })
  )
})

21.3 Vue PWA安装体验的使用误区

核心知识点

在优化Vue PWA安装体验时,常见的误区包括:

  1. 安装提示时机:安装提示时机不当
  2. 安装提示频率:安装提示频率过高
  3. 安装体验优化:未优化安装体验
  4. 安装状态管理:安装状态管理不当

实用案例分析

错误场景:安装提示时机不当

// 错误示例:安装提示时机不当
// 组件中
<template>
  <div>
    <button v-if="deferredPrompt" @click="installApp">安装应用</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      deferredPrompt: null
    }
  },
  mounted() {
    // 错误:在组件挂载时立即提示安装
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()
      this.deferredPrompt = e
      // 错误:立即显示安装按钮,不考虑用户体验
      this.showInstallButton = true
    })
  },
  methods: {
    async installApp() {
      if (this.deferredPrompt) {
        this.deferredPrompt.prompt()
        const { outcome } = await this.deferredPrompt.userChoice
        console.log(`用户选择: ${outcome}`)
        this.deferredPrompt = null
      }
    }
  }
}
</script>

正确实现

// 正确示例:优化安装提示时机
// 组件中
<template>
  <div>
    <button v-if="showInstallButton" @click="installApp">安装应用</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      deferredPrompt: null,
      showInstallButton: false,
      installAttempts: 0,
      maxInstallAttempts: 3
    }
  },
  mounted() {
    // 正确:监听beforeinstallprompt事件
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()
      this.deferredPrompt = e
      // 正确:在合适的时机显示安装按钮
      // 例如,用户访问多次后或完成某些操作后
      this.checkInstallPrompt()
    })
    
    // 监听安装完成事件
    window.addEventListener('appinstalled', (evt) => {
      console.log('应用已安装')
      this.deferredPrompt = null
      this.showInstallButton = false
    })
  },
  methods: {
    checkInstallPrompt() {
      // 检查用户访问次数
      const visitCount = parseInt(localStorage.getItem('visitCount') || '0') + 1
      localStorage.setItem('visitCount', visitCount.toString())
      
      // 检查是否已安装
      const isInstalled = window.matchMedia('(display-mode: standalone)').matches
      
      // 正确:在用户访问多次且未安装时显示安装提示
      if (visitCount >= 3 && !isInstalled && this.installAttempts < this.maxInstallAttempts) {
        this.showInstallButton = true
      }
    },
    async installApp() {
      if (this.deferredPrompt) {
        this.installAttempts++
        this.deferredPrompt.prompt()
        const { outcome } = await this.deferredPrompt.userChoice
        console.log(`用户选择: ${outcome}`)
        this.deferredPrompt = null
        this.showInstallButton = false
      }
    }
  }
}
</script>

// 或使用Intersection Observer在用户滚动到特定位置时显示
<template>
  <div>
    <div ref="installTrigger"></div>
    <button v-if="showInstallButton" @click="installApp">安装应用</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      deferredPrompt: null,
      showInstallButton: false
    }
  },
  mounted() {
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()
      this.deferredPrompt = e
    })
    
    // 使用Intersection Observer
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting && this.deferredPrompt) {
          this.showInstallButton = true
          observer.unobserve(entry.target)
        }
      })
    })
    
    if (this.$refs.installTrigger) {
      observer.observe(this.$refs.installTrigger)
    }
  }
}
</script>

错误场景:安装体验优化不当

// 错误示例:安装体验优化不当
// 组件中
<template>
  <button @click="installApp">安装应用</button>
</template>

<script>
export default {
  methods: {
    installApp() {
      // 错误:直接提示安装,无引导和说明
      if (this.deferredPrompt) {
        this.deferredPrompt.prompt()
      }
    }
  }
}
</script>

正确实现

// 正确示例:优化安装体验
// 组件中
<template>
  <div>
    <!-- 安装提示模态框 -->
    <div v-if="showInstallModal" class="install-modal">
      <div class="install-modal-content">
        <h3>安装我们的应用</h3>
        <p>将应用添加到主屏幕,获得更好的使用体验:</p>
        <ul>
          <li>离线访问</li>
          <li>更快的加载速度</li>
          <li>类似原生应用的体验</li>
        </ul>
        <div class="install-modal-buttons">
          <button @click="installApp">立即安装</button>
          <button @click="cancelInstall">稍后再说</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      deferredPrompt: null,
      showInstallModal: false
    }
  },
  mounted() {
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()
      this.deferredPrompt = e
    })
  },
  methods: {
    showInstallPrompt() {
      if (this.deferredPrompt) {
        this.showInstallModal = true
      }
    },
    async installApp() {
      if (this.deferredPrompt) {
        this.showInstallModal = false
        this.deferredPrompt.prompt()
        const { outcome } = await this.deferredPrompt.userChoice
        console.log(`用户选择: ${outcome}`)
        this.deferredPrompt = null
      }
    },
    cancelInstall() {
      this.showInstallModal = false
      // 记录用户取消安装,一段时间内不再提示
      localStorage.setItem('installCancelled', Date.now().toString())
    }
  }
}
</script>

<style scoped>
.install-modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.install-modal-content {
  background-color: white;
  padding: 20px;
  border-radius: 8px;
  max-width: 400px;
  width: 90%;
}

.install-modal-buttons {
  display: flex;
  gap: 10px;
  margin-top: 20px;
  justify-content: flex-end;
}
</style>

21.3 Vue PWA安装体验的使用误区

核心知识点

在优化Vue PWA安装体验时,常见的误区包括:

  1. 安装提示时机:安装提示时机不当
  2. 安装提示频率:安装提示频率过高
  3. 安装体验优化:未优化安装体验
  4. 安装状态管理:安装状态管理不当

实用案例分析

错误场景:安装提示时机不当

// 错误示例:安装提示时机不当
// 组件中
<template>
  <div>
    <button v-if="deferredPrompt" @click="installApp">安装应用</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      deferredPrompt: null
    }
  },
  mounted() {
    // 错误:在组件挂载时立即提示安装
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()
      this.deferredPrompt = e
      // 错误:立即显示安装按钮,不考虑用户体验
      this.showInstallButton = true
    })
  },
  methods: {
    async installApp() {
      if (this.deferredPrompt) {
        this.deferredPrompt.prompt()
        const { outcome } = await this.deferredPrompt.userChoice
        console.log(`用户选择: ${outcome}`)
        this.deferredPrompt = null
      }
    }
  }
}
</script>

正确实现

// 正确示例:优化安装提示时机
// 组件中
<template>
  <div>
    <button v-if="showInstallButton" @click="installApp">安装应用</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      deferredPrompt: null,
      showInstallButton: false,
      installAttempts: 0,
      maxInstallAttempts: 3
    }
  },
  mounted() {
    // 正确:监听beforeinstallprompt事件
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()
      this.deferredPrompt = e
      // 正确:在合适的时机显示安装按钮
      // 例如,用户访问多次后或完成某些操作后
      this.checkInstallPrompt()
    })
    
    // 监听安装完成事件
    window.addEventListener('appinstalled', (evt) => {
      console.log('应用已安装')
      this.deferredPrompt = null
      this.showInstallButton = false
    })
  },
  methods: {
    checkInstallPrompt() {
      // 检查用户访问次数
      const visitCount = parseInt(localStorage.getItem('visitCount') || '0') + 1
      localStorage.setItem('visitCount', visitCount.toString())
      
      // 检查是否已安装
      const isInstalled = window.matchMedia('(display-mode: standalone)').matches
      
      // 正确:在用户访问多次且未安装时显示安装提示
      if (visitCount >= 3 && !isInstalled && this.installAttempts < this.maxInstallAttempts) {
        this.showInstallButton = true
      }
    },
    async installApp() {
      if (this.deferredPrompt) {
        this.installAttempts++
        this.deferredPrompt.prompt()
        const { outcome } = await this.deferredPrompt.userChoice
        console.log(`用户选择: ${outcome}`)
        this.deferredPrompt = null
        this.showInstallButton = false
      }
    }
  }
}
</script>

// 或使用Intersection Observer在用户滚动到特定位置时显示
<template>
  <div>
    <div ref="installTrigger"></div>
    <button v-if="showInstallButton" @click="installApp">安装应用</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      deferredPrompt: null,
      showInstallButton: false
    }
  },
  mounted() {
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()
      this.deferredPrompt = e
    })
    
    // 使用Intersection Observer
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting && this.deferredPrompt) {
          this.showInstallButton = true
          observer.unobserve(entry.target)
        }
      })
    })
    
    if (this.$refs.installTrigger) {
      observer.observe(this.$refs.installTrigger)
    }
  }
}
</script>

错误场景:安装体验优化不当

// 错误示例:安装体验优化不当
// 组件中
<template>
  <button @click="installApp">安装应用</button>
</template>

<script>
export default {
  methods: {
    installApp() {
      // 错误:直接提示安装,无引导和说明
      if (this.deferredPrompt) {
        this.deferredPrompt.prompt()
      }
    }
  }
}
</script>

正确实现

// 正确示例:优化安装体验
// 组件中
<template>
  <div>
    <!-- 安装提示模态框 -->
    <div v-if="showInstallModal" class="install-modal">
      <div class="install-modal-content">
        <h3>安装我们的应用</h3>
        <p>将应用添加到主屏幕,获得更好的使用体验:</p>
        <ul>
          <li>离线访问</li>
          <li>更快的加载速度</li>
          <li>类似原生应用的体验</li>
        </ul>
        <div class="install-modal-buttons">
          <button @click="installApp">立即安装</button>
          <button @click="cancelInstall">稍后再说</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      deferredPrompt: null,
      showInstallModal: false
    }
  },
  mounted() {
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()
      this.deferredPrompt = e
    })
  },
  methods: {
    showInstallPrompt() {
      if (this.deferredPrompt) {
        this.showInstallModal = true
      }
    },
    async installApp() {
      if (this.deferredPrompt) {
        this.showInstallModal = false
        this.deferredPrompt.prompt()
        const { outcome } = await this.deferredPrompt.userChoice
        console.log(`用户选择: ${outcome}`)
        this.deferredPrompt = null
      }
    },
    cancelInstall() {
      this.showInstallModal = false
      // 记录用户取消安装,一段时间内不再提示
      localStorage.setItem('installCancelled', Date.now().toString())
    }
  }
}
</script>

<style scoped>
.install-modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.install-modal-content {
  background-color: white;
  padding: 20px;
  border-radius: 8px;
  max-width: 400px;
  width: 90%;
}

.install-modal-buttons {
  display: flex;
  gap: 10px;
  margin-top: 20px;
  justify-content: flex-end;
}
</style>

21.4 Vue PWA推送通知的常见问题

核心知识点

在实现Vue PWA推送通知时,常见的问题包括:

  1. 推送通知权限:权限申请不当
  2. 推送服务配置:推送服务配置错误
  3. 消息处理:推送消息处理不当
  4. 离线推送:离线状态下的推送处理

实用案例分析

错误场景:推送通知权限申请不当

// 错误示例:推送通知权限申请不当
// 组件中
<template>
  <button @click="requestNotificationPermission">启用通知</button>
</template>

<script>
export default {
  methods: {
    async requestNotificationPermission() {
      // 错误:直接请求权限,不解释用途
      const permission = await Notification.requestPermission()
      console.log(`通知权限: ${permission}`)
    }
  }
}
</script>

正确实现

// 正确示例:优化推送通知权限申请
// 组件中
<template>
  <div>
    <button v-if="!notificationPermission" @click="requestNotificationPermission">启用通知</button>
    <p v-if="notificationPermission === 'granted'">通知已启用</p>
    <p v-if="notificationPermission === 'denied'">通知已被拒绝</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      notificationPermission: null
    }
  },
  mounted() {
    // 检查现有权限
    this.checkNotificationPermission()
  },
  methods: {
    checkNotificationPermission() {
      if ('Notification' in window) {
        this.notificationPermission = Notification.permission
      }
    },
    async requestNotificationPermission() {
      // 正确:先解释通知用途
      if ('Notification' in window) {
        const shouldRequest = confirm('启用通知后,您将收到以下类型的通知:\n- 新消息提醒\n- 活动更新\n- 重要通知')
        
        if (shouldRequest) {
          const permission = await Notification.requestPermission()
          this.notificationPermission = permission
          console.log(`通知权限: ${permission}`)
          
          if (permission === 'granted') {
            // 订阅推送服务
            this.subscribeToPush()
          }
        }
      } else {
        alert('您的浏览器不支持通知功能')
      }
    },
    async subscribeToPush() {
      if ('serviceWorker' in navigator && 'PushManager' in window) {
        const registration = await navigator.serviceWorker.ready
        try {
          const subscription = await registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: this.urlBase64ToUint8Array('YOUR_PUBLIC_VAPID_KEY')
          })
          console.log('推送订阅成功:', subscription)
          // 将订阅信息发送到服务器
          this.sendSubscriptionToServer(subscription)
        } catch (error) {
          console.error('推送订阅失败:', error)
        }
      }
    },
    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
    },
    sendSubscriptionToServer(subscription) {
      // 发送订阅信息到服务器
      fetch('/api/push-subscribe', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(subscription)
      })
    }
  }
}
</script>

错误场景:推送消息处理不当

// 错误示例:推送消息处理不当
// service-worker.js
self.addEventListener('push', (event) => {
  // 错误:未处理推送消息
  console.log('收到推送消息:', event)
})

正确实现

// 正确示例:处理推送消息
// service-worker.js
self.addEventListener('push', (event) => {
  if (!event.data) return
  
  try {
    const data = event.data.json()
    const options = {
      body: data.body,
      icon: '/img/icons/icon-192x192.png',
      badge: '/img/icons/badge-72x72.png',
      vibrate: [100, 50, 100],
      data: {
        url: data.url || '/'
      },
      actions: [
        {
          action: 'view',
          title: '查看详情'
        },
        {
          action: 'close',
          title: '关闭'
        }
      ]
    }
    
    event.waitUntil(
      self.registration.showNotification(data.title, options)
    )
  } catch (error) {
    console.error('处理推送消息失败:', error)
  }
})

// 处理通知点击
self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  
  if (event.action === 'view') {
    const urlToOpen = event.notification.data.url || '/'
    
    event.waitUntil(
      clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
        // 检查是否已有打开的窗口
        for (let client of windowClients) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus()
          }
        }
        // 打开新窗口
        if (clients.openWindow) {
          return clients.openWindow(urlToOpen)
        }
      })
    )
  }
})

// 处理通知关闭
self.addEventListener('notificationclose', (event) => {
  console.log('通知已关闭')
})

21.5 Vue PWA离线功能的陷阱

核心知识点

在实现Vue PWA离线功能时,常见的陷阱包括:

  1. 离线缓存策略:离线缓存策略不当
  2. 离线页面:未设置离线页面
  3. 离线数据处理:离线状态下的数据处理不当
  4. 网络状态检测:网络状态检测错误

实用案例分析

错误场景:离线缓存策略不当

// 错误示例:离线缓存策略不当
// service-worker.js
self.addEventListener('fetch', (event) => {
  // 错误:对所有请求使用CacheFirst策略,可能导致数据过期
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request)
    })
  )
})

正确实现

// 正确示例:实现合理的离线缓存策略
// service-worker.js
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  
  // 对API请求使用NetworkFirst策略
  if (url.origin === 'https://api.example.com') {
    event.respondWith(
      fetch(event.request).catch(() => {
        return caches.match(event.request)
      })
    )
  } 
  // 对静态资源使用CacheFirst策略
  else if (url.pathname.match(/\\.(js|css|png|jpg|jpeg|svg|gif)$/)) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request).then((networkResponse) => {
          if (networkResponse && networkResponse.ok) {
            const clonedResponse = networkResponse.clone()
            caches.open('static-cache').then((cache) => {
              cache.put(event.request, clonedResponse)
            })
          }
          return networkResponse
        })
      })
    )
  }
  // 对HTML页面使用NetworkFirst策略
  else {
    event.respondWith(
      fetch(event.request).catch(() => {
        return caches.match('/offline.html')
      })
    )
  }
})

// 缓存离线页面
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('app-cache').then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/offline.html',
        '/css/app.css',
        '/js/app.js'
      ])
    })
  )
})

错误场景:未设置离线页面和网络状态检测

// 错误示例:未设置离线页面
// 组件中
<template>
  <div>
    <h1>应用内容</h1>
  </div>
</template>

<script>
export default {
  // 错误:未检测网络状态
}
</script>

正确实现

// 正确示例:设置离线页面和网络状态检测
// 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>网络连接已断开</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      height: 100vh;
      margin: 0;
      padding: 20px;
      text-align: center;
    }
    h1 {
      color: #333;
    }
    p {
      color: #666;
      max-width: 400px;
    }
    button {
      margin-top: 20px;
      padding: 10px 20px;
      background-color: #4CAF50;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <h1>网络连接已断开</h1>
  <p>您当前处于离线状态,无法访问在线内容。请检查网络连接后重试。</p>
  <button onclick="window.location.reload()">重新连接</button>
</body>
</html>

// 2. 组件中检测网络状态
// 组件中
<template>
  <div>
    <!-- 网络状态提示 -->
    <div v-if="!isOnline" class="offline-indicator">
      您当前处于离线状态,正在使用缓存内容
    </div>
    
    <h1>应用内容</h1>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isOnline: navigator.onLine
    }
  },
  mounted() {
    // 监听网络状态变化
    window.addEventListener('online', this.updateOnlineStatus)
    window.addEventListener('offline', this.updateOnlineStatus)
  },
  beforeUnmount() {
    window.removeEventListener('online', this.updateOnlineStatus)
    window.removeEventListener('offline', this.updateOnlineStatus)
  },
  methods: {
    updateOnlineStatus() {
      this.isOnline = navigator.onLine
      if (this.isOnline) {
        // 网络恢复,刷新数据
        this.refreshData()
      }
    },
    refreshData() {
      // 刷新应用数据
      console.log('网络已恢复,刷新数据')
    }
  }
}
</script>

<style scoped>
.offline-indicator {
  background-color: #ff9800;
  color: white;
  padding: 10px;
  text-align: center;
  margin-bottom: 20px;
}
</style>

21.6 Vue PWA更新机制的使用误区

核心知识点

在实现Vue PWA更新机制时,常见的误区包括:

  1. 更新检测:更新检测机制不当
  2. 更新提示:更新提示时机和方式不当
  3. 更新安装:更新安装过程处理不当
  4. 缓存清理:更新后未正确清理缓存

实用案例分析

错误场景:更新检测机制不当

// 错误示例:更新检测机制不当
// 组件中
<template>
  <div>
    <button @click="checkForUpdates">检查更新</button>
  </div>
</template>

<script>
export default {
  methods: {
    checkForUpdates() {
      // 错误:手动检查更新,用户体验差
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker.ready.then((registration) => {
          registration.update()
          console.log('检查更新')
        })
      }
    }
  }
}
</script>

正确实现

// 正确示例:实现自动更新检测
// 组件中
<template>
  <div>
    <!-- 更新提示 -->
    <div v-if="updateAvailable" class="update-notification">
      <p>发现新版本</p>
      <button @click="installUpdate">立即更新</button>
      <button @click="dismissUpdate">稍后再说</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      updateAvailable: false,
      refreshing: false,
      updateRegistration: null
    }
  },
  mounted() {
    // 监听Service Worker更新
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.addEventListener('controllerchange', () => {
        if (this.refreshing) return
        this.refreshing = true
        // 重新加载页面
        window.location.reload()
      })
      
      navigator.serviceWorker.ready.then((registration) => {
        // 检查是否有等待中的更新
        if (registration.waiting) {
          this.updateAvailable = true
          this.updateRegistration = registration
        }
        
        // 监听更新事件
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing
          if (newWorker) {
            newWorker.addEventListener('statechange', () => {
              if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
                // 有新版本可用
                this.updateAvailable = true
                this.updateRegistration = registration
              }
            })
          }
        })
      })
    }
  },
  methods: {
    installUpdate() {
      if (this.updateRegistration && this.updateRegistration.waiting) {
        // 发送消息给Service Worker,触发更新
        this.updateRegistration.waiting.postMessage({ type: 'SKIP_WAITING' })
      }
    },
    dismissUpdate() {
      this.updateAvailable = false
      // 记录用户忽略更新的时间
      localStorage.setItem('lastUpdateDismissed', Date.now().toString())
    }
  }
}
</script>

<style scoped>
.update-notification {
  background-color: #4CAF50;
  color: white;
  padding: 15px;
  margin-bottom: 20px;
  border-radius: 5px;
}

.update-notification button {
  margin: 5px;
  padding: 5px 10px;
  border: none;
  border-radius: 3px;
  cursor: pointer;
}
</style>

// service-worker.js中处理更新
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting()
  }
})

// 激活时立即控制所有客户端
self.addEventListener('activate', (event) => {
  event.waitUntil(
    self.clients.claim()
  )
})

错误场景:更新后未正确清理缓存

// 错误示例:更新后未清理缓存
// service-worker.js
self.addEventListener('activate', (event) => {
  event.waitUntil(
    // 错误:未清理过期缓存
    self.clients.claim()
  )
})

正确实现

// 正确示例:更新后清理过期缓存
// service-worker.js
self.addEventListener('activate', (event) => {
  event.waitUntil(
    Promise.all([
      // 清理过期缓存
      caches.keys().then((cacheNames) => {
        return Promise.all(
          cacheNames.map((cacheName) => {
            // 清理旧版本的缓存
            if (cacheName !== 'app-cache-v2' && cacheName !== 'static-cache-v2') {
              return caches.delete(cacheName)
            }
          })
        )
      }),
      // 立即控制所有客户端
      self.clients.claim()
    ])
  )
})

// 版本化缓存名称
const CACHE_NAME = 'app-cache-v2'
const STATIC_CACHE_NAME = 'static-cache-v2'

// 安装时缓存必要资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/offline.html'
      ])
    })
  )
})

21.7 Vue PWA性能优化的陷阱

核心知识点

在优化Vue PWA性能时,常见的陷阱包括:

  1. 资源优化:静态资源优化不当
  2. 缓存策略:缓存策略导致的性能问题
  3. Service Worker性能:Service Worker导致的性能问题
  4. 安装包大小:PWA安装包过大

实用案例分析

错误场景:静态资源优化不当

// 错误示例:静态资源优化不当
// vue.config.js
module.exports = {
  // 错误:未配置资源优化
  pwa: {
    // 缺少资源优化配置
  }
}

正确实现

// 正确示例:优化静态资源
// vue.config.js
module.exports = {
  // 优化构建
  configureWebpack: {
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            name: 'vendor',
            test: /[\\/]node_modules[\\/]/,
            priority: 10
          },
          common: {
            name: 'common',
            minChunks: 2,
            priority: 5,
            reuseExistingChunk: true
          }
        }
      }
    }
  },
  // PWA配置
  pwa: {
    workboxOptions: {
      // 优化缓存
      cleanupOutdatedCaches: true,
      skipWaiting: true,
      clientsClaim: true,
      // 预缓存必要资源
      preCaching: [
        '/',
        '/index.html'
      ],
      // 动态缓存策略
      runtimeCaching: [
        {
          urlPattern: new RegExp('^https://api\.example\.com/'),
          handler: 'NetworkFirst',
          options: {
            cacheName: 'api-cache',
            networkTimeoutSeconds: 10
          }
        },
        {
          urlPattern: new RegExp('\\.(js|css)$'),
          handler: 'StaleWhileRevalidate',
          options: {
            cacheName: 'static-cache'
          }
        },
        {
          urlPattern: new RegExp('\\.(png|jpg|jpeg|svg|gif)$'),
          handler: 'CacheFirst',
          options: {
            cacheName: 'image-cache',
            expiration: {
              maxEntries: 60,
              maxAgeSeconds: 60 * 60 * 24 * 30 // 30天
            }
          }
        }
      ]
    }
  }
}

错误场景:Service Worker性能问题

// 错误示例:Service Worker性能问题
// service-worker.js
self.addEventListener('fetch', (event) => {
  // 错误:对所有请求都进行缓存处理,影响性能
  event.respondWith(
    caches.match(event.request).then((response) => {
      if (response) {
        return response
      }
      return fetch(event.request).then((networkResponse) => {
        if (networkResponse && networkResponse.ok) {
          // 错误:对所有响应都进行缓存,包括大型文件
          const clonedResponse = networkResponse.clone()
          caches.open('all-cache').then((cache) => {
            cache.put(event.request, clonedResponse)
          })
        }
        return networkResponse
      })
    })
  )
})

正确实现

// 正确示例:优化Service Worker性能
// service-worker.js
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  
  // 跳过浏览器扩展请求
  if (url.origin !== self.location.origin) return
  
  // 跳过非GET请求
  if (event.request.method !== 'GET') return
  
  // 跳过大型文件
  if (url.pathname.match(/\\.(mp4|mov|avi|zip|rar)$/)) return
  
  // 对不同类型的资源使用不同的缓存策略
  if (url.pathname.match(/\\.(js|css)$/)) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request).then((networkResponse) => {
          if (networkResponse && networkResponse.ok) {
            const clonedResponse = networkResponse.clone()
            caches.open('static-cache').then((cache) => {
              cache.put(event.request, clonedResponse)
            })
          }
          return networkResponse
        })
      })
    )
  } else if (url.pathname.match(/\\.(png|jpg|jpeg|svg|gif)$/)) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request).then((networkResponse) => {
          if (networkResponse && networkResponse.ok) {
            const clonedResponse = networkResponse.clone()
            caches.open('image-cache').then((cache) => {
              cache.put(event.request, clonedResponse)
            })
          }
          return networkResponse
        })
      })
    )
  } else {
    event.respondWith(
      fetch(event.request).catch(() => {
        return caches.match('/offline.html')
      })
    )
  }
})

// 限制缓存大小
self.addEventListener('activate', (event) => {
  event.waitUntil(
    Promise.all([
      // 清理过期缓存
      caches.open('image-cache').then((cache) => {
        cache.keys().then((keys) => {
          if (keys.length > 60) {
            // 删除最早的缓存
            return Promise.all(
              keys.slice(0, keys.length - 60).map((key) => {
                return cache.delete(key)
              })
            )
          }
        })
      }),
      self.clients.claim()
    ])
  )
})

21.8 Vue PWA兼容性的常见问题

核心知识点

在处理Vue PWA兼容性时,常见的问题包括:

  1. 浏览器兼容性:不同浏览器的PWA支持差异
  2. 设备兼容性:不同设备的PWA支持差异
  3. 功能降级:PWA功能在不支持的环境中的降级处理
  4. 测试覆盖:PWA兼容性测试覆盖不足

实用案例分析

错误场景:未处理浏览器兼容性

// 错误示例:未处理浏览器兼容性
// 组件中
<template>
  <div>
    <button @click="installApp">安装应用</button>
  </div>
</template>

<script>
export default {
  methods: {
    installApp() {
      // 错误:直接调用PWA相关API,不检查浏览器支持
      if (this.deferredPrompt) {
        this.deferredPrompt.prompt()
      }
    }
  }
}
</script>

正确实现

// 正确示例:处理浏览器兼容性
// 组件中
<template>
  <div>
    <div v-if="!isPwaSupported" class="pwa-not-supported">
      <p>您的浏览器不支持PWA功能</p>
      <p>推荐使用Chrome、Firefox或Safari最新版本</p>
    </div>
    <button v-else-if="showInstallButton" @click="installApp">安装应用</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isPwaSupported: false,
      showInstallButton: false,
      deferredPrompt: null
    }
  },
  mounted() {
    // 检查PWA支持
    this.checkPwaSupport()
  },
  methods: {
    checkPwaSupport() {
      // 检查必要的PWA API支持
      this.isPwaSupported = (
        'serviceWorker' in navigator &&
        'Notification' in window &&
        'PushManager' in window &&
        'beforeinstallprompt' in window
      )
      
      if (this.isPwaSupported) {
        // 监听安装提示
        window.addEventListener('beforeinstallprompt', (e) => {
          e.preventDefault()
          this.deferredPrompt = e
          this.showInstallButton = true
        })
      }
    },
    async installApp() {
      if (this.deferredPrompt) {
        this.deferredPrompt.prompt()
        const { outcome } = await this.deferredPrompt.userChoice
        console.log(`用户选择: ${outcome}`)
        this.deferredPrompt = null
        this.showInstallButton = false
      }
    }
  }
}
</script>

<style scoped>
.pwa-not-supported {
  background-color: #f44336;
  color: white;
  padding: 15px;
  margin-bottom: 20px;
  border-radius: 5px;
}
</style>

错误场景:未实现功能降级

// 错误示例:未实现功能降级
// 组件中
<template>
  <div>
    <h1>{{ data.title }}</h1>
    <p>{{ data.content }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      data: {}
    }
  },
  mounted() {
    // 错误:假设PWA功能可用,无降级方案
    this.fetchData()
  },
  methods: {
    async fetchData() {
      // 错误:直接使用fetch,无离线降级
      const response = await fetch('/api/data')
      this.data = await response.json()
    }
  }
}
</script>

正确实现

// 正确示例:实现功能降级
// 组件中
<template>
  <div>
    <h1>{{ data.title }}</h1>
    <p>{{ data.content }}</p>
    <div v-if="!isOnline" class="offline-warning">
      您当前处于离线状态,显示的是缓存内容
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      data: {},
      isOnline: navigator.onLine
    }
  },
  mounted() {
    // 监听网络状态
    window.addEventListener('online', this.updateOnlineStatus)
    window.addEventListener('offline', this.updateOnlineStatus)
    
    // 获取数据
    this.fetchData()
  },
  beforeUnmount() {
    window.removeEventListener('online', this.updateOnlineStatus)
    window.removeEventListener('offline', this.updateOnlineStatus)
  },
  methods: {
    updateOnlineStatus() {
      this.isOnline = navigator.onLine
      if (this.isOnline) {
        this.fetchData()
      }
    },
    async fetchData() {
      try {
        // 尝试从网络获取
        const response = await fetch('/api/data')
        if (response.ok) {
          this.data = await response.json()
          // 缓存数据
          localStorage.setItem('cachedData', JSON.stringify(this.data))
        } else {
          // 网络请求失败,使用缓存
          this.loadCachedData()
        }
      } catch (error) {
        // 网络错误,使用缓存
        this.loadCachedData()
      }
    },
    loadCachedData() {
      // 加载缓存数据
      const cachedData = localStorage.getItem('cachedData')
      if (cachedData) {
        this.data = JSON.parse(cachedData)
      } else {
        // 无缓存数据,使用默认数据
        this.data = {
          title: '默认标题',
          content: '您当前处于离线状态,无可用数据'
        }
      }
    }
  }
}
</script>

<style scoped>
.offline-warning {
  background-color: #ff9800;
  color: white;
  padding: 10px;
  margin-top: 20px;
  border-radius: 5px;
}
</style>
« 上一篇 Vue SSR开发踩坑 下一篇 » Vue国际化踩坑