Skip to content

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.ico48x48浏览器标签图标
apple-touch-icon.png180x180iOS 主屏幕图标
pwa-192x192.png192x192Android 图标
pwa-512x512.png512x512Android 大图标
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

然后在浏览器中:

  1. 打开开发者工具 → Application → Service Workers
  2. 检查 Service Worker 是否正确注册
  3. 在 Application → Manifest 查看配置是否正确

使用 Lighthouse 审计

Chrome DevTools → Lighthouse → 勾选 Progressive Web App → 运行审计

测试离线功能

  1. DevTools → Network → Offline
  2. 刷新页面,检查是否正常显示

最佳实践

缓存更新策略

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。

下一步

贡献者

加载中...

想要成为贡献者?

在 CNB 上参与贡献