PWA 应用完整实现指南
渐进式 Web 应用(Progressive Web App,PWA)结合了 Web 和原生应用的优势:可通过浏览器访问,又能安装到桌面,支持离线使用和推送通知。
对于希望以较低成本覆盖多平台的团队来说,PWA 是极具性价比的选择。本文将系统讲解 PWA 的核心技术和实现方法。
PWA 核心概念
什么是 PWA
PWA 不是单一技术,而是一组技术和最佳实践的集合:
| 特性 | 实现技术 | 作用 |
|---|---|---|
| 可安装 | Web App Manifest | 添加到主屏幕 |
| 离线可用 | Service Worker | 缓存资源和请求 |
| 推送通知 | Push API + Notification API | 用户重新激活 |
| 安全 | HTTPS | 必要条件 |
| 响应式 | CSS Media Queries | 适配各种设备 |
PWA 的优势
相比传统网站:
- 可安装到主屏幕,像原生应用一样启动
- 离线或弱网环境下仍可使用
- 可发送推送通知
相比原生应用:
- 无需应用商店审核
- 一套代码多平台运行
- 更新即时生效
- 更小的安装体积
浏览器支持
| 功能 | Chrome | Safari | Firefox | Edge |
|---|---|---|---|---|
| Service Worker | ✅ | ✅ | ✅ | ✅ |
| Web App Manifest | ✅ | ✅ | ✅ | ✅ |
| Push Notifications | ✅ | ⚠️ (iOS 16.4+) | ✅ | ✅ |
| Background Sync | ✅ | ❌ | ❌ | ✅ |
Web App Manifest
Manifest 是一个 JSON 文件,定义应用的元数据。
基础配置
// public/manifest.json
{
"name": "HTMLPAGE 应用",
"short_name": "HTMLPAGE",
"description": "让网页构建更简单",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4f46e5",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile.png",
"sizes": "640x1136",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "新建项目",
"short_name": "新建",
"url": "/projects/new",
"icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
},
{
"name": "我的模板",
"short_name": "模板",
"url": "/templates",
"icons": [{ "src": "/icons/templates.png", "sizes": "96x96" }]
}
]
}
关键属性说明
display 模式:
| 值 | 说明 |
|---|---|
fullscreen | 全屏,隐藏所有浏览器 UI |
standalone | 独立窗口,像原生应用 |
minimal-ui | 保留最小导航控件 |
browser | 普通浏览器标签页 |
引用 Manifest:
<head>
<link rel="manifest" href="/manifest.json">
<!-- iOS 支持 -->
<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="HTMLPAGE">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<!-- 主题色 -->
<meta name="theme-color" content="#4f46e5">
</head>
Service Worker
Service Worker 是 PWA 的核心,运行在独立线程中,可以拦截网络请求、管理缓存。
生命周期
注册 → 安装(install) → 等待(waiting) → 激活(activate) → 运行
↓
刷新页面后激活
基础 Service Worker
// public/sw.js
const CACHE_NAME = 'app-cache-v1'
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/icons/icon-192x192.png'
]
// 安装事件:缓存静态资源
self.addEventListener('install', (event) => {
console.log('[SW] Installing...')
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('[SW] Caching static assets')
return cache.addAll(STATIC_ASSETS)
})
.then(() => {
// 跳过等待,立即激活
return self.skipWaiting()
})
)
})
// 激活事件:清理旧缓存
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...')
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('[SW] Deleting old cache:', name)
return caches.delete(name)
})
)
}).then(() => {
// 接管所有客户端
return self.clients.claim()
})
)
})
// 请求拦截:实现缓存策略
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
// 只处理同源请求
if (url.origin !== location.origin) {
return
}
// API 请求:网络优先
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request))
return
}
// 静态资源:缓存优先
event.respondWith(cacheFirst(request))
})
// 缓存优先策略
async function cacheFirst(request) {
const cached = await caches.match(request)
if (cached) {
return cached
}
try {
const response = await fetch(request)
// 缓存成功的响应
if (response.ok) {
const cache = await caches.open(CACHE_NAME)
cache.put(request, response.clone())
}
return response
} catch (error) {
// 离线时返回离线页面
return caches.match('/offline.html')
}
}
// 网络优先策略
async function networkFirst(request) {
try {
const response = await fetch(request)
// 缓存 API 响应
if (response.ok) {
const cache = await caches.open(CACHE_NAME)
cache.put(request, response.clone())
}
return response
} catch (error) {
// 网络失败,尝试缓存
const cached = await caches.match(request)
if (cached) {
return cached
}
// 返回错误响应
return new Response(JSON.stringify({ error: 'Offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
})
}
}
注册 Service Worker
// scripts/register-sw.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
})
console.log('SW registered:', registration.scope)
// 检测更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本可用
showUpdateNotification()
}
})
})
} catch (error) {
console.error('SW registration failed:', error)
}
})
}
function showUpdateNotification() {
// 显示更新提示
const notification = document.createElement('div')
notification.className = 'update-notification'
notification.innerHTML = `
<p>新版本可用</p>
<button onclick="location.reload()">立即更新</button>
`
document.body.appendChild(notification)
}
缓存策略
常用缓存策略
| 策略 | 说明 | 适用场景 |
|---|---|---|
| Cache First | 优先缓存,缓存没有再请求网络 | 静态资源(CSS、JS、图片) |
| Network First | 优先网络,网络失败用缓存 | API 数据、动态内容 |
| Stale While Revalidate | 立即返回缓存,同时后台更新 | 需要快速响应但要保持新鲜 |
| Network Only | 只用网络 | 实时数据、表单提交 |
| Cache Only | 只用缓存 | 离线页面、预缓存资源 |
实现各种策略
// 策略工厂
const strategies = {
// 缓存优先
async cacheFirst(request, cacheName) {
const cached = await caches.match(request)
if (cached) return cached
const response = await fetch(request)
if (response.ok) {
const cache = await caches.open(cacheName)
cache.put(request, response.clone())
}
return response
},
// 网络优先
async networkFirst(request, cacheName, timeout = 3000) {
try {
const response = await Promise.race([
fetch(request),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeout)
)
])
if (response.ok) {
const cache = await caches.open(cacheName)
cache.put(request, response.clone())
}
return response
} catch {
return caches.match(request)
}
},
// 边返回边更新
async staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName)
const cached = await cache.match(request)
// 后台更新
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone())
}
return response
})
// 有缓存立即返回,否则等待网络
return cached || fetchPromise
}
}
// 在 fetch 事件中使用
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// 根据路径选择策略
if (url.pathname.match(/\.(js|css|png|jpg|svg)$/)) {
event.respondWith(strategies.cacheFirst(event.request, 'static-v1'))
} else if (url.pathname.startsWith('/api/')) {
event.respondWith(strategies.networkFirst(event.request, 'api-v1'))
} else {
event.respondWith(strategies.staleWhileRevalidate(event.request, 'pages-v1'))
}
})
离线支持
离线页面
<!-- public/offline.html -->
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线 - HTMLPAGE</title>
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
text-align: center;
padding: 2rem;
}
.icon {
font-size: 4rem;
margin-bottom: 1rem;
}
h1 {
color: #333;
margin-bottom: 0.5rem;
}
p {
color: #666;
margin-bottom: 1.5rem;
}
button {
padding: 0.75rem 1.5rem;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📡</div>
<h1>您当前处于离线状态</h1>
<p>请检查网络连接后重试</p>
<button onclick="location.reload()">重新加载</button>
</div>
</body>
</html>
离线数据存储
// 使用 IndexedDB 存储离线数据
class OfflineStore {
constructor(dbName, storeName) {
this.dbName = dbName
this.storeName = storeName
this.db = null
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve(this.db)
}
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'id' })
}
}
})
}
async save(data) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(this.storeName, 'readwrite')
const store = tx.objectStore(this.storeName)
const request = store.put(data)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
async get(id) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(this.storeName, 'readonly')
const store = tx.objectStore(this.storeName)
const request = store.get(id)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
async getAll() {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(this.storeName, 'readonly')
const store = tx.objectStore(this.storeName)
const request = store.getAll()
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
}
// 使用
const store = new OfflineStore('app-db', 'drafts')
await store.init()
// 保存草稿
await store.save({ id: 'draft-1', content: '...', updatedAt: Date.now() })
// 获取草稿
const draft = await store.get('draft-1')
安装体验
检测可安装性
// 保存安装提示事件
let deferredPrompt = null
window.addEventListener('beforeinstallprompt', (event) => {
// 阻止默认的安装提示
event.preventDefault()
// 保存事件以便稍后使用
deferredPrompt = event
// 显示自定义安装按钮
showInstallButton()
})
function showInstallButton() {
const button = document.getElementById('install-button')
if (button) {
button.style.display = 'block'
button.addEventListener('click', promptInstall)
}
}
async function promptInstall() {
if (!deferredPrompt) return
// 显示安装提示
deferredPrompt.prompt()
// 等待用户响应
const { outcome } = await deferredPrompt.userChoice
console.log('Install outcome:', outcome)
// 清理
deferredPrompt = null
hideInstallButton()
}
// 监听安装完成
window.addEventListener('appinstalled', () => {
console.log('App was installed')
// 可以发送分析事件
analytics.track('pwa_installed')
})
检测运行模式
// 检测是否以 PWA 模式运行
function isPWA() {
return (
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true ||
document.referrer.includes('android-app://')
)
}
// 根据运行模式调整 UI
if (isPWA()) {
// 隐藏"安装应用"提示
document.getElementById('install-banner')?.remove()
// 可能需要调整导航(PWA 没有浏览器返回按钮)
showAppNavigation()
}
推送通知
请求通知权限
async function requestNotificationPermission() {
// 检查浏览器支持
if (!('Notification' in window)) {
console.log('Browser does not support notifications')
return false
}
// 已经有权限
if (Notification.permission === 'granted') {
return true
}
// 请求权限
const permission = await Notification.requestPermission()
return permission === 'granted'
}
订阅推送
async function subscribeToPush() {
const hasPermission = await requestNotificationPermission()
if (!hasPermission) return null
const registration = await navigator.serviceWorker.ready
// 获取推送订阅
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
// 创建新订阅
const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY'
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
})
}
// 发送订阅信息到服务器
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
})
return subscription
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
Service Worker 处理推送
// sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() || {}
const options = {
body: data.body || '您有新消息',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/'
},
actions: [
{ action: 'open', title: '查看' },
{ action: 'close', title: '关闭' }
]
}
event.waitUntil(
self.registration.showNotification(data.title || '通知', options)
)
})
// 处理通知点击
self.addEventListener('notificationclick', (event) => {
event.notification.close()
if (event.action === 'close') return
const url = event.notification.data.url
event.waitUntil(
clients.matchAll({ type: 'window' }).then((windowClients) => {
// 如果已有窗口打开,聚焦并导航
for (const client of windowClients) {
if (client.url === url && 'focus' in client) {
return client.focus()
}
}
// 否则打开新窗口
if (clients.openWindow) {
return clients.openWindow(url)
}
})
)
})
Nuxt 中实现 PWA
使用 @vite-pwa/nuxt
pnpm add @vite-pwa/nuxt -D
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@vite-pwa/nuxt'],
pwa: {
registerType: 'autoUpdate',
manifest: {
name: 'HTMLPAGE 应用',
short_name: 'HTMLPAGE',
description: '让网页构建更简单',
theme_color: '#4f46e5',
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
},
workbox: {
// 预缓存的资源
globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
// 运行时缓存
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 1 天
}
}
},
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 年
}
}
}
]
},
// 开发环境也启用
devOptions: {
enabled: true,
type: 'module'
}
}
})
更新提示组件
<!-- components/PWAUpdatePrompt.vue -->
<script setup lang="ts">
const { needRefresh, updateServiceWorker } = useRegisterSW({
onRegistered(registration) {
// 定期检查更新
setInterval(() => {
registration?.update()
}, 60 * 60 * 1000) // 每小时
}
})
const close = () => {
needRefresh.value = false
}
const update = () => {
updateServiceWorker()
}
</script>
<template>
<Teleport to="body">
<Transition name="slide-up">
<div v-if="needRefresh" class="pwa-update-prompt">
<div class="content">
<span>🔄 有新版本可用</span>
<div class="actions">
<button class="secondary" @click="close">稍后</button>
<button class="primary" @click="update">立即更新</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.pwa-update-prompt {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 1rem 1.5rem;
z-index: 9999;
}
.content {
display: flex;
align-items: center;
gap: 1rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
button {
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.primary {
background: #4f46e5;
color: white;
border: none;
}
.secondary {
background: transparent;
border: 1px solid #e5e7eb;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(100%);
}
</style>
最佳实践
性能优化
- 精选预缓存资源
// 只预缓存核心资源 globPatterns: ['index.html', 'main.*.js', 'main.*.css'] // 避免:globPatterns: ['**/*'] - 设置合理的缓存过期
expiration: { maxEntries: 50, // 限制条目数 maxAgeSeconds: 86400 // 限制时间 } - 使用合适的缓存策略
- 静态资源 → Cache First
- API 数据 → Network First / Stale While Revalidate
- 用户数据 → Network Only
用户体验
- 提供离线反馈
window.addEventListener('online', () => showToast('已恢复网络连接')) window.addEventListener('offline', () => showToast('您已离线')) - 优雅的更新提示
- 不强制刷新,让用户选择
- 说明更新内容
- 渐进增强
- PWA 功能失败时不影响基本使用
- 检测功能支持再启用
检查清单
- HTTPS 已启用
- Manifest 文件完整
- 多尺寸图标(192x192、512x512、maskable)
- Service Worker 正常注册
- 离线页面可用
- 缓存策略合理
- 更新机制正常
- Lighthouse PWA 审计通过
总结
PWA 让 Web 应用具备了类原生的体验:
- Manifest:定义应用元数据,支持安装
- Service Worker:实现离线缓存和推送
- 缓存策略:根据资源类型选择合适策略
- 安装体验:自定义安装提示,检测运行模式
- 推送通知:重新激活用户
PWA 不是全有或全无的选择,你可以根据需求逐步添加功能。从简单的离线支持开始,逐步完善到完整的 PWA 体验。


