PWA 离线访问
将你的 VitePress 站点转换为渐进式 Web 应用,支持离线访问和安装。
安装依赖
bash
npm install @vite-pwa/vitepress -D基础配置
ts
// .vitepress/config.mts
import { withPwa } from '@vite-pwa/vitepress'
import { defineConfig } from 'vitepress'
const baseConfig = defineConfig({
title: 'VitePress 学习指南',
// ... 其他配置
})
export default withPwa({
...baseConfig,
pwa: {
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'logo.svg', 'robots.txt'],
manifest: {
name: 'VitePress 学习指南',
short_name: 'VitePress指南',
description: '从零开始学习 VitePress',
theme_color: '#6366f1',
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-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: '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
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
}
}
})添加 Meta 标签
ts
export default defineConfig({
head: [
['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: 'default' }],
['meta', { name: 'apple-mobile-web-app-title', content: 'VitePress' }],
['link', { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }],
['link', { rel: 'mask-icon', href: '/safari-pinned-tab.svg', color: '#6366f1' }]
]
})离线提示组件
vue
<!-- .vitepress/theme/components/OfflineIndicator.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const isOffline = ref(false)
const updateOnlineStatus = () => {
isOffline.value = !navigator.onLine
}
onMounted(() => {
updateOnlineStatus()
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
})
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
})
</script>
<template>
<Transition name="slide">
<div v-if="isOffline" class="offline-indicator">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
</svg>
<span>网络连接已断开,部分功能可能不可用</span>
</div>
</Transition>
</template>
<style scoped>
.offline-indicator {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 0.5rem 1rem;
background: #f59e0b;
color: white;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 0.875rem;
z-index: 1000;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateY(-100%);
}
</style>更新提示组件
vue
<!-- .vitepress/theme/components/UpdatePrompt.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const needRefresh = ref(false)
const offlineReady = ref(false)
let updateServiceWorker: (() => Promise<void>) | null = null
onMounted(async () => {
const { registerSW } = await import('virtual:pwa-register')
updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh() {
needRefresh.value = true
},
onOfflineReady() {
offlineReady.value = true
setTimeout(() => {
offlineReady.value = false
}, 3000)
}
})
})
const close = () => {
needRefresh.value = false
}
const update = async () => {
if (updateServiceWorker) {
await updateServiceWorker()
needRefresh.value = false
}
}
</script>
<template>
<!-- 更新提示 -->
<Transition name="fade">
<div v-if="needRefresh" class="update-prompt">
<span>有新版本可用</span>
<div class="actions">
<button class="btn-update" @click="update">更新</button>
<button class="btn-close" @click="close">稍后</button>
</div>
</div>
</Transition>
<!-- 离线就绪提示 -->
<Transition name="fade">
<div v-if="offlineReady" class="offline-ready">
✅ 内容已缓存,可离线访问
</div>
</Transition>
</template>
<style scoped>
.update-prompt {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 1rem;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
display: flex;
align-items: center;
gap: 1rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn-update,
.btn-close {
padding: 0.5rem 1rem;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 0.875rem;
}
.btn-update {
background: var(--vp-c-brand-1);
color: white;
}
.btn-close {
background: var(--vp-c-bg-alt);
}
.offline-ready {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: 0.75rem 1.5rem;
background: var(--vp-c-brand-1);
color: white;
border-radius: 8px;
z-index: 1000;
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(20px);
}
</style>缓存策略
ts
workbox: {
// 缓存策略
runtimeCaching: [
// 静态资源:缓存优先
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 60,
maxAgeSeconds: 60 * 60 * 24 * 30
}
}
},
// API 请求:网络优先
{
urlPattern: /^https:\/\/api\./,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24
}
}
},
// HTML 页面:网络优先
{
urlPattern: /\.html$/,
handler: 'NetworkFirst',
options: {
cacheName: 'html-cache'
}
}
]
}生成 PWA 图标
ts
// scripts/generate-pwa-icons.ts
import sharp from 'sharp'
import fs from 'fs'
const sizes = [192, 512]
const svgPath = 'docs/public/logo.svg'
async function generateIcons() {
for (const size of sizes) {
await sharp(svgPath)
.resize(size, size)
.png()
.toFile(`docs/public/pwa-${size}x${size}.png`)
console.log(`✅ Generated pwa-${size}x${size}.png`)
}
// 生成 Apple Touch Icon
await sharp(svgPath)
.resize(180, 180)
.png()
.toFile('docs/public/apple-touch-icon.png')
console.log('✅ Generated apple-touch-icon.png')
}
generateIcons()