- 发布于
PWA 渐进式 Web 应用:打造原生应用体验的 Web 应用
- 作者

- 姓名
- 全能波
- GitHub
- @weicracker
PWA 渐进式 Web 应用:打造原生应用体验的 Web 应用
PWA(Progressive Web App)是一种使用现代 Web 技术构建的应用程序,它结合了 Web 和原生应用的最佳特性。本文将深入探讨 PWA 的核心技术和实现方法。
PWA 核心概念
什么是 PWA?
PWA 是一种 Web 应用程序,它使用现代 Web 功能为用户提供类似原生应用的体验。PWA 具有以下特征:
- 渐进式:适用于所有浏览器
- 响应式:适配各种设备和屏幕尺寸
- 离线功能:通过 Service Worker 实现离线访问
- 类原生体验:具有应用外壳和全屏体验
- 安全:通过 HTTPS 提供服务
- 可安装:可以添加到主屏幕
- 可更新:通过 Service Worker 自动更新
PWA 核心技术栈
// PWA 技术组成
const PWATechnologies = {
serviceWorker: {
purpose: '后台脚本,处理网络请求、缓存、推送通知',
features: ['离线缓存', '后台同步', '推送通知', '网络代理'],
},
webAppManifest: {
purpose: '应用元数据配置文件',
features: ['应用图标', '启动画面', '显示模式', '主题颜色'],
},
cacheAPI: {
purpose: '程序化缓存管理',
features: ['缓存存储', '缓存更新', '缓存策略'],
},
webAPIs: {
purpose: '现代浏览器 API',
features: ['推送通知', '后台同步', '设备访问', '支付 API'],
},
}
Service Worker 实现
1. Service Worker 注册
// main.js - 注册 Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
})
console.log('Service Worker 注册成功:', registration.scope)
// 监听更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本可用
showUpdateNotification()
}
})
})
} catch (error) {
console.error('Service Worker 注册失败:', error)
}
})
}
// 显示更新通知
function showUpdateNotification() {
const notification = document.createElement('div')
notification.className = 'update-notification'
notification.innerHTML = `
<div class="notification-content">
<p>发现新版本,是否立即更新?</p>
<button onclick="updateApp()">立即更新</button>
<button onclick="dismissUpdate()">稍后提醒</button>
</div>
`
document.body.appendChild(notification)
}
// 更新应用
function updateApp() {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' })
}
window.location.reload()
}
2. Service Worker 核心逻辑
// sw.js - Service Worker 文件
const CACHE_NAME = 'pwa-app-v1.2.0'
const STATIC_CACHE = 'static-v1'
const DYNAMIC_CACHE = 'dynamic-v1'
// 需要缓存的静态资源
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/css/app.css',
'/js/app.js',
'/images/icon-192.png',
'/images/icon-512.png',
'/offline.html',
]
// 安装事件 - 缓存静态资源
self.addEventListener('install', (event) => {
console.log('Service Worker 安装中...')
event.waitUntil(
caches
.open(STATIC_CACHE)
.then((cache) => {
console.log('缓存静态资源')
return cache.addAll(STATIC_ASSETS)
})
.then(() => {
// 强制激活新的 Service Worker
return self.skipWaiting()
})
)
})
// 激活事件 - 清理旧缓存
self.addEventListener('activate', (event) => {
console.log('Service Worker 激活中...')
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
console.log('删除旧缓存:', cacheName)
return caches.delete(cacheName)
}
})
)
})
.then(() => {
// 立即控制所有客户端
return self.clients.claim()
})
)
})
// 拦截网络请求
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
// 跳过非 HTTP 请求
if (!url.protocol.startsWith('http')) {
return
}
event.respondWith(handleRequest(request))
})
// 请求处理策略
async function handleRequest(request) {
const url = new URL(request.url)
// 静态资源 - Cache First 策略
if (STATIC_ASSETS.includes(url.pathname)) {
return cacheFirst(request)
}
// API 请求 - Network First 策略
if (url.pathname.startsWith('/api/')) {
return networkFirst(request)
}
// 页面请求 - Stale While Revalidate 策略
if (request.mode === 'navigate') {
return staleWhileRevalidate(request)
}
// 其他资源 - Cache First 策略
return cacheFirst(request)
}
// Cache First 策略
async function cacheFirst(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) {
// 网络失败,返回离线页面
if (request.mode === 'navigate') {
return caches.match('/offline.html')
}
throw error
}
}
// Network First 策略
async function networkFirst(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) {
// 网络失败,尝试从缓存获取
const cachedResponse = await caches.match(request)
if (cachedResponse) {
return cachedResponse
}
throw error
}
}
// Stale While Revalidate 策略
async function staleWhileRevalidate(request) {
const cache = await caches.open(DYNAMIC_CACHE)
const cachedResponse = await cache.match(request)
// 后台更新缓存
const networkResponsePromise = fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
cache.put(request, networkResponse.clone())
}
return networkResponse
})
.catch(() => cachedResponse)
// 返回缓存的响应或等待网络响应
return cachedResponse || networkResponsePromise
}
// 处理消息
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
Web App Manifest
1. Manifest 配置
{
"name": "PWA 示例应用",
"short_name": "PWA App",
"description": "一个功能完整的 PWA 示例应用",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#1890ff",
"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"],
"shortcuts": [
{
"name": "新建任务",
"short_name": "新建",
"description": "快速创建新任务",
"url": "/tasks/new",
"icons": [
{
"src": "/images/shortcut-new.png",
"sizes": "96x96"
}
]
},
{
"name": "我的任务",
"short_name": "任务",
"description": "查看我的任务列表",
"url": "/tasks/my",
"icons": [
{
"src": "/images/shortcut-tasks.png",
"sizes": "96x96"
}
]
}
]
}
2. HTML 中的 Manifest 引用
<!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 Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- iOS Safari 支持 -->
<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" />
<link rel="apple-touch-icon" href="/images/icon-192.png" />
<!-- 主题颜色 -->
<meta name="theme-color" content="#1890ff" />
<meta name="msapplication-TileColor" content="#1890ff" />
<!-- 其他图标 -->
<link rel="icon" type="image/png" sizes="32x32" href="/images/icon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/images/icon-16.png" />
</head>
<body>
<div id="app"></div>
<!-- 安装提示 -->
<div id="install-prompt" class="install-prompt hidden">
<div class="prompt-content">
<h3>安装应用</h3>
<p>将此应用添加到主屏幕以获得更好的体验</p>
<button id="install-button">安装</button>
<button id="dismiss-button">稍后</button>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>
推送通知
1. 推送通知注册
// 推送通知管理
class PushNotificationManager {
constructor() {
this.vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY'
}
// 请求通知权限
async requestPermission() {
if (!('Notification' in window)) {
throw new Error('此浏览器不支持通知')
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
throw new Error('用户拒绝了通知权限')
}
return permission
}
// 订阅推送通知
async subscribe() {
try {
await this.requestPermission()
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
})
// 将订阅信息发送到服务器
await this.sendSubscriptionToServer(subscription)
return subscription
} catch (error) {
console.error('订阅推送通知失败:', error)
throw error
}
}
// 取消订阅
async unsubscribe() {
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
await this.removeSubscriptionFromServer(subscription)
}
} catch (error) {
console.error('取消订阅失败:', error)
throw error
}
}
// 发送订阅信息到服务器
async sendSubscriptionToServer(subscription) {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
})
if (!response.ok) {
throw new Error('发送订阅信息失败')
}
}
// 从服务器移除订阅信息
async removeSubscriptionFromServer(subscription) {
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
})
}
// 转换 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
}
}
// 使用推送通知
const pushManager = new PushNotificationManager()
document.getElementById('enable-notifications').addEventListener('click', async () => {
try {
await pushManager.subscribe()
console.log('推送通知已启用')
} catch (error) {
console.error('启用推送通知失败:', error)
}
})
2. Service Worker 中处理推送
// sw.js 中处理推送事件
self.addEventListener('push', (event) => {
console.log('收到推送消息')
let notificationData = {
title: '新消息',
body: '您有一条新消息',
icon: '/images/icon-192.png',
badge: '/images/badge.png',
tag: 'default',
requireInteraction: false,
actions: [
{
action: 'view',
title: '查看',
icon: '/images/view-icon.png',
},
{
action: 'dismiss',
title: '忽略',
icon: '/images/dismiss-icon.png',
},
],
}
if (event.data) {
try {
const data = event.data.json()
notificationData = { ...notificationData, ...data }
} catch (error) {
console.error('解析推送数据失败:', error)
}
}
event.waitUntil(self.registration.showNotification(notificationData.title, notificationData))
})
// 处理通知点击
self.addEventListener('notificationclick', (event) => {
console.log('通知被点击:', event.notification.tag)
event.notification.close()
if (event.action === 'view') {
// 打开应用
event.waitUntil(clients.openWindow('/'))
} else if (event.action === 'dismiss') {
// 忽略通知
console.log('用户忽略了通知')
} else {
// 默认行为:打开应用
event.waitUntil(clients.openWindow('/'))
}
})
离线功能实现
1. 离线页面
<!-- offline.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 App</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.offline-container {
text-align: center;
max-width: 400px;
}
.offline-icon {
font-size: 64px;
margin-bottom: 20px;
}
.retry-button {
background: rgba(255, 255, 255, 0.2);
border: 2px solid white;
color: white;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
margin-top: 20px;
}
.retry-button:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>
</head>
<body>
<div class="offline-container">
<div class="offline-icon">📱</div>
<h1>您当前处于离线状态</h1>
<p>请检查您的网络连接,然后重试。</p>
<button class="retry-button" onclick="window.location.reload()">重新连接</button>
</div>
</body>
</html>
2. 离线数据同步
// 离线数据管理
class OfflineDataManager {
constructor() {
this.dbName = 'PWAOfflineDB'
this.dbVersion = 1
this.db = null
}
// 初始化数据库
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion)
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('tasks')) {
const taskStore = db.createObjectStore('tasks', { keyPath: 'id' })
taskStore.createIndex('status', 'status', { unique: false })
taskStore.createIndex('createdAt', 'createdAt', { unique: false })
}
if (!db.objectStoreNames.contains('syncQueue')) {
db.createObjectStore('syncQueue', { keyPath: 'id', autoIncrement: true })
}
}
})
}
// 保存数据到本地
async saveData(storeName, data) {
const transaction = this.db.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
return store.put(data)
}
// 从本地获取数据
async getData(storeName, key) {
const transaction = this.db.transaction([storeName], 'readonly')
const store = transaction.objectStore(storeName)
return store.get(key)
}
// 获取所有数据
async getAllData(storeName) {
const transaction = this.db.transaction([storeName], 'readonly')
const store = transaction.objectStore(storeName)
return store.getAll()
}
// 添加到同步队列
async addToSyncQueue(action, data) {
const syncItem = {
action,
data,
timestamp: Date.now(),
retries: 0,
}
return this.saveData('syncQueue', syncItem)
}
// 处理同步队列
async processSyncQueue() {
const syncItems = await this.getAllData('syncQueue')
for (const item of syncItems) {
try {
await this.syncItem(item)
await this.removeFromSyncQueue(item.id)
} catch (error) {
console.error('同步失败:', error)
item.retries++
if (item.retries < 3) {
await this.saveData('syncQueue', item)
} else {
await this.removeFromSyncQueue(item.id)
}
}
}
}
// 同步单个项目
async syncItem(item) {
const { action, data } = item
switch (action) {
case 'CREATE_TASK':
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
break
case 'UPDATE_TASK':
await fetch(`/api/tasks/${data.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
break
case 'DELETE_TASK':
await fetch(`/api/tasks/${data.id}`, {
method: 'DELETE',
})
break
}
}
// 从同步队列中移除
async removeFromSyncQueue(id) {
const transaction = this.db.transaction(['syncQueue'], 'readwrite')
const store = transaction.objectStore('syncQueue')
return store.delete(id)
}
}
// 在 Service Worker 中处理后台同步
self.addEventListener('sync', (event) => {
if (event.tag === 'background-sync') {
event.waitUntil(
(async () => {
const offlineManager = new OfflineDataManager()
await offlineManager.init()
await offlineManager.processSyncQueue()
})()
)
}
})
应用安装
1. 安装提示
// 应用安装管理
class InstallManager {
constructor() {
this.deferredPrompt = null
this.init()
}
init() {
// 监听安装提示事件
window.addEventListener('beforeinstallprompt', (event) => {
console.log('可以安装 PWA')
// 阻止默认的安装提示
event.preventDefault()
// 保存事件以便后续使用
this.deferredPrompt = event
// 显示自定义安装按钮
this.showInstallButton()
})
// 监听应用安装事件
window.addEventListener('appinstalled', () => {
console.log('PWA 已安装')
this.hideInstallButton()
this.deferredPrompt = null
})
}
// 显示安装按钮
showInstallButton() {
const installPrompt = document.getElementById('install-prompt')
const installButton = document.getElementById('install-button')
const dismissButton = document.getElementById('dismiss-button')
if (installPrompt) {
installPrompt.classList.remove('hidden')
installButton.addEventListener('click', () => {
this.promptInstall()
})
dismissButton.addEventListener('click', () => {
this.hideInstallButton()
})
}
}
// 隐藏安装按钮
hideInstallButton() {
const installPrompt = document.getElementById('install-prompt')
if (installPrompt) {
installPrompt.classList.add('hidden')
}
}
// 提示安装
async promptInstall() {
if (!this.deferredPrompt) {
return
}
// 显示安装提示
this.deferredPrompt.prompt()
// 等待用户响应
const { outcome } = await this.deferredPrompt.userChoice
console.log(`用户选择: ${outcome}`)
// 清理
this.deferredPrompt = null
this.hideInstallButton()
}
// 检查是否已安装
isInstalled() {
return (
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true
)
}
}
// 初始化安装管理器
const installManager = new InstallManager()
// 检查是否在独立模式下运行
if (installManager.isInstalled()) {
console.log('应用运行在独立模式下')
document.body.classList.add('standalone')
}
性能优化
1. 资源预加载
// 资源预加载策略
class ResourcePreloader {
constructor() {
this.criticalResources = ['/css/critical.css', '/js/critical.js', '/images/hero.jpg']
this.preloadResources = ['/css/app.css', '/js/app.js', '/images/icon-192.png']
}
// 预加载关键资源
preloadCriticalResources() {
this.criticalResources.forEach((resource) => {
const link = document.createElement('link')
link.rel = 'preload'
link.href = resource
if (resource.endsWith('.css')) {
link.as = 'style'
} else if (resource.endsWith('.js')) {
link.as = 'script'
} else if (resource.match(/\.(jpg|jpeg|png|webp)$/)) {
link.as = 'image'
}
document.head.appendChild(link)
})
}
// 预获取非关键资源
prefetchResources() {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
this.preloadResources.forEach((resource) => {
const link = document.createElement('link')
link.rel = 'prefetch'
link.href = resource
document.head.appendChild(link)
})
})
}
}
// 预连接到外部域名
preconnectToDomains() {
const domains = ['https://api.example.com', 'https://cdn.example.com']
domains.forEach((domain) => {
const link = document.createElement('link')
link.rel = 'preconnect'
link.href = domain
document.head.appendChild(link)
})
}
}
// 初始化资源预加载
const preloader = new ResourcePreloader()
preloader.preloadCriticalResources()
preloader.prefetchResources()
preloader.preconnectToDomains()
2. 性能监控
// PWA 性能监控
class PWAPerformanceMonitor {
constructor() {
this.metrics = {}
this.init()
}
init() {
// 监控页面加载性能
window.addEventListener('load', () => {
this.measurePageLoad()
})
// 监控 Service Worker 性能
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'CACHE_HIT') {
this.recordCacheHit(event.data.url)
}
})
}
}
// 测量页面加载性能
measurePageLoad() {
if ('performance' in window) {
const navigation = performance.getEntriesByType('navigation')[0]
this.metrics.pageLoad = {
dns: navigation.domainLookupEnd - navigation.domainLookupStart,
tcp: navigation.connectEnd - navigation.connectStart,
request: navigation.responseStart - navigation.requestStart,
response: navigation.responseEnd - navigation.responseStart,
dom: navigation.domContentLoadedEventEnd - navigation.responseEnd,
load: navigation.loadEventEnd - navigation.loadEventStart,
total: navigation.loadEventEnd - navigation.navigationStart,
}
console.log('页面加载性能:', this.metrics.pageLoad)
this.sendMetrics()
}
}
// 记录缓存命中
recordCacheHit(url) {
if (!this.metrics.cacheHits) {
this.metrics.cacheHits = {}
}
this.metrics.cacheHits[url] = (this.metrics.cacheHits[url] || 0) + 1
}
// 发送性能指标
sendMetrics() {
if (navigator.onLine) {
fetch('/api/metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
metrics: this.metrics,
timestamp: Date.now(),
userAgent: navigator.userAgent,
}),
}).catch((error) => {
console.error('发送性能指标失败:', error)
})
}
}
}
// 初始化性能监控
const performanceMonitor = new PWAPerformanceMonitor()
总结
PWA 技术为 Web 应用提供了接近原生应用的体验:
- 离线功能:通过 Service Worker 实现离线访问
- 可安装性:可以添加到主屏幕,像原生应用一样启动
- 推送通知:支持后台推送通知
- 响应式设计:适配各种设备和屏幕尺寸
- 安全性:必须通过 HTTPS 提供服务
- 渐进式增强:在支持的浏览器中提供更好的体验
掌握 PWA 技术,你就能构建出功能强大、用户体验优秀的现代 Web 应用!
PWA 是 Web 应用的未来发展方向,值得深入学习和实践。