PWA技术深度解析:构建现代化Web应用的核心技术栈
目录
- PWA技术概述
- PWA核心特性
- [Service Worker详解](#Service Worker详解 "#service-worker%E8%AF%A6%E8%A7%A3")
- [Web App Manifest配置](#Web App Manifest配置 "#web-app-manifest%E9%85%8D%E7%BD%AE")
- 缓存策略实现
- 离线功能开发
- 推送通知机制
- 性能优化实践
- 最佳实践总结
PWA技术概述
Progressive Web App(渐进式Web应用)是一种结合了Web和原生应用最佳特性的技术方案。PWA通过现代Web API和渐进式增强策略,为用户提供类似原生应用的体验。
PWA的核心价值
PWA技术解决了传统Web应用的几个关键痛点:
- 性能问题:通过Service Worker实现智能缓存
- 离线体验:支持离线访问和数据同步
- 用户参与度:支持推送通知和桌面安装
- 跨平台兼容:一套代码多端运行
技术架构优势
javascript
// PWA核心技术栈示例
const PWACore = {
serviceWorker: 'sw.js',
manifest: 'manifest.json',
cacheStrategy: 'Cache First',
offlineSupport: true,
pushNotifications: true,
installable: true
};
// 检测PWA支持
function checkPWASupport() {
const features = {
serviceWorker: 'serviceWorker' in navigator,
manifest: 'manifest' in document.createElement('link'),
notification: 'Notification' in window,
pushManager: 'PushManager' in window
};
console.log('PWA Features Support:', features);
return features;
}
缓存策略实现
PWA的缓存策略是性能优化的核心,不同的资源类型需要采用不同的缓存策略以达到最佳的用户体验。
常见缓存策略
graph TD
A[请求发起] --> B{缓存策略类型}
B -->|Cache First| C[检查缓存]
C -->|命中| D[返回缓存]
C -->|未命中| E[网络请求]
E --> F[缓存响应]
F --> G[返回响应]
B -->|Network First| H[网络请求]
H -->|成功| I[缓存响应]
I --> J[返回响应]
H -->|失败| K[检查缓存]
K -->|有缓存| L[返回缓存]
K -->|无缓存| M[返回错误]
B -->|Stale While Revalidate| N[返回缓存]
N --> O[后台更新]
O --> P[更新缓存]
缓存策略实现
javascript
// 缓存策略管理器
class CacheStrategies {
constructor() {
this.strategies = {
cacheFirst: this.cacheFirst.bind(this),
networkFirst: this.networkFirst.bind(this),
staleWhileRevalidate: this.staleWhileRevalidate.bind(this),
cacheOnly: this.cacheOnly.bind(this),
networkOnly: this.networkOnly.bind(this)
};
}
// 缓存优先策略 - 适用于不经常变化的资源
async cacheFirst(request, cacheName = 'cache-first') {
try {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('Cache First策略失败:', error);
throw error;
}
}
// 网络优先策略 - 适用于需要最新数据的API
async networkFirst(request, cacheName = 'network-first', timeout = 3000) {
try {
const cache = await caches.open(cacheName);
// 设置网络请求超时
const networkPromise = this.fetchWithTimeout(request, timeout);
try {
const networkResponse = await networkPromise;
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (networkError) {
console.warn('网络请求失败,尝试缓存:', networkError);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw networkError;
}
} catch (error) {
console.error('Network First策略失败:', error);
throw error;
}
}
// 先返回缓存,同时更新策略 - 适用于频繁更新但可容忍旧数据的资源
async staleWhileRevalidate(request, cacheName = 'stale-while-revalidate') {
try {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
// 后台更新缓存
const updateCache = async () => {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
await cache.put(request, networkResponse.clone());
}
} catch (error) {
console.warn('后台更新缓存失败:', error);
}
};
updateCache(); // 不等待
if (cachedResponse) {
return cachedResponse;
}
// 如果没有缓存,等待网络响应
return await fetch(request);
} catch (error) {
console.error('Stale While Revalidate策略失败:', error);
throw error;
}
}
// 仅缓存策略 - 适用于完全离线的资源
async cacheOnly(request, cacheName = 'cache-only') {
try {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw new Error('缓存中未找到资源');
} catch (error) {
console.error('Cache Only策略失败:', error);
throw error;
}
}
// 仅网络策略 - 适用于敏感数据或分析
async networkOnly(request) {
try {
return await fetch(request);
} catch (error) {
console.error('Network Only策略失败:', error);
throw error;
}
}
// 带超时的fetch
fetchWithTimeout(request, timeout) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`请求超时: ${timeout}ms`));
}, timeout);
fetch(request)
.then(response => {
clearTimeout(timeoutId);
resolve(response);
})
.catch(error => {
clearTimeout(timeoutId);
reject(error);
});
});
}
// 根据请求类型选择策略
getStrategy(request) {
const url = new URL(request.url);
const pathname = url.pathname;
const fileExtension = pathname.split('.').pop().toLowerCase();
// 静态资源使用缓存优先
if (['css', 'js', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'woff', 'woff2'].includes(fileExtension)) {
return 'cacheFirst';
}
// API请求使用网络优先
if (pathname.startsWith('/api/') || pathname.startsWith('/graphql')) {
return 'networkFirst';
}
// HTML文档使用先返回缓存再更新
if (request.destination === 'document') {
return 'staleWhileRevalidate';
}
// 默认策略
return 'networkFirst';
}
}
// Service Worker中的缓存策略应用
class ServiceWorkerCache {
constructor() {
this.cacheStrategies = new CacheStrategies();
this.setupEventListeners();
}
setupEventListeners() {
self.addEventListener('fetch', (event) => {
event.respondWith(this.handleFetch(event.request));
});
}
async handleFetch(request) {
// 跳过非GET请求
if (request.method !== 'GET') {
return fetch(request);
}
try {
const strategy = this.cacheStrategies.getStrategy(request);
return await this.cacheStrategies.strategies[strategy](request);
} catch (error) {
return this.handleFetchError(request, error);
}
}
async handleFetchError(request, error) {
console.error('请求处理失败:', error);
// 如果是文档请求,返回离线页面
if (request.destination === 'document') {
try {
const cache = await caches.open('offline-pages');
const offlinePage = await cache.match('/offline.html');
if (offlinePage) {
return offlinePage;
}
} catch (cacheError) {
console.error('获取离线页面失败:', cacheError);
}
}
// 返回基本的错误响应
return new Response('网络错误', {
status: 408,
statusText: 'Network Timeout'
});
}
}
缓存管理工具
javascript
// 缓存管理器
class CacheManager {
constructor() {
this.maxCacheSize = 50 * 1024 * 1024; // 50MB
this.maxCacheAge = 7 * 24 * 60 * 60 * 1000; // 7天
}
// 清理过期缓存
async cleanExpiredCache() {
try {
const cacheNames = await caches.keys();
for (const cacheName of cacheNames) {
await this.cleanCacheByAge(cacheName);
}
console.log('过期缓存清理完成');
} catch (error) {
console.error('清理过期缓存失败:', error);
}
}
async cleanCacheByAge(cacheName) {
try {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
for (const request of requests) {
const response = await cache.match(request);
if (response) {
const cachedDate = response.headers.get('date');
if (cachedDate) {
const ageInMs = Date.now() - new Date(cachedDate).getTime();
if (ageInMs > this.maxCacheAge) {
await cache.delete(request);
console.log(`删除过期缓存: ${request.url}`);
}
}
}
}
} catch (error) {
console.error(`清理缓存 ${cacheName} 失败:`, error);
}
}
// 控制缓存大小
async limitCacheSize(cacheName, maxItems = 50) {
try {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
if (requests.length > maxItems) {
// 删除最老的缓存项
const itemsToDelete = requests.slice(0, requests.length - maxItems);
for (const request of itemsToDelete) {
await cache.delete(request);
}
console.log(`缓存 ${cacheName} 大小已限制为 ${maxItems} 项`);
}
} catch (error) {
console.error(`限制缓存大小失败:`, error);
}
}
// 获取缓存统计信息
async getCacheStats() {
try {
const cacheNames = await caches.keys();
const stats = {};
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
let totalSize = 0;
for (const request of requests) {
const response = await cache.match(request);
if (response && response.headers.get('content-length')) {
totalSize += parseInt(response.headers.get('content-length'));
}
}
stats[cacheName] = {
itemCount: requests.length,
size: totalSize,
sizeFormatted: this.formatBytes(totalSize)
};
}
return stats;
} catch (error) {
console.error('获取缓存统计失败:', error);
return {};
}
}
formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 预缓存资源
async precacheResources(resources) {
try {
const cache = await caches.open('precache-v1');
for (const resource of resources) {
try {
const response = await fetch(resource);
if (response.ok) {
await cache.put(resource, response);
console.log(`预缓存成功: ${resource}`);
}
} catch (error) {
console.warn(`预缓存失败: ${resource}`, error);
}
}
} catch (error) {
console.error('预缓存失败:', error);
}
}
}
PWA核心特性
1. 渐进式增强
PWA采用渐进式增强策略,确保在不同环境下都能正常工作:
javascript
// 特性检测和渐进式加载
class PWAEnhancement {
constructor() {
this.features = this.detectFeatures();
this.init();
}
detectFeatures() {
return {
serviceWorker: 'serviceWorker' in navigator,
pushManager: 'PushManager' in window,
notification: 'Notification' in window,
backgroundSync: 'serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype
};
}
async init() {
// 基础功能始终可用
this.setupBasicFeatures();
// 高级功能按需启用
if (this.features.serviceWorker) {
await this.setupServiceWorker();
}
if (this.features.notification) {
this.setupNotifications();
}
}
setupBasicFeatures() {
// 基础Web功能实现
console.log('设置基础功能');
}
async setupServiceWorker() {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service Worker注册成功:', registration);
} catch (error) {
console.error('Service Worker注册失败:', error);
}
}
setupNotifications() {
// 通知功能设置
console.log('设置通知功能');
}
}
2. 响应式设计
PWA必须在所有设备上提供一致的用户体验:
javascript
// 响应式断点管理
class ResponsiveManager {
constructor() {
this.breakpoints = {
mobile: '(max-width: 768px)',
tablet: '(min-width: 769px) and (max-width: 1024px)',
desktop: '(min-width: 1025px)'
};
this.setupMediaQueries();
}
setupMediaQueries() {
Object.entries(this.breakpoints).forEach(([device, query]) => {
const mediaQuery = window.matchMedia(query);
mediaQuery.addListener(this.handleDeviceChange.bind(this, device));
if (mediaQuery.matches) {
this.handleDeviceChange(device, mediaQuery);
}
});
}
handleDeviceChange(device, mediaQuery) {
if (mediaQuery.matches) {
document.body.setAttribute('data-device', device);
this.optimizeForDevice(device);
}
}
optimizeForDevice(device) {
// 根据设备类型优化资源加载
const optimizations = {
mobile: () => this.loadMobileAssets(),
tablet: () => this.loadTabletAssets(),
desktop: () => this.loadDesktopAssets()
};
optimizations[device]?.();
}
}
Service Worker详解
Service Worker是PWA的核心技术,它是一个运行在浏览器后台的脚本,独立于网页主线程,为PWA提供了强大的后台处理能力。
Service Worker生命周期
Service Worker的生命周期包含多个关键阶段:
graph TD
A[注册Service Worker] --> B[下载sw.js文件]
B --> C[安装阶段 install]
C --> D{安装成功?}
D -->|成功| E[等待激活]
D -->|失败| F[安装失败]
E --> G[激活阶段 activate]
G --> H[控制页面]
H --> I[监听事件]
I --> J[fetch/message/sync]
J --> K[更新检测]
K --> A
Service Worker注册和管理
javascript
// Service Worker管理器
class ServiceWorkerManager {
constructor() {
this.registration = null;
this.isUpdateAvailable = false;
}
async register() {
if (!('serviceWorker' in navigator)) {
console.warn('Service Worker不被支持');
return false;
}
try {
this.registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service Worker注册成功:', this.registration);
// 监听更新
this.registration.addEventListener('updatefound', () => {
this.handleUpdate();
});
// 检查现有的Service Worker
if (this.registration.active) {
console.log('Service Worker已激活');
}
return true;
} catch (error) {
console.error('Service Worker注册失败:', error);
return false;
}
}
handleUpdate() {
const newWorker = this.registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
this.isUpdateAvailable = true;
this.showUpdateNotification();
}
});
}
showUpdateNotification() {
// 显示更新通知给用户
const updateBanner = document.createElement('div');
updateBanner.className = 'update-banner';
updateBanner.innerHTML = `
<div class="update-content">
<span>新版本可用</span>
<button onclick="this.parentNode.parentNode.remove(); window.location.reload();">
更新
</button>
</div>
`;
document.body.appendChild(updateBanner);
}
async skipWaiting() {
if (this.registration && this.registration.waiting) {
this.registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}
}
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/main.css',
'/js/app.js',
'/images/logo.png',
'/manifest.json'
];
// 安装事件
self.addEventListener('install', (event) => {
console.log('Service Worker: 安装中...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log('Service Worker: 缓存静态资源');
return cache.addAll(STATIC_ASSETS);
})
.then(() => {
console.log('Service Worker: 安装完成');
return self.skipWaiting(); // 强制激活新的Service Worker
})
);
});
// 激活事件
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('Service Worker: 删除旧缓存', cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => {
console.log('Service Worker: 激活完成');
return self.clients.claim(); // 立即控制所有页面
})
);
});
// 网络请求拦截
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
event.respondWith(
caches.match(event.request)
.then((cachedResponse) => {
if (cachedResponse) {
// 缓存命中,返回缓存
return cachedResponse;
}
// 缓存未命中,发起网络请求
return fetch(event.request)
.then((response) => {
// 检查响应是否有效
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// 克隆响应(响应只能使用一次)
const responseToCache = response.clone();
// 动态缓存策略
if (shouldCache(event.request)) {
caches.open(DYNAMIC_CACHE)
.then((cache) => {
cache.put(event.request, responseToCache);
});
}
return response;
});
})
.catch(() => {
// 网络失败,返回离线页面
if (event.request.destination === 'document') {
return caches.match('/offline.html');
}
})
);
});
// 判断是否应该缓存请求
function shouldCache(request) {
const url = new URL(request.url);
// 缓存同源资源
if (url.origin === location.origin) {
return true;
}
// 缓存CDN资源
const cdnHosts = [
'cdn.jsdelivr.net',
'unpkg.com',
'fonts.googleapis.com'
];
return cdnHosts.includes(url.hostname);
}
// 监听来自主线程的消息
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
Web App Manifest配置
Web App Manifest是一个JSON文件,它提供了PWA应用的元数据信息,使应用能够被安装到设备主屏幕上,并提供类似原生应用的启动体验。
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",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "新建文档",
"short_name": "新建",
"description": "快速创建新文档",
"url": "/new",
"icons": [
{
"src": "/icons/new-document.png",
"sizes": "96x96"
}
]
}
],
"categories": ["productivity", "utilities"],
"screenshots": [
{
"src": "/screenshots/desktop-1.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "桌面端主界面"
},
{
"src": "/screenshots/mobile-1.png",
"sizes": "360x640",
"type": "image/png",
"form_factor": "narrow",
"label": "移动端主界面"
}
]
}
Manifest动态管理
javascript
// Manifest管理器
class ManifestManager {
constructor() {
this.manifestElement = null;
this.init();
}
init() {
this.createManifestLink();
this.generateManifest();
}
createManifestLink() {
this.manifestElement = document.createElement('link');
this.manifestElement.rel = 'manifest';
this.manifestElement.href = '/manifest.json';
document.head.appendChild(this.manifestElement);
}
generateManifest() {
const manifest = {
name: this.getAppName(),
short_name: this.getShortName(),
description: this.getDescription(),
start_url: this.getStartUrl(),
scope: this.getScope(),
display: this.getDisplayMode(),
orientation: this.getOrientation(),
theme_color: this.getThemeColor(),
background_color: this.getBackgroundColor(),
icons: this.generateIcons(),
shortcuts: this.generateShortcuts()
};
// 动态创建manifest
this.saveManifest(manifest);
}
getAppName() {
return document.title || 'PWA应用';
}
getShortName() {
const metaAppName = document.querySelector('meta[name="application-name"]');
return metaAppName ? metaAppName.content : 'PWA';
}
getDescription() {
const metaDescription = document.querySelector('meta[name="description"]');
return metaDescription ? metaDescription.content : '渐进式Web应用';
}
getStartUrl() {
return window.location.pathname || '/';
}
getScope() {
return '/';
}
getDisplayMode() {
// 根据屏幕尺寸自适应
const isMobile = window.innerWidth <= 768;
return isMobile ? 'standalone' : 'minimal-ui';
}
getOrientation() {
const isLandscape = window.innerWidth > window.innerHeight;
return isLandscape ? 'landscape' : 'portrait';
}
getThemeColor() {
const metaTheme = document.querySelector('meta[name="theme-color"]');
return metaTheme ? metaTheme.content : '#2196F3';
}
getBackgroundColor() {
const computedStyle = getComputedStyle(document.body);
return computedStyle.backgroundColor || '#ffffff';
}
generateIcons() {
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
return sizes.map(size => ({
src: `/icons/icon-${size}x${size}.png`,
sizes: `${size}x${size}`,
type: 'image/png',
purpose: size >= 512 ? 'any maskable' : 'any'
}));
}
generateShortcuts() {
// 基于路由生成快捷方式
const routes = [
{ name: '首页', url: '/', icon: 'home' },
{ name: '设置', url: '/settings', icon: 'settings' },
{ name: '关于', url: '/about', icon: 'info' }
];
return routes.map(route => ({
name: route.name,
short_name: route.name,
description: `跳转到${route.name}`,
url: route.url,
icons: [
{
src: `/icons/${route.icon}.png`,
sizes: '96x96'
}
]
}));
}
async saveManifest(manifest) {
try {
// 如果支持,可以动态更新manifest
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'UPDATE_MANIFEST',
manifest: manifest
});
}
// 或者通过Blob URL临时创建
const blob = new Blob([JSON.stringify(manifest, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
this.manifestElement.href = url;
} catch (error) {
console.error('保存manifest失败:', error);
}
}
// 检测安装状态
checkInstallation() {
return new Promise((resolve) => {
if (window.matchMedia('(display-mode: standalone)').matches) {
resolve({ installed: true, source: 'standalone' });
} else if (window.navigator.standalone === true) {
resolve({ installed: true, source: 'ios' });
} else if (document.referrer.includes('android-app://')) {
resolve({ installed: true, source: 'android' });
} else {
resolve({ installed: false });
}
});
}
}
离线功能开发
离线功能是PWA的核心特性之一,让用户在网络不可用时仍能使用应用的基本功能。
网络状态检测
javascript
// 网络状态管理器
class NetworkManager {
constructor() {
this.isOnline = navigator.onLine;
this.connectionType = this.getConnectionType();
this.setupEventListeners();
this.notifyCallbacks = [];
}
setupEventListeners() {
window.addEventListener('online', () => {
this.isOnline = true;
this.notifyStatusChange('online');
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.notifyStatusChange('offline');
});
// 监听连接类型变化
if ('connection' in navigator) {
navigator.connection.addEventListener('change', () => {
this.connectionType = this.getConnectionType();
this.notifyStatusChange('connection-change');
});
}
}
getConnectionType() {
if ('connection' in navigator) {
return {
type: navigator.connection.effectiveType,
downlink: navigator.connection.downlink,
rtt: navigator.connection.rtt,
saveData: navigator.connection.saveData
};
}
return null;
}
onStatusChange(callback) {
this.notifyCallbacks.push(callback);
}
notifyStatusChange(type) {
this.notifyCallbacks.forEach(callback => {
callback({
type,
isOnline: this.isOnline,
connectionType: this.connectionType
});
});
}
// 智能网络检测
async checkConnectivity() {
if (!this.isOnline) {
return false;
}
try {
// 尝试请求一个小文件来确认网络连接
const response = await fetch('/ping.txt', {
method: 'HEAD',
cache: 'no-cache'
});
return response.ok;
} catch (error) {
return false;
}
}
}
离线数据同步
javascript
// 离线数据同步管理器
class OfflineSyncManager {
constructor() {
this.dbName = 'PWA_OfflineDB';
this.dbVersion = 1;
this.db = null;
this.syncQueue = [];
this.init();
}
async init() {
await this.openDatabase();
await this.processQueuedRequests();
}
openDatabase() {
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();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 创建离线数据存储
if (!db.objectStoreNames.contains('offlineData')) {
const store = db.createObjectStore('offlineData', { keyPath: 'id', autoIncrement: true });
store.createIndex('timestamp', 'timestamp');
store.createIndex('type', 'type');
}
// 创建同步队列存储
if (!db.objectStoreNames.contains('syncQueue')) {
const syncStore = db.createObjectStore('syncQueue', { keyPath: 'id', autoIncrement: true });
syncStore.createIndex('timestamp', 'timestamp');
}
};
});
}
// 存储离线数据
async storeOfflineData(type, data) {
try {
const transaction = this.db.transaction(['offlineData'], 'readwrite');
const store = transaction.objectStore('offlineData');
const record = {
type,
data,
timestamp: Date.now(),
synced: false
};
await new Promise((resolve, reject) => {
const request = store.add(record);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
console.log(`离线数据已存储: ${type}`);
} catch (error) {
console.error('存储离线数据失败:', error);
}
}
// 获取离线数据
async getOfflineData(type) {
try {
const transaction = this.db.transaction(['offlineData'], 'readonly');
const store = transaction.objectStore('offlineData');
const index = store.index('type');
return new Promise((resolve, reject) => {
const request = index.getAll(type);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
} catch (error) {
console.error('获取离线数据失败:', error);
return [];
}
}
// 添加到同步队列
async addToSyncQueue(action, data) {
try {
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
const queueItem = {
action,
data,
timestamp: Date.now(),
retryCount: 0
};
await new Promise((resolve, reject) => {
const request = store.add(queueItem);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
console.log(`已添加到同步队列: ${action}`);
} catch (error) {
console.error('添加到同步队列失败:', error);
}
}
// 处理同步队列
async processQueuedRequests() {
if (!navigator.onLine) return;
try {
const transaction = this.db.transaction(['syncQueue'], 'readwrite');
const store = transaction.objectStore('syncQueue');
const queuedItems = await new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
for (const item of queuedItems) {
try {
await this.syncItem(item);
// 同步成功,从队列中删除
await new Promise((resolve, reject) => {
const deleteRequest = store.delete(item.id);
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => reject(deleteRequest.error);
});
} catch (error) {
console.error(`同步失败: ${item.action}`, error);
// 增加重试次数
item.retryCount++;
if (item.retryCount < 3) {
store.put(item);
} else {
// 重试次数过多,删除
store.delete(item.id);
}
}
}
} catch (error) {
console.error('处理同步队列失败:', error);
}
}
async syncItem(item) {
const response = await fetch(`/api/${item.action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(item.data)
});
if (!response.ok) {
throw new Error(`同步失败: ${response.status}`);
}
return response.json();
}
}
离线页面实现
javascript
// 离线页面管理
class OfflinePageManager {
constructor() {
this.offlinePagePath = '/offline.html';
this.fallbackContent = this.createFallbackContent();
}
createFallbackContent() {
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线模式</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%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.offline-container {
background: white;
border-radius: 12px;
padding: 40px;
text-align: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
max-width: 400px;
}
.offline-icon {
font-size: 48px;
margin-bottom: 20px;
}
.offline-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.offline-message {
color: #666;
line-height: 1.5;
margin-bottom: 30px;
}
.retry-button {
background: #667eea;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.retry-button:hover {
background: #5a6fd8;
}
</style>
</head>
<body>
<div class="offline-container">
<div class="offline-icon">📡</div>
<h1 class="offline-title">您处于离线状态</h1>
<p class="offline-message">
网络连接似乎出现了问题。请检查您的网络连接,或稍后重试。
</p>
<button class="retry-button" onclick="window.location.reload()">
重新尝试
</button>
</div>
<script>
// 监听网络状态变化
window.addEventListener('online', () => {
window.location.reload();
});
</script>
</body>
</html>
`;
}
// 在Service Worker中使用
async handleOfflineRequest(request) {
try {
// 尝试从缓存获取离线页面
const cache = await caches.open('offline-pages');
const offlinePage = await cache.match(this.offlinePagePath);
if (offlinePage) {
return offlinePage;
}
// 如果没有缓存的离线页面,返回备用内容
return new Response(this.fallbackContent, {
headers: {
'Content-Type': 'text/html; charset=utf-8'
}
});
} catch (error) {
console.error('处理离线请求失败:', error);
return new Response('离线模式', {
status: 503,
statusText: 'Service Unavailable'
});
}
}
}
推送通知机制
推送通知让PWA能够在后台与用户保持连接,提升用户参与度。
推送通知权限管理
javascript
// 推送通知管理器
class PushNotificationManager {
constructor() {
this.registration = null;
this.subscription = null;
this.vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
}
async init() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('推送通知不被支持');
return false;
}
try {
this.registration = await navigator.serviceWorker.ready;
return true;
} catch (error) {
console.error('推送通知初始化失败:', error);
return false;
}
}
// 请求通知权限
async requestPermission() {
if (!('Notification' in window)) {
console.warn('通知不被支持');
return false;
}
let permission = Notification.permission;
if (permission === 'default') {
permission = await Notification.requestPermission();
}
if (permission === 'granted') {
console.log('通知权限已获得');
return true;
} else {
console.log('通知权限被拒绝');
return false;
}
}
// 订阅推送服务
async subscribeToPush() {
try {
const subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey)
});
this.subscription = subscription;
// 将订阅信息发送到服务器
await this.sendSubscriptionToServer(subscription);
console.log('推送订阅成功');
return subscription;
} catch (error) {
console.error('推送订阅失败:', error);
return null;
}
}
// 取消推送订阅
async unsubscribeFromPush() {
try {
if (this.subscription) {
await this.subscription.unsubscribe();
this.subscription = null;
console.log('推送订阅已取消');
}
} catch (error) {
console.error('取消推送订阅失败:', error);
}
}
// 发送订阅信息到服务器
async sendSubscriptionToServer(subscription) {
try {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription: subscription.toJSON(),
timestamp: Date.now()
})
});
if (!response.ok) {
throw new Error(`订阅失败: ${response.status}`);
}
} catch (error) {
console.error('发送订阅信息失败:', error);
}
}
// 显示本地通知
async showLocalNotification(title, options = {}) {
if (!this.registration) {
console.error('Service Worker未注册');
return;
}
const defaultOptions = {
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
tag: 'default',
requireInteraction: false,
data: {
timestamp: Date.now()
}
};
const notificationOptions = { ...defaultOptions, ...options };
try {
await this.registration.showNotification(title, notificationOptions);
} catch (error) {
console.error('显示通知失败:', error);
}
}
// 工具函数:Base64转Uint8Array
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;
}
}
性能优化实践
PWA性能优化涉及多个层面,从资源加载到运行时优化。
资源预加载策略
javascript
// 资源预加载管理器
class ResourcePreloader {
constructor() {
this.criticalResources = [];
this.prefetchResources = [];
this.observedElements = new Set();
}
// 预加载关键资源
preloadCriticalResources(resources) {
resources.forEach(resource => {
const link = document.createElement('link');
link.rel = 'preload';
link.href = resource.url;
link.as = resource.type;
if (resource.type === 'font') {
link.crossOrigin = 'anonymous';
}
document.head.appendChild(link);
});
}
// 智能预获取
intelligentPrefetch() {
// 基于用户行为预测下一步需要的资源
this.observeUserInteractions();
this.prefetchOnHover();
this.prefetchOnViewport();
}
observeUserInteractions() {
document.addEventListener('mouseover', (event) => {
const link = event.target.closest('a[href]');
if (link && !this.observedElements.has(link.href)) {
this.observedElements.add(link.href);
this.prefetchResource(link.href);
}
});
}
prefetchOnHover() {
document.addEventListener('mouseenter', (event) => {
const target = event.target;
if (target.dataset.prefetch && !this.observedElements.has(target.dataset.prefetch)) {
this.observedElements.add(target.dataset.prefetch);
this.prefetchResource(target.dataset.prefetch);
}
}, true);
}
prefetchOnViewport() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.target;
const prefetchUrl = element.dataset.prefetch;
if (prefetchUrl && !this.observedElements.has(prefetchUrl)) {
this.observedElements.add(prefetchUrl);
this.prefetchResource(prefetchUrl);
}
}
});
}, { threshold: 0.1 });
document.querySelectorAll('[data-prefetch]').forEach(element => {
observer.observe(element);
});
}
prefetchResource(url) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
document.head.appendChild(link);
}
}
最佳实践总结
javascript
// PWA最佳实践检查器
class PWABestPracticesChecker {
constructor() {
this.checks = [
{ name: 'HTTPS', check: this.checkHTTPS },
{ name: 'Service Worker', check: this.checkServiceWorker },
{ name: 'Web App Manifest', check: this.checkManifest },
{ name: 'Responsive Design', check: this.checkResponsive },
{ name: 'Performance', check: this.checkPerformance }
];
}
async runAllChecks() {
const results = {};
for (const check of this.checks) {
try {
results[check.name] = await check.check();
} catch (error) {
results[check.name] = { passed: false, error: error.message };
}
}
return results;
}
checkHTTPS() {
return {
passed: location.protocol === 'https:' || location.hostname === 'localhost',
message: location.protocol === 'https:' ? 'HTTPS已启用' : '建议使用HTTPS'
};
}
async checkServiceWorker() {
const hasServiceWorker = 'serviceWorker' in navigator;
const isRegistered = hasServiceWorker && (await navigator.serviceWorker.getRegistration()) !== undefined;
return {
passed: isRegistered,
message: isRegistered ? 'Service Worker已注册' : '未检测到Service Worker'
};
}
checkManifest() {
const manifestLink = document.querySelector('link[rel="manifest"]');
return {
passed: !!manifestLink,
message: manifestLink ? 'Web App Manifest已配置' : '缺少Web App Manifest'
};
}
checkResponsive() {
const viewportMeta = document.querySelector('meta[name="viewport"]');
const hasViewport = !!viewportMeta;
const hasResponsiveContent = viewportMeta && viewportMeta.content.includes('width=device-width');
return {
passed: hasViewport && hasResponsiveContent,
message: hasViewport && hasResponsiveContent ? '响应式设计已配置' : '建议优化响应式设计'
};
}
async checkPerformance() {
if ('performance' in window && 'navigation' in performance) {
const navTiming = performance.getEntriesByType('navigation')[0];
const loadTime = navTiming.loadEventEnd - navTiming.fetchStart;
return {
passed: loadTime < 3000,
message: `页面加载时间: ${loadTime}ms`,
value: loadTime
};
}
return {
passed: false,
message: '无法检测性能指标'
};
}
}
// 使用示例
const checker = new PWABestPracticesChecker();
checker.runAllChecks().then(results => {
console.log('PWA最佳实践检查结果:', results);
});
总结
PWA技术通过Service Worker、Web App Manifest、推送通知等核心技术,为Web应用带来了接近原生应用的用户体验。合理的缓存策略、完善的离线功能、以及性能优化措施,共同构建了现代化的Web应用解决方案。
通过本文的详细介绍和代码示例,您可以:
- 掌握PWA核心概念:理解渐进式增强的设计理念
- 实现Service Worker:管理缓存、处理网络请求、支持离线功能
- 配置Web App Manifest:让应用可安装,提供原生体验
- 优化缓存策略:根据资源类型选择合适的缓存方案
- 开发离线功能:确保应用在网络不稳定时正常工作
- 集成推送通知:提升用户参与度和留存率
- 性能优化实践:通过预加载、智能缓存等技术提升性能
PWA代表了Web应用的未来发展方向,是构建高质量、高性能Web应用的重要技术栈。
缓存策略实现
PWA的缓存策略是性能优化的核心,不同的资源类型需要采用不同的缓存策略以达到最佳的用户体验。
常见缓存策略
graph TD
A[请求发起] --> B{缓存策略类型}
B -->|Cache First| C[检查缓存]
C -->|命中| D[返回缓存]
C -->|未命中| E[网络请求]
E --> F[缓存响应]
F --> G[返回响应]
B -->|Network First| H[网络请求]
H -->|成功| I[缓存响应]
I --> J[返回响应]
H -->|失败| K[检查缓存]
K -->|有缓存| L[返回缓存]
K -->|无缓存| M[返回错误]
B -->|Stale While Revalidate| N[返回缓存]
N --> O[后台更新]
O --> P[更新缓存]
缓存策略实现
javascript
// 缓存策略管理器
class CacheStrategies {
constructor() {
this.strategies = {
cacheFirst: this.cacheFirst.bind(this),
networkFirst: this.networkFirst.bind(this),
staleWhileRevalidate: this.staleWhileRevalidate.bind(this),
cacheOnly: this.cacheOnly.bind(this),
networkOnly: this.networkOnly.bind(this)
};
}
// 缓存优先策略 - 适用于不经常变化的资源
async cacheFirst(request, cacheName = 'cache-first') {
try {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('Cache First策略失败:', error);
throw error;
}
}
// 网络优先策略 - 适用于需要最新数据的API
async networkFirst(request, cacheName = 'network-first', timeout = 3000) {
try {
const cache = await caches.open(cacheName);
// 设置网络请求超时
const networkPromise = this.fetchWithTimeout(request, timeout);
try {
const networkResponse = await networkPromise;
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (networkError) {
console.warn('网络请求失败,尝试缓存:', networkError);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw networkError;
}
} catch (error) {
console.error('Network First策略失败:', error);
throw error;
}
}
// 先返回缓存,同时更新策略 - 适用于频繁更新但可容忍旧数据的资源
async staleWhileRevalidate(request, cacheName = 'stale-while-revalidate') {
try {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
// 后台更新缓存
const updateCache = async () => {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
await cache.put(request, networkResponse.clone());
}
} catch (error) {
console.warn('后台更新缓存失败:', error);
}
};
updateCache(); // 不等待
if (cachedResponse) {
return cachedResponse;
}
// 如果没有缓存,等待网络响应
return await fetch(request);
} catch (error) {
console.error('Stale While Revalidate策略失败:', error);
throw error;
}
}
// 带超时的fetch
fetchWithTimeout(request, timeout) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`请求超时: ${timeout}ms`));
}, timeout);
fetch(request)
.then(response => {
clearTimeout(timeoutId);
resolve(response);
})
.catch(error => {
clearTimeout(timeoutId);
reject(error);
});
});
}
// 根据请求类型选择策略
getStrategy(request) {
const url = new URL(request.url);
const pathname = url.pathname;
const fileExtension = pathname.split('.').pop().toLowerCase();
// 静态资源使用缓存优先
if (['css', 'js', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'woff', 'woff2'].includes(fileExtension)) {
return 'cacheFirst';
}
// API请求使用网络优先
if (pathname.startsWith('/api/') || pathname.startsWith('/graphql')) {
return 'networkFirst';
}
// HTML文档使用先返回缓存再更新
if (request.destination === 'document') {
return 'staleWhileRevalidate';
}
// 默认策略
return 'networkFirst';
}
}