PWA渐进式Web应用开发
渐进式Web应用(Progressive Web App,简称PWA)是Web技术的一次重要进化,它让网站能够提供接近原生应用的体验。离线访问、桌面安装、推送通知——这些曾经是原生应用专属的能力,现在Web应用也能实现。作为一个深度参与过多个PWA项目的开发者,我想带你全面了解这项技术,从基础概念到实战应用,让你也能打造出原生级别的Web体验。
什么是PWA?
PWA是一种使用现代Web技术构建的应用,它结合了Web和原生应用的优点:
- 渐进式:适用于所有浏览器,渐进增强用户体验
- 响应式:适配各种设备,手机、平板、桌面
- 离线可用:通过Service Worker实现离线功能
- 类原生体验:可以添加到主屏幕,全屏运行
- 可更新:无需应用商店审核,即时更新
- 可发现:通过搜索引擎索引,可分享URL
- 推送通知:即使用户未打开应用也能接收消息
PWA核心组件
PWA技术架构:
┌─────────────────────────────────────────┐
│ Web Application │
├─────────────────────────────────────────┤
│ Web App Manifest │
│ - 应用名称、图标、主题色 │
│ - 启动画面、显示模式 │
├─────────────────────────────────────────┤
│ Service Worker │
│ - 离线缓存 │
│ - 推送通知 │
│ - 后台同步 │
├─────────────────────────────────────────┤
│ HTTPS │
│ - 安全传输 │
│ - Service Worker必需 │
└─────────────────────────────────────────┘
Web App Manifest
Web App Manifest是一个JSON文件,描述应用的元数据。它让浏览器知道如何将网站作为应用安装。
基本配置
// manifest.json
{
"name": "HEPH工具箱",
"short_name": "HEPH",
"description": "魔法网站开发工具箱",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a12",
"theme_color": "#8b5cf6",
"orientation": "any",
"scope": "/",
"lang": "zh-CN",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["utilities", "productivity"],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "1280x720",
"type": "image/png"
}
],
"shortcuts": [
{
"name": "JSON格式化",
"short_name": "JSON",
"url": "/tools/json-formatter.html",
"icons": [{ "src": "/icons/json.png", "sizes": "96x96" }]
}
]
}
在HTML中引用
<head>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#8b5cf6">
<!-- iOS支持 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="HEPH">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
</head>
显示模式
- fullscreen:全屏模式,无浏览器UI
- standalone:独立应用窗口,推荐使用
- minimal-ui:保留最小浏览器UI
- browser:常规浏览器标签页
Service Worker基础
Service Worker是PWA的核心,它是一个在浏览器后台运行的脚本,独立于Web页面,可以拦截网络请求、管理缓存。
注册Service Worker
// main.js
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) {
// 新版本可用,提示用户刷新
console.log('新版本可用,请刷新页面');
}
});
});
} catch (error) {
console.error('Service Worker 注册失败:', error);
}
});
}
Service Worker生命周期
// sw.js - Service Worker生命周期事件
// 安装事件 - 预缓存关键资源
const CACHE_NAME = 'heph-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/css/style.css',
'/js/main.js',
'/offline.html'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting()) // 立即激活
);
});
// 激活事件 - 清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
))
.then(() => self.clients.claim()) // 立即控制页面
);
});
// 请求拦截
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
.catch(() => caches.match('/offline.html'))
);
});
缓存策略
不同的资源类型需要不同的缓存策略:
缓存优先(Cache First)
适用于静态资源,优先使用缓存,缓存不存在时才请求网络:
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
}
网络优先(Network First)
适用于动态内容,优先使用网络,网络失败时使用缓存:
async function networkFirst(request) {
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) {
const cached = await caches.match(request);
return cached || caches.match('/offline.html');
}
}
网络优先+缓存更新
快速响应的同时后台更新缓存:
async function staleWhileRevalidate(request) {
const cached = await caches.match(request);
const fetchPromise = fetch(request).then(response => {
if (response.ok) {
caches.open(CACHE_NAME).then(cache => {
cache.put(request, response.clone());
});
}
return response;
});
// 立即返回缓存,同时后台更新
return cached || fetchPromise;
}
综合缓存策略
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API请求:网络优先
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// 静态资源:缓存优先
if (isStaticAsset(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
// HTML页面:网络优先+离线回退
if (request.headers.get('accept').includes('text/html')) {
event.respondWith(networkFirst(request));
return;
}
// 其他:stale-while-revalidate
event.respondWith(staleWhileRevalidate(request));
});
function isStaticAsset(pathname) {
return /\.(js|css|png|jpg|jpeg|gif|svg|woff2?)$/.test(pathname);
}
离线功能实现
离线页面
<!-- 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>离线 - HEPH</title>
<style>
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #0a0a12;
color: #e8e8f0;
font-family: sans-serif;
text-align: center;
}
.offline-icon { font-size: 4rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<div>
<div class="offline-icon">📡</div>
<h1>您当前处于离线状态</h1>
<p>请检查网络连接后重试</p>
<button onclick="location.reload()">重试</button>
</div>
</body>
</html>
后台同步
// 注册后台同步
async function syncData() {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-data');
}
// Service Worker处理同步
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(
// 从IndexedDB获取待同步数据
getPendingData().then(data =>
// 发送到服务器
fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(data)
})
)
);
}
});
推送通知
订阅推送
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
// 请求通知权限
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('通知权限被拒绝');
}
// 订阅推送服务
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// 发送订阅信息到服务器
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription)
});
return subscription;
}
// VAPID密钥转换
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = atob(base64);
return new Uint8Array([...rawData].map(char => char.charCodeAt(0)));
}
处理推送事件
// sw.js
self.addEventListener('push', (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/icons/icon-192.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 === 'open' || !event.action) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
安装提示
自定义安装提示
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (event) => {
// 阻止默认提示
event.preventDefault();
deferredPrompt = event;
// 显示自定义安装按钮
showInstallButton();
});
async function installApp() {
if (!deferredPrompt) return;
// 显示安装提示
deferredPrompt.prompt();
// 等待用户响应
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('用户同意安装');
}
deferredPrompt = null;
}
// 安装成功事件
window.addEventListener('appinstalled', () => {
console.log('应用安装成功');
hideInstallButton();
});
数据存储
IndexedDB封装
// 使用IndexedDB存储离线数据
class OfflineDB {
constructor() {
this.dbName = 'heph-offline';
this.version = 1;
}
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('pending')) {
db.createObjectStore('pending', { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async add(store, data) {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(store, 'readwrite');
const objectStore = transaction.objectStore(store);
const request = objectStore.add(data);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getAll(store) {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(store, 'readonly');
const objectStore = transaction.objectStore(store);
const request = objectStore.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
const offlineDB = new OfflineDB();
最佳实践
- 渐进增强:核心功能在无Service Worker时也能工作
- 版本化缓存:使用版本号管理缓存,便于更新
- 优雅降级:提供离线页面和错误处理
- 合理缓存:不要缓存敏感数据或过大文件
- 通知权限:在合适时机请求,不要一打开就弹
- 测试离线:使用Chrome DevTools模拟离线场景
- 性能监控:追踪Service Worker安装和缓存命中率
总结
PWA让Web应用获得了前所未有的能力——离线访问、桌面安装、推送通知,这些曾经是原生应用的专利。通过Service Worker和Web App Manifest,我们可以打造出体验接近原生的Web应用。
但PWA不是万能的。在某些场景下,原生应用仍然有优势(如深度系统集成、后台任务)。选择技术方案时,需要根据项目需求和目标用户做出权衡。希望这篇指南能帮助你理解PWA的核心概念,在实际项目中做出明智的选择。