Skip to content

PWA 离线访问

将你的 VitePress 站点转换为渐进式 Web 应用,支持离线访问和安装。

相关文档

本文为「代码配方」,提供快速集成步骤。更多 PWA 内容请参考:

安装依赖

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()

参考链接

贡献者

加载中...

想要成为贡献者?

在 CNB 上参与贡献