Vue 3 与 PWA 进阶
概述
渐进式 Web 应用(Progressive Web App,PWA)是一种结合了 Web 和原生应用优点的应用形式,具有可安装、离线工作、推送通知、后台同步等特性。Vue 3 凭借其现代化的架构和良好的性能,与 PWA 结合使用时能够发挥出强大的威力。
本集将深入探讨 PWA 的高级特性以及如何在 Vue 3 应用中实现这些特性。我们将学习离线策略优化、推送通知、后台同步、PWA 安装体验优化、性能优化等高级技巧,帮助你构建更加完善、用户体验更好的 PWA 应用。
核心知识点
1. PWA 高级特性介绍
1.1 离线工作
离线工作是 PWA 的核心特性之一,它允许应用在没有网络连接的情况下继续工作。这通过 Service Worker 和 Cache API 实现,Service Worker 可以拦截网络请求并从缓存中返回响应。
1.2 推送通知
推送通知允许应用在用户不使用应用时向用户发送通知,提高用户参与度和留存率。这通过 Push API 和 Notification API 实现。
1.3 后台同步
后台同步允许应用在后台执行数据同步操作,例如在网络恢复后自动上传用户数据。这通过 Service Worker 的 Background Sync API 实现。
1.4 可安装
PWA 可以安装到用户的设备上,与原生应用一样出现在主屏幕上,并且可以全屏运行。这通过 Web App Manifest 实现。
1.5 背景获取
背景获取允许应用在后台获取最新数据,确保用户打开应用时看到的是最新内容。这通过 Service Worker 的 Background Fetch API 实现。
2. Vue 3 PWA 配置进阶
2.1 使用 Vite PWA 插件
在 Vue 3 应用中,我们可以使用 vite-plugin-pwa 插件来实现 PWA 功能。首先,安装插件:
npm install vite-plugin-pwa -D然后,在 vite.config.js 中配置插件:
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate', // 自动更新 Service Worker
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'], // 包含的静态资源
manifest: {
name: 'Vue 3 PWA Advanced',
short_name: 'Vue 3 PWA',
description: 'Vue 3 PWA Advanced Tutorial',
theme_color: '#42b983',
background_color: '#ffffff',
display: 'standalone', // 全屏显示
scope: '/',
start_url: '/',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-maskable-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable'
},
{
src: 'pwa-maskable-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'], // 缓存的文件模式
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\//, // API 请求缓存
handler: 'NetworkFirst', // 网络优先策略
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 24 小时
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
}
})
]
})2.2 生成 PWA 图标
我们可以使用 pwa-asset-generator 工具自动生成各种尺寸的 PWA 图标:
npm install -g pwa-asset-generator
pwa-asset-generator ./public/icon.png ./public --manifest ./public/manifest.json --favicon3. 离线策略优化
3.1 缓存策略类型
Workbox 提供了多种缓存策略,我们可以根据不同的资源类型选择合适的策略:
- CacheFirst:优先从缓存中获取资源,只有在缓存中不存在时才从网络获取
- NetworkFirst:优先从网络获取资源,只有在网络不可用时才从缓存中获取
- CacheOnly:只从缓存中获取资源
- NetworkOnly:只从网络获取资源
- StaleWhileRevalidate:先从缓存中返回资源(如果存在),然后在后台从网络获取并更新缓存
3.2 资源分类缓存
我们可以根据资源类型设置不同的缓存策略:
// vite.config.js
VitePWA({
// ...
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
// 静态资源缓存
urlPattern: /^https:\/\/example\.com\/assets\//,
handler: 'CacheFirst',
options: {
cacheName: 'static-assets',
expiration: {
maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 天
}
}
},
{
// API 请求缓存
urlPattern: /^https:\/\/api\.example\.com\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 24 小时
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
// 字体资源缓存
urlPattern: /^https:\/\/fonts\.googleapis\.com\//,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 年
}
}
}
]
}
})3.3 动态缓存管理
我们可以在 Service Worker 中实现动态缓存管理,例如根据缓存大小自动清理旧缓存:
// public/service-worker.js
self.addEventListener('activate', (event) => {
const cacheWhitelist = ['static-assets', 'api-cache', 'fonts-cache']
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName)
}
})
)
})
)
})4. 推送通知实现
4.1 注册推送服务
首先,我们需要在应用中注册推送服务,并获取推送订阅:
// src/composables/usePushNotification.js
import { ref } from 'vue'
export function usePushNotification() {
const isSupported = 'PushManager' in window
const isSubscribed = ref(false)
// 请求通知权限
const requestPermission = async () => {
if (!isSupported) return false
const permission = await Notification.requestPermission()
return permission === 'granted'
}
// 获取推送订阅
const getSubscription = async () => {
if (!isSupported) return null
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
isSubscribed.value = !!subscription
return subscription
}
// 订阅推送
const subscribe = async () => {
if (!isSupported) return null
const permissionGranted = await requestPermission()
if (!permissionGranted) return null
const registration = await navigator.serviceWorker.ready
// 这里的 publicKey 需要从推送服务获取,例如 Firebase Cloud Messaging
const publicKey = 'YOUR_PUBLIC_KEY'
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey)
})
isSubscribed.value = true
// 将订阅发送到服务器
await sendSubscriptionToServer(subscription)
return subscription
}
// 取消订阅
const unsubscribe = async () => {
if (!isSupported) return false
const subscription = await getSubscription()
if (!subscription) return false
await subscription.unsubscribe()
isSubscribed.value = false
// 通知服务器取消订阅
await sendUnsubscriptionToServer(subscription)
return true
}
// 将 base64 URL 转换为 Uint8Array
const 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 sendSubscriptionToServer = async (subscription) => {
try {
await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
})
} catch (error) {
console.error('Failed to send subscription to server:', error)
}
}
// 发送取消订阅到服务器
const sendUnsubscriptionToServer = async (subscription) => {
try {
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
})
} catch (error) {
console.error('Failed to send unsubscription to server:', error)
}
}
return {
isSupported,
isSubscribed,
requestPermission,
getSubscription,
subscribe,
unsubscribe
}
}4.2 处理推送事件
在 Service Worker 中,我们需要处理推送事件并显示通知:
// public/service-worker.js
self.addEventListener('push', (event) => {
if (!event.data) return
const data = event.data.json()
const options = {
body: data.body,
icon: '/pwa-192x192.png',
badge: '/badge-72x72.png',
vibrate: [100, 50, 100], // 振动模式
data: {
url: data.url || '/' // 通知点击后打开的 URL
}
}
event.waitUntil(
self.registration.showNotification(data.title, options)
)
})
// 处理通知点击事件
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = event.notification.data.url
event.waitUntil(
clients.openWindow(url)
)
})4.3 在 Vue 组件中使用
在 Vue 组件中,我们可以使用上面定义的 composable 来实现推送通知功能:
<template>
<div class="push-notification">
<h3>Push Notification</h3>
<p v-if="!isSupported">Push notification is not supported in this browser.</p>
<div v-else>
<p v-if="isSubscribed">You are subscribed to push notifications.</p>
<p v-else>You are not subscribed to push notifications.</p>
<button @click="toggleSubscription">
{{ isSubscribed ? 'Unsubscribe' : 'Subscribe' }}
</button>
</div>
</div>
</template>
<script setup>
import { usePushNotification } from '../composables/usePushNotification'
const {
isSupported,
isSubscribed,
subscribe,
unsubscribe
} = usePushNotification()
const toggleSubscription = async () => {
if (isSubscribed.value) {
await unsubscribe()
} else {
await subscribe()
}
}
</script>
<style scoped>
.push-notification {
background-color: #f0f0f0;
padding: 16px;
border-radius: 8px;
margin: 16px 0;
}
button {
background-color: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #369f71;
}
</style>5. 后台同步
5.1 注册后台同步
我们可以使用 Background Sync API 来实现后台同步功能,例如在网络恢复后自动上传用户数据:
// src/composables/useBackgroundSync.js
import { ref } from 'vue'
export function useBackgroundSync() {
const isSupported = 'SyncManager' in window
// 注册后台同步
const registerSync = async (tag) => {
if (!isSupported) return false
try {
const registration = await navigator.serviceWorker.ready
await registration.sync.register(tag)
return true
} catch (error) {
console.error('Failed to register background sync:', error)
return false
}
}
return {
isSupported,
registerSync
}
}5.2 在 Service Worker 中处理后台同步
// public/service-worker.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(syncData())
}
})
// 同步数据函数
async function syncData() {
try {
// 从 IndexedDB 中获取待同步数据
const pendingData = await getPendingDataFromIndexedDB()
// 遍历待同步数据并上传
for (const data of pendingData) {
await fetch('/api/data/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
// 删除已同步的数据
await deleteSyncedDataFromIndexedDB(data.id)
}
} catch (error) {
console.error('Failed to sync data:', error)
throw error // 重新触发同步
}
}5.3 在 Vue 组件中使用
<template>
<div class="background-sync">
<h3>Background Sync</h3>
<p v-if="!isSupported">Background sync is not supported in this browser.</p>
<div v-else>
<button @click="syncData">Sync Data Now</button>
<p v-if="syncStatus">Sync status: {{ syncStatus }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useBackgroundSync } from '../composables/useBackgroundSync'
const { isSupported, registerSync } = useBackgroundSync()
const syncStatus = ref('')
const syncData = async () => {
syncStatus.value = 'Syncing...'
// 保存数据到 IndexedDB
await saveDataToIndexedDB({
id: Date.now(),
data: 'Sample data',
timestamp: new Date().toISOString()
})
// 注册后台同步
const success = await registerSync('sync-data')
if (success) {
syncStatus.value = 'Sync registered successfully. Data will be synced in background.'
} else {
syncStatus.value = 'Failed to register sync.'
}
// 3 秒后清除状态
setTimeout(() => {
syncStatus.value = ''
}, 3000)
}
// 保存数据到 IndexedDB
const saveDataToIndexedDB = async (data) => {
// 这里需要实现 IndexedDB 操作
console.log('Saving data to IndexedDB:', data)
}
</script>
<style scoped>
.background-sync {
background-color: #f0f0f0;
padding: 16px;
border-radius: 8px;
margin: 16px 0;
}
button {
background-color: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #369f71;
}
</style>6. PWA 安装体验优化
6.1 自定义安装提示
我们可以自定义 PWA 的安装提示,提供更好的用户体验:
// src/composables/usePwaInstall.js
import { ref } from 'vue'
export function usePwaInstall() {
const deferredPrompt = ref(null)
const isInstalled = ref(false)
// 监听 beforeinstallprompt 事件
const init = () => {
window.addEventListener('beforeinstallprompt', (event) => {
// 阻止默认安装提示
event.preventDefault()
// 保存事件,以便稍后触发安装
deferredPrompt.value = event
// 标记应用尚未安装
isInstalled.value = false
})
// 监听 appinstalled 事件
window.addEventListener('appinstalled', () => {
// 标记应用已安装
isInstalled.value = true
// 清除保存的事件
deferredPrompt.value = null
})
// 检查应用是否已安装
checkIfInstalled()
}
// 检查应用是否已安装
const checkIfInstalled = () => {
// PWA 安装检查逻辑
isInstalled.value = window.matchMedia('(display-mode: standalone)').matches
}
// 触发安装提示
const promptInstall = async () => {
if (!deferredPrompt.value) return false
try {
// 显示安装提示
await deferredPrompt.value.prompt()
// 等待用户响应
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
console.log('User accepted the install prompt')
} else {
console.log('User dismissed the install prompt')
}
// 清除保存的事件
deferredPrompt.value = null
return outcome === 'accepted'
} catch (error) {
console.error('Failed to prompt install:', error)
return false
}
}
return {
init,
isInstalled,
promptInstall
}
}6.2 在 Vue 组件中使用
<template>
<div class="pwa-install">
<h3>PWA Install</h3>
<div v-if="isInstalled">
<p>App is already installed.</p>
</div>
<div v-else>
<p>Install this app to your device for a better experience.</p>
<button @click="installApp">Install App</button>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { usePwaInstall } from '../composables/usePwaInstall'
const { init, isInstalled, promptInstall } = usePwaInstall()
onMounted(() => {
init()
})
const installApp = async () => {
await promptInstall()
}
</script>
<style scoped>
.pwa-install {
background-color: #f0f0f0;
padding: 16px;
border-radius: 8px;
margin: 16px 0;
}
button {
background-color: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #369f71;
}
</style>7. 性能优化
7.1 预缓存策略优化
我们可以优化预缓存策略,只预缓存必要的资源:
// vite.config.js
VitePWA({
// ...
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
globIgnores: ['node_modules/**/*', '.git/**/*', 'dist/**/*.map'], // 忽略的文件
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 最大缓存文件大小(5MB)
// ...
}
})7.2 延迟加载非关键资源
我们可以延迟加载非关键资源,例如图片、视频等:
<template>
<div class="lazy-loading">
<h3>Lazy Loading</h3>
<img v-for="image in images" :key="image.id" :src="image.src" :alt="image.alt" loading="lazy">
</div>
</template>
<script setup>
const images = [
{ id: 1, src: '/images/image1.jpg', alt: 'Image 1' },
{ id: 2, src: '/images/image2.jpg', alt: 'Image 2' },
{ id: 3, src: '/images/image3.jpg', alt: 'Image 3' }
]
</script>
<style scoped>
.lazy-loading {
background-color: #f0f0f0;
padding: 16px;
border-radius: 8px;
margin: 16px 0;
}
img {
width: 100%;
height: auto;
margin-bottom: 16px;
border-radius: 4px;
}
</style>7.3 使用 Web Workers 处理复杂计算
我们可以使用 Web Workers 处理复杂计算,避免阻塞主线程:
// src/workers/calculator.worker.js
self.onmessage = (event) => {
const { operation, numbers } = event.data
let result
switch (operation) {
case 'sum':
result = numbers.reduce((acc, num) => acc + num, 0)
break
case 'average':
result = numbers.reduce((acc, num) => acc + num, 0) / numbers.length
break
case 'factorial':
result = factorial(numbers[0])
break
default:
result = null
}
self.postMessage({ result })
}
function factorial(n) {
if (n <= 1) return 1
return n * factorial(n - 1)
}在 Vue 组件中使用 Web Worker:
<template>
<div class="web-worker">
<h3>Web Worker</h3>
<div>
<label for="number">Enter a number:</label>
<input type="number" id="number" v-model.number="number">
<button @click="calculateFactorial">Calculate Factorial</button>
</div>
<div v-if="result !== null">
<p>Result: {{ result }}</p>
</div>
<div v-if="isCalculating">
<p>Calculating...</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const number = ref(5)
const result = ref(null)
const isCalculating = ref(false)
let worker = null
// 初始化 Web Worker
const initWorker = () => {
if (worker) return
worker = new Worker(new URL('../workers/calculator.worker.js', import.meta.url))
worker.onmessage = (event) => {
result.value = event.data.result
isCalculating.value = false
}
worker.onerror = (error) => {
console.error('Worker error:', error)
isCalculating.value = false
}
}
const calculateFactorial = () => {
initWorker()
isCalculating.value = true
result.value = null
worker.postMessage({
operation: 'factorial',
numbers: [number.value]
})
}
</script>
<style scoped>
.web-worker {
background-color: #f0f0f0;
padding: 16px;
border-radius: 8px;
margin: 16px 0;
}
input {
padding: 8px;
margin: 0 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
background-color: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #369f71;
}
</style>最佳实践
1. PWA 设计原则
- 渐进式:确保应用在所有浏览器中都能正常工作,然后逐步增强功能
- 响应式:适配不同屏幕尺寸和设备类型
- 离线优先:优先考虑离线体验,确保应用在没有网络连接时也能工作
- 安全:使用 HTTPS 确保数据传输安全
- 可发现:确保应用可以被搜索引擎索引
- 可安装:提供良好的安装体验
- 可链接:支持通过 URL 分享和访问应用内容
2. 性能优化最佳实践
- 优化首屏加载时间:减少首屏加载时间,提高用户体验
- 使用 Service Worker 缓存策略:根据资源类型选择合适的缓存策略
- 延迟加载非关键资源:延迟加载图片、视频等非关键资源
- 使用 Web Workers 处理复杂计算:避免阻塞主线程
- 优化 JavaScript 代码:减少 JavaScript 执行时间
- 优化 CSS:减少 CSS 体积和复杂度
- 使用 CDN:将静态资源部署到 CDN 上,提高加载速度
3. 安全性考虑
- 使用 HTTPS:确保应用使用 HTTPS 协议,保护数据传输安全
- 验证推送通知来源:确保推送通知来自合法来源
- 保护用户数据:妥善处理用户数据,遵守隐私法规
- 使用安全的依赖:定期更新依赖,修复安全漏洞
- 实现内容安全策略(CSP):防止跨站脚本攻击
4. 可访问性设计
- 确保键盘可访问:确保所有功能都可以通过键盘访问
- 使用语义化 HTML:使用正确的 HTML 元素和属性
- 提供足够的对比度:确保文本和背景之间有足够的对比度
- 添加 alt 文本:为图片添加 alt 文本,提高可访问性
- 支持屏幕阅读器:确保应用可以被屏幕阅读器正确读取
常见问题和解决方案
1. 缓存策略问题
问题:缓存策略设置不当导致用户看不到最新内容
解决方案:
- 对频繁更新的资源使用
NetworkFirst或StaleWhileRevalidate策略 - 对静态资源使用
CacheFirst策略,并设置合理的过期时间 - 实现 Service Worker 自动更新机制
2. 推送通知问题
问题:推送通知无法正常工作
解决方案:
- 确保使用 HTTPS 协议
- 检查推送服务配置是否正确
- 确保用户已授予通知权限
- 检查 Service Worker 中的推送事件处理逻辑
3. 安装问题
问题:PWA 无法安装或安装后无法正常工作
解决方案:
- 确保 Web App Manifest 配置正确
- 确保 Service Worker 注册成功
- 检查浏览器是否支持 PWA 安装
- 实现自定义安装提示,提供更好的用户体验
4. 性能问题
问题:PWA 加载速度慢或运行卡顿
解决方案:
- 优化首屏加载时间
- 使用适当的缓存策略
- 延迟加载非关键资源
- 使用 Web Workers 处理复杂计算
- 优化 JavaScript 和 CSS 代码
5. 后台同步问题
问题:后台同步无法正常工作
解决方案:
- 确保浏览器支持 Background Sync API
- 检查 Service Worker 中的后台同步事件处理逻辑
- 实现适当的错误处理和重试机制
进阶学习资源
1. 官方文档
- Progressive Web Apps (PWA) - MDN
- vite-plugin-pwa - GitHub
- Service Worker API - MDN
- Web App Manifest - MDN
2. 教程和博客
- Building a PWA with Vue 3 and Vite
- Progressive Web Apps with Vue.js
- Push Notifications in Vue 3 PWA
- Background Sync in PWA
3. 视频资源
4. 开源项目和示例
实践练习
练习 1:PWA 高级配置
- 创建一个 Vue 3 应用
- 配置 Vite PWA 插件
- 设置不同资源类型的缓存策略
- 测试应用的离线工作能力
- 测试应用的自动更新功能
练习 2:推送通知实现
- 注册一个推送服务(例如 Firebase Cloud Messaging)
- 在 Vue 3 应用中实现推送通知功能
- 测试推送通知的订阅和取消订阅
- 测试推送通知的显示和点击处理
练习 3:后台同步实现
- 在 Vue 3 应用中实现后台同步功能
- 使用 IndexedDB 存储待同步数据
- 在 Service Worker 中处理后台同步事件
- 测试后台同步功能
练习 4:PWA 安装体验优化
- 实现自定义 PWA 安装提示
- 测试应用的安装和卸载流程
- 实现应用安装状态检查
- 优化安装体验
练习 5:性能优化
- 优化应用的首屏加载时间
- 实现延迟加载非关键资源
- 使用 Web Workers 处理复杂计算
- 测试应用的性能指标
总结
PWA 是一种结合了 Web 和原生应用优点的应用形式,具有可安装、离线工作、推送通知、后台同步等特性。Vue 3 凭借其现代化的架构和良好的性能,与 PWA 结合使用时能够发挥出强大的威力。
在本集中,我们学习了 PWA 的高级特性以及如何在 Vue 3 应用中实现这些特性。我们探讨了离线策略优化、推送通知、后台同步、PWA 安装体验优化、性能优化等高级技巧,并介绍了相关的最佳实践和常见问题解决方案。
通过掌握这些高级技巧,你将能够构建更加完善、用户体验更好的 PWA 应用,提高用户参与度和留存率。PWA 是现代 Web 应用开发的重要方向,掌握 PWA 开发将使你在 Web 开发领域更具竞争力。
下一集我们将学习 Vue 3 与 Service Worker 高级应用,敬请期待!