PWA 支持
PWA(Progressive Web App)可以让你的 VitePress 站点支持离线访问、安装到桌面等特性,提供更接近原生应用的体验。
什么是 PWA?
PWA 是一种 Web 应用技术,结合了网页和原生应用的优点:
| 特性 | 说明 |
|---|---|
| 离线访问 | 即使没有网络也能浏览已缓存的内容 |
| 可安装 | 用户可以将站点添加到主屏幕 |
| 推送通知 | 发送消息提醒用户(需要后端支持) |
| 后台同步 | 在后台同步数据(需要后端支持) |
安装依赖
bash
npm add -D vite-plugin-pwa workbox-window配置 PWA
修改 VitePress 配置
ts
// docs/.vitepress/config.mts
import { defineConfig } from 'vitepress'
import { withPwa } from '@vite-pwa/vitepress'
export default withPwa(defineConfig({
// ... 其他配置
vite: {
plugins: [
// PWA 配置
]
}
}))完整 PWA 配置示例
ts
// docs/.vitepress/config.mts
import { defineConfig } from 'vitepress'
import { withPwa } from '@vite-pwa/vitepress'
export default withPwa(defineConfig({
title: 'VitePress 文档',
description: 'VitePress 文档站点',
head: [
// PWA 相关 meta 标签
['meta', { name: 'theme-color', content: '#6366f1' }],
['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' }],
['link', { rel: 'mask-icon', href: '/safari-pinned-tab.svg', color: '#6366f1' }]
],
themeConfig: {
// ... 其他主题配置
}
}), {
// PWA 插件配置
outDir: '.vitepress/dist',
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
manifest: {
name: 'VitePress 文档',
short_name: 'VitePress',
description: 'VitePress 文档站点',
theme_color: '#6366f1',
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: '/pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: '/pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{css,js,html,svg,png,ico,txt,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 年
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 年
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
}
})创建 PWA 图标
在 docs/public/ 目录下添加以下图标文件:
| 文件名 | 尺寸 | 用途 |
|---|---|---|
favicon.ico | 48x48 | 浏览器标签图标 |
apple-touch-icon.png | 180x180 | iOS 主屏幕图标 |
pwa-192x192.png | 192x192 | Android 图标 |
pwa-512x512.png | 512x512 | Android 大图标 |
mask-icon.svg | 矢量 | Safari 固定标签图标 |
使用工具生成图标
bash
# 安装 PWA 图标生成工具
npm install -D pwa-asset-generator
# 从一个图标生成所有需要的尺寸
npx pwa-asset-generator ./logo.svg ./docs/public/添加安装提示组件
创建一个组件,提示用户安装 PWA:
vue
<!-- docs/.vitepress/theme/components/PWAInstallPrompt.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const showPrompt = ref(false)
const deferredPrompt = ref<any>(null)
onMounted(() => {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt.value = e
showPrompt.value = true
})
})
async function install() {
if (!deferredPrompt.value) return
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
if (outcome === 'accepted') {
showPrompt.value = false
}
deferredPrompt.value = null
}
function dismiss() {
showPrompt.value = false
}
</script>
<template>
<Transition name="slide-up">
<div v-if="showPrompt" class="pwa-prompt">
<div class="prompt-content">
<div class="prompt-icon">📱</div>
<div class="prompt-text">
<h4>安装应用</h4>
<p>将文档添加到主屏幕,随时离线访问</p>
</div>
</div>
<div class="prompt-actions">
<button class="btn-dismiss" @click="dismiss">稍后</button>
<button class="btn-install" @click="install">安装</button>
</div>
</div>
</Transition>
</template>
<style scoped>
.pwa-prompt {
position: fixed;
bottom: 20px;
left: 20px;
right: 20px;
max-width: 400px;
margin: 0 auto;
padding: 16px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
.prompt-content {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.prompt-icon {
font-size: 32px;
}
.prompt-text h4 {
margin: 0;
font-size: 16px;
}
.prompt-text p {
margin: 4px 0 0;
font-size: 14px;
color: var(--vp-c-text-2);
}
.prompt-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn-dismiss,
.btn-install {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.btn-dismiss {
background: transparent;
border: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-2);
}
.btn-install {
background: var(--vp-c-brand-1);
border: none;
color: white;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(20px);
opacity: 0;
}
</style>注册组件
ts
// docs/.vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
export default {
extends: DefaultTheme,
Layout: () => {
return h(DefaultTheme.Layout, null, {
'layout-bottom': () => h(PWAInstallPrompt)
})
}
}添加更新提示
当网站有更新时,提示用户刷新:
vue
<!-- docs/.vitepress/theme/components/PWAUpdateToast.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const needRefresh = ref(false)
let updateServiceWorker: (() => Promise<void>) | undefined
onMounted(() => {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
needRefresh.value = true
}
})
}
})
})
}
})
function close() {
needRefresh.value = false
}
function update() {
needRefresh.value = false
window.location.reload()
}
</script>
<template>
<Transition name="toast">
<div v-if="needRefresh" class="update-toast">
<span>发现新版本</span>
<div class="toast-actions">
<button @click="close">稍后</button>
<button class="primary" @click="update">刷新</button>
</div>
</div>
</Transition>
</template>
<style scoped>
.update-toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 16px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 16px;
z-index: 1000;
}
.toast-actions {
display: flex;
gap: 8px;
}
.toast-actions button {
padding: 4px 12px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
background: transparent;
border: 1px solid var(--vp-c-divider);
}
.toast-actions button.primary {
background: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
color: white;
}
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from,
.toast-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>离线页面
创建自定义离线页面:
vue
<!-- docs/.vitepress/theme/components/OfflinePage.vue -->
<script setup lang="ts">
import { useData } from 'vitepress'
const { site } = useData()
</script>
<template>
<div class="offline-page">
<div class="offline-icon">📡</div>
<h1>离线状态</h1>
<p>当前没有网络连接,请检查网络后重试</p>
<button @click="() => window.location.reload()">重新加载</button>
</div>
</template>
<style scoped>
.offline-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.offline-icon {
font-size: 64px;
margin-bottom: 24px;
}
h1 {
margin: 0 0 8px;
}
p {
color: var(--vp-c-text-2);
margin: 0 0 24px;
}
button {
padding: 12px 24px;
background: var(--vp-c-brand-1);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
</style>缓存策略
常用缓存策略
| 策略 | 适用场景 | 说明 |
|---|---|---|
CacheFirst | 静态资源 | 优先使用缓存,缓存不存在时请求网络 |
NetworkFirst | 动态内容 | 优先使用网络,网络失败时使用缓存 |
StaleWhileRevalidate | 普通内容 | 使用缓存同时后台更新 |
NetworkOnly | 实时数据 | 仅使用网络 |
CacheOnly | 预缓存 | 仅使用缓存 |
配置示例
ts
workbox: {
runtimeCaching: [
// 静态资源 - 缓存优先
{
urlPattern: /\.(?:png|jpg|svg|gif|webp|ico)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 天
}
}
},
// API 请求 - 网络优先
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 1 天
}
}
},
// 字体 - 缓存优先
{
urlPattern: /\.(?:woff|woff2)$/,
handler: 'CacheFirst',
options: {
cacheName: 'fonts-cache',
expiration: {
maxEntries: 20,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 年
}
}
}
]
}测试 PWA
本地测试
bash
# 构建生产版本
npm run build
# 预览
npm run preview然后在浏览器中:
- 打开开发者工具 → Application → Service Workers
- 检查 Service Worker 是否正确注册
- 在 Application → Manifest 查看配置是否正确
使用 Lighthouse 审计
Chrome DevTools → Lighthouse → 勾选 Progressive Web App → 运行审计
测试离线功能
- DevTools → Network → Offline
- 刷新页面,检查是否正常显示
最佳实践
缓存更新策略
ts
workbox: {
// 自动更新
registerType: 'autoUpdate',
// 或提示用户更新
registerType: 'prompt',
}预缓存重要页面
ts
workbox: {
globPatterns: ['**/*.{css,js,html,svg,png}'],
globIgnores: ['**/unused/**'],
// 预缓存的额外文件
additionalManifestEntries: [
{
url: '/offline/',
revision: '1.0.0'
}
]
}处理路由缓存
对于 SPA 路由,需要确保所有页面都正确缓存:
ts
workbox: {
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api/]
}常见问题
1. Service Worker 更新不生效
确保文件内容有变化,或添加版本号:
ts
manifest: {
// ...
},
workbox: {
// 修改配置后,清理旧缓存
cleanupOutdatedCaches: true
}2. iOS Safari 问题
iOS 需要额外的配置:
ts
head: [
['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: 'My App' }]
]3. 缓存大小限制
浏览器通常限制缓存大小为可用磁盘空间的百分比。避免缓存过多资源:
ts
expiration: {
maxEntries: 100, // 最大条目数
maxAgeSeconds: 60 * 60 * 24 * 30 // 最长保留时间
}部署注意事项
HTTPS 要求
PWA 必须在 HTTPS 环境下运行(localhost 除外)。确保你的部署平台支持 HTTPS。