Vue 中应用 PWA
约 936 字大约 3 分钟
PWAVueVite
2026-04-08
在 Vue 项目里接入 PWA 有三条主流路径:Vue CLI 插件(老项目)、vite-plugin-pwa(Vite 项目,推荐)、手写 + Workbox(完全自定义)。本文以 Vite + Vue 3 为主线。
Vite + Vue 3 快速接入
1. 安装插件
pnpm add -D vite-plugin-pwa2. 配置 vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'prompt', // 发现新版本时交给应用提示用户
injectRegister: 'auto',
includeAssets: ['favicon.svg', 'robots.txt', 'apple-touch-icon.png'],
manifest: {
name: 'Vue PWA Demo',
short_name: 'VueDemo',
description: 'Vue 3 + Vite + PWA',
theme_color: '#42b883',
background_color: '#ffffff',
display: 'standalone',
start_url: '/',
icons: [
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png' },
{
src: 'pwa-512-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 3,
expiration: { maxEntries: 50, maxAgeSeconds: 300 },
cacheableResponse: { statuses: [0, 200] }
}
},
{
urlPattern: /^https:\/\/cdn\..*\.(?:png|jpg|svg|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'cdn-images',
expiration: { maxEntries: 100, maxAgeSeconds: 30 * 86400 }
}
}
]
},
devOptions: {
enabled: true, // 开发环境开启 PWA 便于调试
type: 'module'
}
})
]
});3. 在组件中处理更新提示
<!-- PWAUpdateToast.vue -->
<script setup lang="ts">
import { useRegisterSW } from 'virtual:pwa-register/vue';
const { needRefresh, offlineReady, updateServiceWorker } = useRegisterSW({
onRegisteredSW(swUrl, reg) {
// 每小时检查一次更新
reg && setInterval(() => reg.update(), 60 * 60 * 1000);
},
onRegisterError(err) {
console.error('SW 注册失败', err);
}
});
function handleUpdate() {
updateServiceWorker(true);
}
function close() {
needRefresh.value = false;
offlineReady.value = false;
}
</script>
<template>
<div v-if="offlineReady || needRefresh" class="pwa-toast">
<div v-if="offlineReady">应用已就绪,可离线使用</div>
<div v-else>发现新版本,点击更新</div>
<div class="actions">
<button v-if="needRefresh" @click="handleUpdate">更新</button>
<button @click="close">关闭</button>
</div>
</div>
</template>
<style scoped>
.pwa-toast {
position: fixed;
right: 16px;
bottom: 16px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
z-index: 9999;
}
.actions {
margin-top: 8px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
</style>在 App.vue 中挂载:
<template>
<RouterView />
<PWAUpdateToast />
</template>TypeScript 类型声明
在 env.d.ts 添加:
/// <reference types="vite-plugin-pwa/vue" />
/// <reference types="vite-plugin-pwa/client" />自定义 Service Worker(InjectManifest 模式)
需要更灵活的拦截逻辑时切到 injectManifest:
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2}']
},
manifest: { /* ... */ }
});// src/sw.ts
/// <reference lib="webworker" />
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst } from 'workbox-strategies';
declare const self: ServiceWorkerGlobalScope;
self.addEventListener('message', (e) => {
if (e.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
precacheAndRoute(self.__WB_MANIFEST);
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({ cacheName: 'api' })
);组合 Pinia 实现离线队列
// stores/offlineQueue.ts
import { defineStore } from 'pinia';
export const useOfflineQueue = defineStore('offlineQueue', {
state: () => ({ pending: [] as Array<{ id: string; payload: unknown }> }),
actions: {
enqueue(item: { id: string; payload: unknown }) {
this.pending.push(item);
localStorage.setItem('offlineQueue', JSON.stringify(this.pending));
},
async flush() {
while (this.pending.length) {
const item = this.pending[0];
try {
await fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(item.payload)
});
this.pending.shift();
} catch {
break; // 仍然断网,等下次
}
}
localStorage.setItem('offlineQueue', JSON.stringify(this.pending));
}
}
});
// 在 App.vue 中
window.addEventListener('online', () => useOfflineQueue().flush());Vue CLI 项目接入
老项目可用官方插件(内部使用 Workbox v5):
vue add pwa会生成:
public/img/icons/图标集src/registerServiceWorker.tsvue.config.js中的pwa字段
在 vue.config.js 切换到 InjectManifest 模式以自定义 SW:
module.exports = {
pwa: {
workboxPluginMode: 'InjectManifest',
workboxOptions: {
swSrc: 'src/sw.js'
}
}
};Nuxt 项目
Nuxt 生态使用 @vite-pwa/nuxt:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@vite-pwa/nuxt'],
pwa: {
registerType: 'autoUpdate',
manifest: { /* ... */ },
workbox: { navigateFallback: '/' },
client: { installPrompt: true }
}
});常见问题
1. 开发环境 SW 缓存了旧代码
开发期务必开启 devOptions.enabled = true 并在 DevTools 勾选 Update on reload,或直接 Unregister。
2. 路由懒加载 chunk 404
单页应用发布新版后,旧页面保留的动态 import 路径可能已不存在。解决:
- 用
NetworkFirst兜底 navigation - 捕获
importError强制刷新:
router.onError((err) => {
if (/Failed to fetch dynamically imported module/.test(err.message)) {
window.location.reload();
}
});3. iOS Safari 注意事项
- iOS 16.4+ 才支持 Web Push
- 必须由用户 "添加到主屏幕" 才会被视为 PWA
- 状态栏颜色需要额外
meta:
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />4. HMR 与 SW 冲突
开发环境 registerType: 'autoUpdate' 容易吞掉 HMR 响应,推荐开发用 prompt 或直接关闭 devOptions.enabled。
