渐进式 Web 应用(PWA)开发指南
渐进式 Web 应用(Progressive Web App, PWA)结合了 Web 和原生应用的优势,为用户提供快速、可靠、引人入胜的体验。本文将深入探讨 PWA 的核心技术和开发实践。
🎯 PWA 核心特性
PWA 的三大支柱
1. 可靠性 (Reliable)
- 即使在网络不稳定的情况下也能快速加载
- 离线功能支持
- 缓存策略优化
2. 快速性 (Fast)
- 快速响应用户交互
- 流畅的动画和导航
- 优化的性能表现
3. 引人入胜 (Engaging)
- 类似原生应用的用户体验
- 推送通知功能
- 可安装到设备主屏幕
PWA 检查清单
javascript
// PWA 核心要求检查
const PWAChecklist = {
// 基础要求
basic: {
https: true, // 必须使用 HTTPS
serviceWorker: true, // 注册 Service Worker
webAppManifest: true, // 提供 Web App Manifest
responsiveDesign: true, // 响应式设计
offlineSupport: true // 离线功能支持
},
// 增强功能
enhanced: {
pushNotifications: false, // 推送通知
backgroundSync: false, // 后台同步
installPrompt: false, // 安装提示
appShell: false, // 应用外壳架构
cacheFirst: false // 缓存优先策略
},
// 性能指标
performance: {
firstContentfulPaint: 1.5, // FCP < 1.5s
largestContentfulPaint: 2.5, // LCP < 2.5s
firstInputDelay: 100, // FID < 100ms
cumulativeLayoutShift: 0.1 // CLS < 0.1
}
}
🔧 Service Worker 实现
基础 Service Worker
javascript
// sw.js - Service Worker 主文件
const CACHE_NAME = 'pwa-cache-v1'
const STATIC_CACHE = 'static-cache-v1'
const DYNAMIC_CACHE = 'dynamic-cache-v1'
// 需要缓存的静态资源
const STATIC_ASSETS = [
'/',
'/index.html',
'/css/styles.css',
'/js/app.js',
'/images/icon-192.png',
'/images/icon-512.png',
'/offline.html'
]
// 安装事件 - 缓存静态资源
self.addEventListener('install', (event) => {
console.log('Service Worker: Installing...')
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log('Service Worker: Caching static assets')
return cache.addAll(STATIC_ASSETS)
})
.then(() => {
console.log('Service Worker: Static assets cached')
return self.skipWaiting() // 立即激活新的 Service Worker
})
.catch((error) => {
console.error('Service Worker: Cache failed', error)
})
)
})
// 激活事件 - 清理旧缓存
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activating...')
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
console.log('Service Worker: Deleting old cache', cacheName)
return caches.delete(cacheName)
}
})
)
})
.then(() => {
console.log('Service Worker: Activated')
return self.clients.claim() // 立即控制所有页面
})
)
})
// 拦截网络请求
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
// 只处理同源请求
if (url.origin !== location.origin) {
return
}
// 不同类型的请求使用不同的缓存策略
if (request.destination === 'document') {
// HTML 文档 - 网络优先策略
event.respondWith(networkFirstStrategy(request))
} else if (request.destination === 'image') {
// 图片 - 缓存优先策略
event.respondWith(cacheFirstStrategy(request))
} else if (request.url.includes('/api/')) {
// API 请求 - 网络优先,支持离线
event.respondWith(networkFirstWithOfflineStrategy(request))
} else {
// 其他静态资源 - 缓存优先策略
event.respondWith(cacheFirstStrategy(request))
}
})
// 缓存优先策略
async function cacheFirstStrategy(request) {
try {
const cachedResponse = await caches.match(request)
if (cachedResponse) {
return cachedResponse
}
const networkResponse = await fetch(request)
// 缓存成功的响应
if (networkResponse.ok) {
const cache = await caches.open(DYNAMIC_CACHE)
cache.put(request, networkResponse.clone())
}
return networkResponse
} catch (error) {
console.error('Cache first strategy failed:', error)
// 返回离线页面
if (request.destination === 'document') {
return caches.match('/offline.html')
}
// 返回默认图片
if (request.destination === 'image') {
return caches.match('/images/offline-image.png')
}
throw error
}
}
// 网络优先策略
async function networkFirstStrategy(request) {
try {
const networkResponse = await fetch(request)
// 缓存成功的响应
if (networkResponse.ok) {
const cache = await caches.open(DYNAMIC_CACHE)
cache.put(request, networkResponse.clone())
}
return networkResponse
} catch (error) {
console.error('Network first strategy failed:', error)
// 网络失败时从缓存获取
const cachedResponse = await caches.match(request)
if (cachedResponse) {
return cachedResponse
}
// 返回离线页面
return caches.match('/offline.html')
}
}
// 网络优先策略(支持离线)
async function networkFirstWithOfflineStrategy(request) {
try {
const networkResponse = await fetch(request)
// 缓存 API 响应
if (networkResponse.ok) {
const cache = await caches.open(DYNAMIC_CACHE)
cache.put(request, networkResponse.clone())
}
return networkResponse
} catch (error) {
console.error('API request failed:', error)
// 尝试从缓存获取
const cachedResponse = await caches.match(request)
if (cachedResponse) {
return cachedResponse
}
// 返回离线 API 响应
return new Response(
JSON.stringify({
error: 'Offline',
message: '当前处于离线状态,请稍后重试'
}),
{
status: 503,
statusText: 'Service Unavailable',
headers: {
'Content-Type': 'application/json'
}
}
)
}
}
// 后台同步
self.addEventListener('sync', (event) => {
console.log('Service Worker: Background sync', event.tag)
if (event.tag === 'background-sync') {
event.waitUntil(doBackgroundSync())
}
})
// 执行后台同步
async function doBackgroundSync() {
try {
// 获取待同步的数据
const pendingData = await getPendingData()
// 同步数据到服务器
for (const data of pendingData) {
await syncDataToServer(data)
}
console.log('Background sync completed')
} catch (error) {
console.error('Background sync failed:', error)
}
}
// 推送通知
self.addEventListener('push', (event) => {
console.log('Service Worker: Push received')
const options = {
body: event.data ? event.data.text() : '您有新的消息',
icon: '/images/icon-192.png',
badge: '/images/badge-72.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
},
actions: [
{
action: 'explore',
title: '查看详情',
icon: '/images/checkmark.png'
},
{
action: 'close',
title: '关闭',
icon: '/images/xmark.png'
}
]
}
event.waitUntil(
self.registration.showNotification('PWA 通知', options)
)
})
// 通知点击事件
self.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event.action)
event.notification.close()
if (event.action === 'explore') {
// 打开应用
event.waitUntil(
clients.openWindow('/')
)
}
})
Service Worker 注册
javascript
// app.js - 主应用文件
class PWAManager {
constructor() {
this.swRegistration = null
this.isOnline = navigator.onLine
this.init()
}
async init() {
// 注册 Service Worker
await this.registerServiceWorker()
// 监听网络状态变化
this.setupNetworkListeners()
// 设置安装提示
this.setupInstallPrompt()
// 请求通知权限
this.requestNotificationPermission()
// 设置后台同步
this.setupBackgroundSync()
}
// 注册 Service Worker
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
this.swRegistration = await navigator.serviceWorker.register('/sw.js')
console.log('Service Worker registered:', this.swRegistration)
// 监听 Service Worker 更新
this.swRegistration.addEventListener('updatefound', () => {
const newWorker = this.swRegistration.installing
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本可用
this.showUpdateAvailable()
}
})
})
} catch (error) {
console.error('Service Worker registration failed:', error)
}
}
}
// 显示更新可用提示
showUpdateAvailable() {
const updateBanner = document.createElement('div')
updateBanner.className = 'update-banner'
updateBanner.innerHTML = `
<div class="update-content">
<span>新版本可用</span>
<button id="update-btn">更新</button>
<button id="dismiss-btn">稍后</button>
</div>
`
document.body.appendChild(updateBanner)
// 更新按钮事件
document.getElementById('update-btn').addEventListener('click', () => {
window.location.reload()
})
// 关闭按钮事件
document.getElementById('dismiss-btn').addEventListener('click', () => {
updateBanner.remove()
})
}
// 设置网络状态监听
setupNetworkListeners() {
window.addEventListener('online', () => {
this.isOnline = true
this.showNetworkStatus('已连接到网络', 'success')
this.syncPendingData()
})
window.addEventListener('offline', () => {
this.isOnline = false
this.showNetworkStatus('已离线,部分功能可能受限', 'warning')
})
}
// 显示网络状态
showNetworkStatus(message, type) {
const statusBar = document.getElementById('network-status')
if (statusBar) {
statusBar.textContent = message
statusBar.className = `network-status ${type}`
statusBar.style.display = 'block'
// 3秒后隐藏
setTimeout(() => {
statusBar.style.display = 'none'
}, 3000)
}
}
// 设置安装提示
setupInstallPrompt() {
let deferredPrompt
window.addEventListener('beforeinstallprompt', (event) => {
// 阻止默认的安装提示
event.preventDefault()
deferredPrompt = event
// 显示自定义安装按钮
this.showInstallButton(deferredPrompt)
})
// 监听应用安装
window.addEventListener('appinstalled', () => {
console.log('PWA was installed')
this.hideInstallButton()
this.trackEvent('pwa_installed')
})
}
// 显示安装按钮
showInstallButton(deferredPrompt) {
const installBtn = document.getElementById('install-btn')
if (installBtn) {
installBtn.style.display = 'block'
installBtn.addEventListener('click', async () => {
// 显示安装提示
deferredPrompt.prompt()
// 等待用户响应
const { outcome } = await deferredPrompt.userChoice
console.log(`User response to the install prompt: ${outcome}`)
// 清除 deferredPrompt
deferredPrompt = null
this.hideInstallButton()
// 跟踪用户选择
this.trackEvent('install_prompt_response', { outcome })
})
}
}
// 隐藏安装按钮
hideInstallButton() {
const installBtn = document.getElementById('install-btn')
if (installBtn) {
installBtn.style.display = 'none'
}
}
// 请求通知权限
async requestNotificationPermission() {
if ('Notification' in window && 'serviceWorker' in navigator) {
const permission = await Notification.requestPermission()
if (permission === 'granted') {
console.log('Notification permission granted')
await this.subscribeToPushNotifications()
} else {
console.log('Notification permission denied')
}
}
}
// 订阅推送通知
async subscribeToPushNotifications() {
try {
const subscription = await this.swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
})
console.log('Push subscription:', subscription)
// 发送订阅信息到服务器
await this.sendSubscriptionToServer(subscription)
} catch (error) {
console.error('Failed to subscribe to push notifications:', error)
}
}
// 发送订阅信息到服务器
async sendSubscriptionToServer(subscription) {
try {
await fetch('/api/push-subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
})
} catch (error) {
console.error('Failed to send subscription to server:', error)
}
}
// 设置后台同步
setupBackgroundSync() {
if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
// 监听表单提交
document.addEventListener('submit', (event) => {
if (!this.isOnline) {
event.preventDefault()
this.saveDataForSync(event.target)
this.registerBackgroundSync()
}
})
}
}
// 保存数据用于后台同步
saveDataForSync(form) {
const formData = new FormData(form)
const data = Object.fromEntries(formData.entries())
// 保存到 IndexedDB 或 localStorage
const pendingData = JSON.parse(localStorage.getItem('pendingSync') || '[]')
pendingData.push({
id: Date.now(),
data: data,
timestamp: new Date().toISOString()
})
localStorage.setItem('pendingSync', JSON.stringify(pendingData))
this.showMessage('数据已保存,将在网络恢复时自动同步', 'info')
}
// 注册后台同步
async registerBackgroundSync() {
try {
await this.swRegistration.sync.register('background-sync')
console.log('Background sync registered')
} catch (error) {
console.error('Background sync registration failed:', error)
}
}
// 同步待处理数据
async syncPendingData() {
const pendingData = JSON.parse(localStorage.getItem('pendingSync') || '[]')
if (pendingData.length > 0) {
try {
for (const item of pendingData) {
await this.syncDataToServer(item.data)
}
// 清除已同步的数据
localStorage.removeItem('pendingSync')
this.showMessage('离线数据已同步', 'success')
} catch (error) {
console.error('Data sync failed:', error)
}
}
}
// 同步数据到服务器
async syncDataToServer(data) {
const response = await fetch('/api/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
if (!response.ok) {
throw new Error(`Sync failed: ${response.statusText}`)
}
return response.json()
}
// 显示消息
showMessage(message, type = 'info') {
const messageEl = document.createElement('div')
messageEl.className = `message ${type}`
messageEl.textContent = message
document.body.appendChild(messageEl)
setTimeout(() => {
messageEl.remove()
}, 5000)
}
// 跟踪事件
trackEvent(eventName, data = {}) {
// 发送到分析服务
if ('gtag' in window) {
gtag('event', eventName, data)
}
}
// 工具方法:转换 VAPID 密钥
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
}
}
// VAPID 公钥(需要替换为实际的密钥)
const VAPID_PUBLIC_KEY = 'your-vapid-public-key-here'
// 初始化 PWA 管理器
const pwaManager = new PWAManager()
📱 Web App Manifest
完整的 Manifest 配置
json
{
"name": "我的 PWA 应用",
"short_name": "PWA App",
"description": "一个功能完整的渐进式 Web 应用示例",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#2196F3",
"background_color": "#ffffff",
"lang": "zh-CN",
"dir": "ltr",
"icons": [
{
"src": "/images/icon-72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icon-96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icon-128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icon-144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icon-152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/images/icon-384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/images/screenshot-mobile.png",
"sizes": "375x812",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/images/screenshot-desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
],
"categories": ["productivity", "utilities"],
"iarc_rating_id": "e84b072d-71b3-4d3e-86ae-31a8ce4e53b7",
"shortcuts": [
{
"name": "新建文档",
"short_name": "新建",
"description": "快速创建新文档",
"url": "/new",
"icons": [
{
"src": "/images/shortcut-new.png",
"sizes": "96x96"
}
]
},
{
"name": "搜索",
"short_name": "搜索",
"description": "搜索文档和内容",
"url": "/search",
"icons": [
{
"src": "/images/shortcut-search.png",
"sizes": "96x96"
}
]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "files",
"accept": ["image/*", "text/*"]
}
]
}
},
"protocol_handlers": [
{
"protocol": "web+pwa",
"url": "/handle?url=%s"
}
],
"prefer_related_applications": false,
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=com.example.app",
"id": "com.example.app"
}
]
}
🎨 应用外壳架构
App Shell 实现
html
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PWA 应用</title>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#2196F3">
<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="PWA App">
<!-- Icons -->
<link rel="icon" type="image/png" sizes="32x32" href="/images/icon-32.png">
<link rel="apple-touch-icon" href="/images/icon-180.png">
<!-- Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Styles -->
<link rel="stylesheet" href="/css/app-shell.css">
</head>
<body>
<!-- App Shell -->
<div id="app-shell">
<!-- Header -->
<header class="app-header">
<button class="menu-btn" id="menu-btn" aria-label="菜单">
<span class="hamburger"></span>
</button>
<h1 class="app-title">PWA 应用</h1>
<button class="install-btn" id="install-btn" style="display: none;">
安装应用
</button>
</header>
<!-- Navigation -->
<nav class="app-nav" id="app-nav">
<ul class="nav-list">
<li><a href="/" class="nav-link active">首页</a></li>
<li><a href="/features" class="nav-link">功能</a></li>
<li><a href="/settings" class="nav-link">设置</a></li>
<li><a href="/about" class="nav-link">关于</a></li>
</ul>
</nav>
<!-- Main Content -->
<main class="app-main" id="app-main">
<div class="loading-spinner" id="loading-spinner">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<div class="content-container" id="content-container">
<!-- 动态内容将在这里加载 -->
</div>
</main>
<!-- Network Status -->
<div class="network-status" id="network-status" style="display: none;"></div>
</div>
<!-- Scripts -->
<script src="/js/app.js"></script>
</body>
</html>
App Shell 样式
css
/* app-shell.css */
:root {
--primary-color: #2196F3;
--primary-dark: #1976D2;
--secondary-color: #FFC107;
--background-color: #f5f5f5;
--surface-color: #ffffff;
--text-primary: #212121;
--text-secondary: #757575;
--border-color: #e0e0e0;
--shadow: 0 2px 4px rgba(0,0,0,0.1);
--header-height: 56px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--background-color);
color: var(--text-primary);
line-height: 1.6;
}
/* App Shell Layout */
#app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header */
.app-header {
display: flex;
align-items: center;
height: var(--header-height);
background-color: var(--primary-color);
color: white;
padding: 0 16px;
box-shadow: var(--shadow);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
.menu-btn {
background: none;
border: none;
color: white;
padding: 8px;
margin-right: 16px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
}
.menu-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.hamburger {
display: block;
width: 20px;
height: 2px;
background-color: white;
position: relative;
}
.hamburger::before,
.hamburger::after {
content: '';
position: absolute;
width: 20px;
height: 2px;
background-color: white;
transition: transform 0.3s;
}
.hamburger::before {
top: -6px;
}
.hamburger::after {
top: 6px;
}
.app-title {
flex: 1;
font-size: 20px;
font-weight: 500;
}
.install-btn {
background-color: var(--secondary-color);
color: var(--text-primary);
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.install-btn:hover {
background-color: #FFB300;
}
/* Navigation */
.app-nav {
background-color: var(--surface-color);
border-right: 1px solid var(--border-color);
position: fixed;
top: var(--header-height);
left: -250px;
width: 250px;
height: calc(100vh - var(--header-height));
transition: left 0.3s;
z-index: 999;
overflow-y: auto;
}
.app-nav.open {
left: 0;
}
.nav-list {
list-style: none;
padding: 16px 0;
}
.nav-link {
display: block;
padding: 12px 24px;
color: var(--text-primary);
text-decoration: none;
transition: background-color 0.2s;
}
.nav-link:hover,
.nav-link.active {
background-color: var(--background-color);
color: var(--primary-color);
}
/* Main Content */
.app-main {
flex: 1;
margin-top: var(--header-height);
padding: 16px;
transition: margin-left 0.3s;
}
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid var(--border-color);
border-top: 4px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.content-container {
max-width: 800px;
margin: 0 auto;
background-color: var(--surface-color);
border-radius: 8px;
padding: 24px;
box-shadow: var(--shadow);
}
/* Network Status */
.network-status {
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
padding: 8px 16px;
text-align: center;
font-size: 14px;
z-index: 998;
}
.network-status.success {
background-color: #4CAF50;
color: white;
}
.network-status.warning {
background-color: #FF9800;
color: white;
}
.network-status.error {
background-color: #F44336;
color: white;
}
/* 响应式设计 */
@media (min-width: 768px) {
.menu-btn {
display: none;
}
.app-nav {
position: static;
width: 250px;
height: auto;
border-right: 1px solid var(--border-color);
}
#app-shell {
flex-direction: row;
}
.app-main {
margin-top: 0;
margin-left: 0;
}
.app-header {
position: static;
}
}
/* PWA 特定样式 */
@media (display-mode: standalone) {
.app-header {
padding-top: env(safe-area-inset-top);
}
.install-btn {
display: none !important;
}
}
/* 消息样式 */
.message {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
border-radius: 4px;
color: white;
font-weight: 500;
z-index: 1001;
animation: slideUp 0.3s ease-out;
}
.message.info {
background-color: var(--primary-color);
}
.message.success {
background-color: #4CAF50;
}
.message.warning {
background-color: #FF9800;
}
.message.error {
background-color: #F44336;
}
@keyframes slideUp {
from {
transform: translateX(-50%) translateY(100%);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
/* 更新横幅 */
.update-banner {
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
background-color: var(--secondary-color);
color: var(--text-primary);
padding: 12px 16px;
z-index: 1000;
animation: slideDown 0.3s ease-out;
}
.update-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 800px;
margin: 0 auto;
}
.update-content button {
background: none;
border: 1px solid var(--text-primary);
color: var(--text-primary);
padding: 6px 12px;
margin-left: 8px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.update-content button:hover {
background-color: var(--text-primary);
color: var(--secondary-color);
}
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
🚀 性能优化策略
缓存策略优化
javascript
// 高级缓存策略
class AdvancedCacheStrategy {
constructor() {
this.CACHE_VERSIONS = {
static: 'static-v2',
dynamic: 'dynamic-v2',
api: 'api-v1'
}
this.CACHE_EXPIRY = {
static: 30 * 24 * 60 * 60 * 1000, // 30天
dynamic: 7 * 24 * 60 * 60 * 1000, // 7天
api: 60 * 60 * 1000 // 1小时
}
}
// 智能缓存策略
async handleRequest(request) {
const url = new URL(request.url)
// 根据请求类型选择策略
if (this.isStaticAsset(url)) {
return this.cacheFirstWithRefresh(request)
} else if (this.isAPIRequest(url)) {
return this.networkFirstWithCache(request)
} else if (this.isHTMLDocument(request)) {
return this.staleWhileRevalidate(request)
} else {
return this.networkOnly(request)
}
}
// 缓存优先 + 后台刷新
async cacheFirstWithRefresh(request) {
const cache = await caches.open(this.CACHE_VERSIONS.static)
const cachedResponse = await cache.match(request)
if (cachedResponse && !this.isExpired(cachedResponse)) {
// 后台刷新缓存
this.refreshCache(request, cache)
return cachedResponse
}
try {
const networkResponse = await fetch(request)
if (networkResponse.ok) {
cache.put(request, networkResponse.clone())
}
return networkResponse
} catch (error) {
if (cachedResponse) {
return cachedResponse
}
throw error
}
}
// 网络优先 + 缓存备用
async networkFirstWithCache(request) {
const cache = await caches.open(this.CACHE_VERSIONS.api)
try {
const networkResponse = await fetch(request)
if (networkResponse.ok) {
// 添加时间戳用于过期检查
const responseWithTimestamp = new Response(networkResponse.body, {
status: networkResponse.status,
statusText: networkResponse.statusText,
headers: {
...networkResponse.headers,
'sw-cache-timestamp': Date.now().toString()
}
})
cache.put(request, responseWithTimestamp.clone())
return networkResponse
}
throw new Error(`Network response not ok: ${networkResponse.status}`)
} catch (error) {
const cachedResponse = await cache.match(request)
if (cachedResponse && !this.isExpired(cachedResponse)) {
return cachedResponse
}
throw error
}
}
// 过期时重新验证
async staleWhileRevalidate(request) {
const cache = await caches.open(this.CACHE_VERSIONS.dynamic)
const cachedResponse = await cache.match(request)
// 立即返回缓存的响应(如果存在)
const responsePromise = cachedResponse || fetch(request)
// 同时在后台更新缓存
const updatePromise = fetch(request).then(response => {
if (response.ok) {
cache.put(request, response.clone())
}
return response
}).catch(() => {
// 网络失败时忽略错误
})
return responsePromise
}
// 检查响应是否过期
isExpired(response) {
const timestamp = response.headers.get('sw-cache-timestamp')
if (!timestamp) return false
const age = Date.now() - parseInt(timestamp)
const maxAge = this.CACHE_EXPIRY.api // 默认使用 API 过期时间
return age > maxAge
}
// 后台刷新缓存
async refreshCache(request, cache) {
try {
const response = await fetch(request)
if (response.ok) {
cache.put(request, response)
}
} catch (error) {
// 后台刷新失败时忽略错误
console.log('Background cache refresh failed:', error)
}
}
// 判断是否为静态资源
isStaticAsset(url) {
const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2']
return staticExtensions.some(ext => url.pathname.endsWith(ext))
}
// 判断是否为 API 请求
isAPIRequest(url) {
return url.pathname.startsWith('/api/')
}
// 判断是否为 HTML 文档
isHTMLDocument(request) {
return request.destination === 'document'
}
// 仅网络策略
async networkOnly(request) {
return fetch(request)
}
}
预缓存和预加载
javascript
// 预缓存管理器
class PrecacheManager {
constructor() {
this.precacheList = [
// 关键资源
{ url: '/', revision: 'v1.0.0' },
{ url: '/css/app.css', revision: 'v1.2.1' },
{ url: '/js/app.js', revision: 'v1.3.0' },
// 字体文件
{ url: '/fonts/roboto-regular.woff2', revision: 'v1.0.0' },
{ url: '/fonts/roboto-bold.woff2', revision: 'v1.0.0' },
// 图标
{ url: '/images/icon-192.png', revision: 'v1.0.0' },
{ url: '/images/icon-512.png', revision: 'v1.0.0' },
// 离线页面
{ url: '/offline.html', revision: 'v1.1.0' }
]
}
// 安装预缓存
async installPrecache() {
const cache = await caches.open('precache-v1')
const urlsToCache = this.precacheList.map(item => item.url)
try {
await cache.addAll(urlsToCache)
console.log('Precache installation successful')
} catch (error) {
console.error('Precache installation failed:', error)
// 逐个缓存,跳过失败的资源
for (const item of this.precacheList) {
try {
await cache.add(item.url)
} catch (itemError) {
console.warn(`Failed to cache ${item.url}:`, itemError)
}
}
}
}
// 清理过期的预缓存
async cleanupPrecache() {
const cache = await caches.open('precache-v1')
const cachedRequests = await cache.keys()
for (const request of cachedRequests) {
const url = new URL(request.url)
const precacheItem = this.precacheList.find(item => item.url === url.pathname)
if (!precacheItem) {
// 删除不在预缓存列表中的资源
await cache.delete(request)
console.log(`Removed outdated precache: ${url.pathname}`)
}
}
}
// 预加载关键资源
preloadCriticalResources() {
const criticalResources = [
'/css/critical.css',
'/js/critical.js',
'/images/hero-image.jpg'
]
criticalResources.forEach(url => {
const link = document.createElement('link')
link.rel = 'preload'
link.href = url
if (url.endsWith('.css')) {
link.as = 'style'
} else if (url.endsWith('.js')) {
link.as = 'script'
} else if (url.match(/\.(jpg|jpeg|png|webp)$/)) {
link.as = 'image'
}
document.head.appendChild(link)
})
}
// 预取下一页资源
prefetchNextPage() {
const links = document.querySelectorAll('a[href]')
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const link = entry.target
const href = link.getAttribute('href')
if (href && !href.startsWith('#') && !href.startsWith('mailto:')) {
this.prefetchResource(href)
observer.unobserve(link)
}
}
})
}, { rootMargin: '100px' })
links.forEach(link => observer.observe(link))
}
// 预取资源
async prefetchResource(url) {
try {
const cache = await caches.open('prefetch-cache')
const response = await fetch(url)
if (response.ok) {
cache.put(url, response)
console.log(`Prefetched: ${url}`)
}
} catch (error) {
console.warn(`Prefetch failed for ${url}:`, error)
}
}
}
📊 PWA 分析和监控
性能监控
javascript
// PWA 性能监控
class PWAAnalytics {
constructor() {
this.metrics = {}
this.init()
}
init() {
this.trackInstallation()
this.trackServiceWorkerEvents()
this.trackOfflineUsage()
this.trackPerformanceMetrics()
}
// 跟踪安装事件
trackInstallation() {
window.addEventListener('beforeinstallprompt', (event) => {
this.track('pwa_install_prompt_shown', {
timestamp: Date.now()
})
})
window.addEventListener('appinstalled', () => {
this.track('pwa_installed', {
timestamp: Date.now(),
userAgent: navigator.userAgent
})
})
}
// 跟踪 Service Worker 事件
trackServiceWorkerEvents() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
this.track('sw_controller_changed', {
timestamp: Date.now()
})
})
navigator.serviceWorker.ready.then(registration => {
this.track('sw_ready', {
timestamp: Date.now(),
scope: registration.scope
})
})
}
}
// 跟踪离线使用情况
trackOfflineUsage() {
let offlineStartTime = null
window.addEventListener('offline', () => {
offlineStartTime = Date.now()
this.track('went_offline', {
timestamp: offlineStartTime
})
})
window.addEventListener('online', () => {
if (offlineStartTime) {
const offlineDuration = Date.now() - offlineStartTime
this.track('came_online', {
timestamp: Date.now(),
offlineDuration
})
offlineStartTime = null
}
})
}
// 跟踪性能指标
trackPerformanceMetrics() {
// 页面加载性能
window.addEventListener('load', () => {
setTimeout(() => {
const navigation = performance.getEntriesByType('navigation')[0]
this.track('page_load_performance', {
loadTime: navigation.loadEventEnd - navigation.loadEventStart,
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
firstByte: navigation.responseStart - navigation.requestStart,
timestamp: Date.now()
})
}, 0)
})
// Core Web Vitals
this.trackWebVitals()
}
// 跟踪 Web Vitals
trackWebVitals() {
// 这里可以集成 web-vitals 库
// import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'
// getCLS(metric => this.track('cls', metric))
// getFID(metric => this.track('fid', metric))
// getFCP(metric => this.track('fcp', metric))
// getLCP(metric => this.track('lcp', metric))
// getTTFB(metric => this.track('ttfb', metric))
}
// 发送分析数据
track(eventName, data) {
const payload = {
event: eventName,
data: {
...data,
url: location.href,
userAgent: navigator.userAgent,
timestamp: Date.now()
}
}
// 发送到分析服务
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics', JSON.stringify(payload))
} else {
fetch('/api/analytics', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
}).catch(error => {
console.error('Analytics tracking failed:', error)
})
}
}
}
// 初始化分析
const pwaAnalytics = new PWAAnalytics()
🎯 最佳实践总结
开发建议
渐进增强
- 确保基础功能在所有浏览器中可用
- PWA 功能作为增强体验
- 优雅降级处理
性能优化
- 使用应用外壳架构
- 实施智能缓存策略
- 预加载关键资源
- 代码分割和懒加载
用户体验
- 提供离线功能
- 快速响应用户交互
- 清晰的加载状态指示
- 网络状态反馈
安全性
- 强制使用 HTTPS
- 验证 Service Worker 更新
- 保护敏感数据
- 实施内容安全策略
测试策略
功能测试
- 离线功能测试
- 安装流程测试
- 推送通知测试
- 后台同步测试
性能测试
- Lighthouse PWA 审计
- 网络节流测试
- 缓存效果验证
- 内存使用监控
兼容性测试
- 多浏览器测试
- 移动设备测试
- 不同网络条件测试
- 操作系统集成测试
PWA 技术让 Web 应用具备了接近原生应用的能力,通过合理的架构设计和优化策略,可以为用户提供快速、可靠、引人入胜的体验。
拥抱 PWA,让 Web 应用无处不在!