- 发布于
Progressive Web Apps开发实战:Service Worker、离线缓存与推送通知
- 作者

- 姓名
- 全能波
- GitHub
- @weicracker
Progressive Web Apps开发实战:Service Worker、离线缓存与推送通知
Progressive Web Apps (PWA) 结合了Web和原生应用的优势,提供类似原生应用的用户体验。本文将分享PWA开发的核心技术和实战经验。
Service Worker基础
Service Worker生命周期
// sw.js - Service Worker主文件
const CACHE_NAME = 'pwa-cache-v1.0.0';
const STATIC_CACHE = 'static-cache-v1.0.0';
const DYNAMIC_CACHE = 'dynamic-cache-v1.0.0';
// 静态资源列表
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/css/styles.css',
'/js/app.js',
'/js/sw-register.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');
// 强制激活新的Service Worker
return self.skipWaiting();
})
.catch((error) => {
console.error('Service Worker: Failed to cache static assets', 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 &&
cacheName !== CACHE_NAME) {
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;
}
event.respondWith(handleRequest(request));
});
// 请求处理策略
async function handleRequest(request) {
const url = new URL(request.url);
// 静态资源:缓存优先策略
if (isStaticAsset(url.pathname)) {
return cacheFirst(request);
}
// API请求:网络优先策略
if (url.pathname.startsWith('/api/')) {
return networkFirst(request);
}
// HTML页面:网络优先,离线回退
if (request.headers.get('accept')?.includes('text/html')) {
return networkFirstWithOfflineFallback(request);
}
// 其他资源:缓存优先
return cacheFirst(request);
}
// 判断是否为静态资源
function isStaticAsset(pathname) {
const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2'];
return staticExtensions.some(ext => pathname.endsWith(ext));
}
// 缓存优先策略
async function cacheFirst(request) {
try {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
if (cached) {
console.log('Service Worker: Serving from cache', request.url);
return cached;
}
console.log('Service Worker: Fetching from network', request.url);
const response = await fetch(request);
if (response.ok) {
const responseClone = response.clone();
cache.put(request, responseClone);
}
return response;
} catch (error) {
console.error('Service Worker: Cache first failed', error);
return new Response('Network error', { status: 408 });
}
}
// 网络优先策略
async function networkFirst(request) {
try {
console.log('Service Worker: Network first for', request.url);
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(DYNAMIC_CACHE);
const responseClone = response.clone();
cache.put(request, responseClone);
}
return response;
} catch (error) {
console.log('Service Worker: Network failed, trying cache', request.url);
const cache = await caches.open(DYNAMIC_CACHE);
const cached = await cache.match(request);
if (cached) {
return cached;
}
return new Response(JSON.stringify({ error: 'Network unavailable' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
// 网络优先,离线页面回退
async function networkFirstWithOfflineFallback(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(DYNAMIC_CACHE);
const responseClone = response.clone();
cache.put(request, responseClone);
}
return response;
} catch (error) {
console.log('Service Worker: Network failed, trying cache', request.url);
const cache = await caches.open(DYNAMIC_CACHE);
const cached = await cache.match(request);
if (cached) {
return cached;
}
// 返回离线页面
const offlineCache = await caches.open(STATIC_CACHE);
const offlinePage = await offlineCache.match('/offline.html');
return offlinePage || new Response('Offline', { status: 503 });
}
}
// 后台同步
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 offlineData = await getOfflineData();
for (const data of offlineData) {
try {
await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
// 同步成功,删除离线数据
await removeOfflineData(data.id);
} catch (error) {
console.error('Background sync failed for item', data.id, error);
}
}
} catch (error) {
console.error('Background sync failed', error);
}
}
// 推送通知
self.addEventListener('push', (event) => {
console.log('Service Worker: Push received');
const options = {
body: 'Default notification body',
icon: '/images/icon-192.png',
badge: '/images/badge.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
},
actions: [
{
action: 'explore',
title: 'Explore',
icon: '/images/checkmark.png'
},
{
action: 'close',
title: 'Close',
icon: '/images/xmark.png'
}
]
};
if (event.data) {
const payload = event.data.json();
options.title = payload.title || 'PWA Notification';
options.body = payload.body || options.body;
options.icon = payload.icon || options.icon;
options.data = { ...options.data, ...payload.data };
}
event.waitUntil(
self.registration.showNotification('PWA App', options)
);
});
// 通知点击处理
self.addEventListener('notificationclick', (event) => {
console.log('Service Worker: Notification clicked', event);
event.notification.close();
if (event.action === 'explore') {
event.waitUntil(
clients.openWindow('/explore')
);
} else if (event.action === 'close') {
// 通知已关闭,无需额外操作
} else {
// 默认操作:打开应用
event.waitUntil(
clients.matchAll({ type: 'window' })
.then((clientList) => {
for (const client of clientList) {
if (client.url === '/' && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
}
});
// 消息通信
self.addEventListener('message', (event) => {
console.log('Service Worker: Message received', event.data);
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'GET_VERSION') {
event.ports[0].postMessage({ version: CACHE_NAME });
}
});
// 离线数据管理
async function getOfflineData() {
// 从IndexedDB获取离线数据
return new Promise((resolve, reject) => {
const request = indexedDB.open('PWAOfflineDB', 1);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(['offlineData'], 'readonly');
const store = transaction.objectStore('offlineData');
const getAllRequest = store.getAll();
getAllRequest.onsuccess = () => {
resolve(getAllRequest.result);
};
getAllRequest.onerror = () => {
reject(getAllRequest.error);
};
};
request.onerror = () => {
reject(request.error);
};
});
}
async function removeOfflineData(id) {
return new Promise((resolve, reject) => {
const request = indexedDB.open('PWAOfflineDB', 1);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(['offlineData'], 'readwrite');
const store = transaction.objectStore('offlineData');
const deleteRequest = store.delete(id);
deleteRequest.onsuccess = () => {
resolve();
};
deleteRequest.onerror = () => {
reject(deleteRequest.error);
};
};
request.onerror = () => {
reject(request.error);
};
});
}
Service Worker注册与管理
// sw-register.js - Service Worker注册管理
class ServiceWorkerManager {
constructor() {
this.registration = null;
this.isOnline = navigator.onLine;
this.updateAvailable = false;
this.init();
}
async init() {
if ('serviceWorker' in navigator) {
try {
await this.registerServiceWorker();
this.setupEventListeners();
this.checkForUpdates();
} catch (error) {
console.error('Service Worker registration failed:', error);
}
} else {
console.warn('Service Worker not supported');
}
}
async registerServiceWorker() {
try {
this.registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service Worker registered:', this.registration);
// 监听Service Worker状态变化
this.registration.addEventListener('updatefound', () => {
console.log('Service Worker update found');
this.handleUpdateFound();
});
// 检查是否有等待中的Service Worker
if (this.registration.waiting) {
this.showUpdateAvailable();
}
return this.registration;
} catch (error) {
console.error('Service Worker registration failed:', error);
throw error;
}
}
setupEventListeners() {
// 监听网络状态变化
window.addEventListener('online', () => {
this.isOnline = true;
this.handleOnlineStatusChange(true);
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.handleOnlineStatusChange(false);
});
// 监听Service Worker消息
navigator.serviceWorker.addEventListener('message', (event) => {
this.handleServiceWorkerMessage(event);
});
// 监听Service Worker控制器变化
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('Service Worker controller changed');
window.location.reload();
});
}
handleUpdateFound() {
const newWorker = this.registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// 有新版本可用
this.updateAvailable = true;
this.showUpdateAvailable();
} else {
// 首次安装
console.log('Service Worker installed for the first time');
this.showInstallSuccess();
}
}
});
}
showUpdateAvailable() {
// 显示更新提示
const updateBanner = this.createUpdateBanner();
document.body.appendChild(updateBanner);
// 触发自定义事件
window.dispatchEvent(new CustomEvent('sw-update-available', {
detail: { registration: this.registration }
}));
}
showInstallSuccess() {
// 显示安装成功提示
this.showNotification('App installed successfully! You can now use it offline.');
// 触发自定义事件
window.dispatchEvent(new CustomEvent('sw-installed'));
}
createUpdateBanner() {
const banner = document.createElement('div');
banner.className = 'update-banner';
banner.innerHTML = `
<div class="update-content">
<span>A new version is available!</span>
<button id="updateBtn" class="update-btn">Update</button>
<button id="dismissBtn" class="dismiss-btn">×</button>
</div>
`;
// 样式
banner.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
background: #007bff;
color: white;
padding: 12px;
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
`;
// 事件监听
banner.querySelector('#updateBtn').addEventListener('click', () => {
this.applyUpdate();
banner.remove();
});
banner.querySelector('#dismissBtn').addEventListener('click', () => {
banner.remove();
});
return banner;
}
async applyUpdate() {
if (this.registration && this.registration.waiting) {
// 发送消息给Service Worker,让其跳过等待
this.registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}
handleOnlineStatusChange(isOnline) {
console.log('Network status changed:', isOnline ? 'online' : 'offline');
// 显示网络状态
this.showNetworkStatus(isOnline);
// 触发自定义事件
window.dispatchEvent(new CustomEvent('network-status-change', {
detail: { isOnline }
}));
if (isOnline) {
// 重新上线时,尝试后台同步
this.triggerBackgroundSync();
}
}
showNetworkStatus(isOnline) {
const statusBar = document.getElementById('network-status') || this.createNetworkStatusBar();
statusBar.textContent = isOnline ? 'Online' : 'Offline';
statusBar.className = `network-status ${isOnline ? 'online' : 'offline'}`;
statusBar.style.display = isOnline ? 'none' : 'block';
if (!isOnline) {
setTimeout(() => {
if (!this.isOnline) {
statusBar.style.display = 'none';
}
}, 5000);
}
}
createNetworkStatusBar() {
const statusBar = document.createElement('div');
statusBar.id = 'network-status';
statusBar.style.cssText = `
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
text-align: center;
font-size: 14px;
z-index: 9999;
display: none;
`;
// 添加样式
const style = document.createElement('style');
style.textContent = `
.network-status.online {
background: #28a745;
color: white;
}
.network-status.offline {
background: #dc3545;
color: white;
}
`;
document.head.appendChild(style);
document.body.appendChild(statusBar);
return statusBar;
}
async triggerBackgroundSync() {
if (this.registration && 'sync' in this.registration) {
try {
await this.registration.sync.register('background-sync');
console.log('Background sync registered');
} catch (error) {
console.error('Background sync registration failed:', error);
}
}
}
handleServiceWorkerMessage(event) {
const { data } = event;
switch (data.type) {
case 'VERSION_INFO':
console.log('Service Worker version:', data.version);
break;
case 'CACHE_UPDATED':
console.log('Cache updated:', data.cacheName);
break;
default:
console.log('Unknown message from Service Worker:', data);
}
}
showNotification(message, type = 'info') {
// 创建通知元素
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 16px;
border-radius: 4px;
color: white;
z-index: 10001;
max-width: 300px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
background: ${type === 'success' ? '#28a745' : type === 'error' ? '#dc3545' : '#007bff'};
`;
document.body.appendChild(notification);
// 自动移除
setTimeout(() => {
notification.remove();
}, 5000);
}
// 检查更新
async checkForUpdates() {
if (this.registration) {
try {
await this.registration.update();
console.log('Checked for Service Worker updates');
} catch (error) {
console.error('Update check failed:', error);
}
}
}
// 获取缓存信息
async getCacheInfo() {
if ('caches' in window) {
const cacheNames = await caches.keys();
const cacheInfo = {};
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
cacheInfo[cacheName] = keys.length;
}
return cacheInfo;
}
return {};
}
// 清除缓存
async clearCache(cacheName) {
if ('caches' in window) {
const deleted = await caches.delete(cacheName);
console.log(`Cache ${cacheName} deleted:`, deleted);
return deleted;
}
return false;
}
// 发送消息给Service Worker
async sendMessage(message) {
if (navigator.serviceWorker.controller) {
const messageChannel = new MessageChannel();
return new Promise((resolve) => {
messageChannel.port1.onmessage = (event) => {
resolve(event.data);
};
navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
});
}
throw new Error('No active Service Worker');
}
}
// 初始化Service Worker管理器
const swManager = new ServiceWorkerManager();
// 导出全局访问
window.swManager = swManager;
// 使用示例
window.addEventListener('sw-update-available', () => {
console.log('New version available!');
});
window.addEventListener('network-status-change', (event) => {
console.log('Network status:', event.detail.isOnline ? 'online' : 'offline');
});
离线数据存储
IndexedDB封装
// offline-storage.js - 离线数据存储管理
class OfflineStorage {
constructor(dbName = 'PWAOfflineDB', version = 1) {
this.dbName = dbName;
this.version = version;
this.db = null;
this.stores = {
offlineData: {
keyPath: 'id',
autoIncrement: true,
indexes: [
{ name: 'timestamp', keyPath: 'timestamp' },
{ name: 'type', keyPath: 'type' },
{ name: 'synced', keyPath: 'synced' }
]
},
userPreferences: {
keyPath: 'key'
},
cacheMetadata: {
keyPath: 'url',
indexes: [
{ name: 'timestamp', keyPath: 'timestamp' },
{ name: 'type', keyPath: 'type' }
]
}
};
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => {
reject(new Error(`Failed to open database: ${request.error}`));
};
request.onsuccess = () => {
this.db = request.result;
console.log('IndexedDB opened successfully');
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
this.createStores(db);
};
});
}
createStores(db) {
// 删除旧的对象存储
const existingStores = Array.from(db.objectStoreNames);
existingStores.forEach(storeName => {
if (!this.stores[storeName]) {
db.deleteObjectStore(storeName);
}
});
// 创建新的对象存储
Object.entries(this.stores).forEach(([storeName, config]) => {
if (!db.objectStoreNames.contains(storeName)) {
const store = db.createObjectStore(storeName, {
keyPath: config.keyPath,
autoIncrement: config.autoIncrement || false
});
// 创建索引
if (config.indexes) {
config.indexes.forEach(index => {
store.createIndex(index.name, index.keyPath, {
unique: index.unique || false
});
});
}
console.log(`Created object store: ${storeName}`);
}
});
}
async add(storeName, data) {
return this.performTransaction(storeName, 'readwrite', (store) => {
const request = store.add({
...data,
timestamp: Date.now(),
synced: false
});
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
});
}
async put(storeName, data) {
return this.performTransaction(storeName, 'readwrite', (store) => {
const request = store.put({
...data,
timestamp: Date.now()
});
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
});
}
async get(storeName, key) {
return this.performTransaction(storeName, 'readonly', (store) => {
const request = store.get(key);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
});
}
async getAll(storeName, query = null) {
return this.performTransaction(storeName, 'readonly', (store) => {
const request = query ? store.getAll(query) : store.getAll();
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
});
}
async delete(storeName, key) {
return this.performTransaction(storeName, 'readwrite', (store) => {
const request = store.delete(key);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
});
}
async clear(storeName) {
return this.performTransaction(storeName, 'readwrite', (store) => {
const request = store.clear();
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
});
}
async count(storeName) {
return this.performTransaction(storeName, 'readonly', (store) => {
const request = store.count();
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
});
}
async getByIndex(storeName, indexName, value) {
return this.performTransaction(storeName, 'readonly', (store) => {
const index = store.index(indexName);
const request = index.get(value);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
});
}
async getAllByIndex(storeName, indexName, value = null) {
return this.performTransaction(storeName, 'readonly', (store) => {
const index = store.index(indexName);
const request = value ? index.getAll(value) : index.getAll();
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
});
}
async performTransaction(storeName, mode, operation) {
if (!this.db) {
await this.init();
}
const transaction = this.db.transaction([storeName], mode);
const store = transaction.objectStore(storeName);
return operation(store);
}
// 离线数据同步相关方法
async addOfflineAction(action) {
return this.add('offlineData', {
type: action.type,
data: action.data,
url: action.url,
method: action.method,
headers: action.headers,
synced: false
});
}
async getUnsyncedActions() {
return this.getAllByIndex('offlineData', 'synced', false);
}
async markAsSynced(id) {
const data = await this.get('offlineData', id);
if (data) {
data.synced = true;
data.syncedAt = Date.now();
return this.put('offlineData', data);
}
}
async removeOldSyncedData(maxAge = 7 * 24 * 60 * 60 * 1000) { // 7天
const cutoffTime = Date.now() - maxAge;
const allData = await this.getAll('offlineData');
const toDelete = allData.filter(item =>
item.synced && item.syncedAt < cutoffTime
);
for (const item of toDelete) {
await this.delete('offlineData', item.id);
}
return toDelete.length;
}
// 用户偏好设置
async setPreference(key, value) {
return this.put('userPreferences', { key, value });
}
async getPreference(key, defaultValue = null) {
const result = await this.get('userPreferences', key);
return result ? result.value : defaultValue;
}
// 缓存元数据管理
async setCacheMetadata(url, metadata) {
return this.put('cacheMetadata', {
url,
...metadata,
timestamp: Date.now()
});
}
async getCacheMetadata(url) {
return this.get('cacheMetadata', url);
}
async cleanupOldCacheMetadata(maxAge = 30 * 24 * 60 * 60 * 1000) { // 30天
const cutoffTime = Date.now() - maxAge;
const allMetadata = await this.getAll('cacheMetadata');
const toDelete = allMetadata.filter(item =>
item.timestamp < cutoffTime
);
for (const item of toDelete) {
await this.delete('cacheMetadata', item.url);
}
return toDelete.length;
}
// 数据库统计信息
async getStorageStats() {
const stats = {};
for (const storeName of Object.keys(this.stores)) {
stats[storeName] = await this.count(storeName);
}
return stats;
}
// 导出数据
async exportData() {
const data = {};
for (const storeName of Object.keys(this.stores)) {
data[storeName] = await this.getAll(storeName);
}
return data;
}
// 导入数据
async importData(data) {
for (const [storeName, items] of Object.entries(data)) {
if (this.stores[storeName]) {
await this.clear(storeName);
for (const item of items) {
await this.put(storeName, item);
}
}
}
}
// 关闭数据库
close() {
if (this.db) {
this.db.close();
this.db = null;
}
}
}
// 全局离线存储实例
const offlineStorage = new OfflineStorage();
// 初始化
offlineStorage.init().catch(console.error);
// 导出
window.offlineStorage = offlineStorage;
// 离线数据管理器
class OfflineDataManager {
constructor(storage) {
this.storage = storage;
this.syncInProgress = false;
this.syncQueue = [];
}
async saveOfflineAction(action) {
try {
const id = await this.storage.addOfflineAction(action);
console.log('Offline action saved:', id);
// 如果在线,尝试立即同步
if (navigator.onLine) {
this.syncData();
}
return id;
} catch (error) {
console.error('Failed to save offline action:', error);
throw error;
}
}
async syncData() {
if (this.syncInProgress || !navigator.onLine) {
return;
}
this.syncInProgress = true;
try {
const unsyncedActions = await this.storage.getUnsyncedActions();
console.log(`Syncing ${unsyncedActions.length} offline actions`);
for (const action of unsyncedActions) {
try {
await this.syncAction(action);
await this.storage.markAsSynced(action.id);
console.log('Action synced:', action.id);
} catch (error) {
console.error('Failed to sync action:', action.id, error);
// 继续同步其他动作
}
}
// 清理旧的已同步数据
const cleaned = await this.storage.removeOldSyncedData();
if (cleaned > 0) {
console.log(`Cleaned up ${cleaned} old synced records`);
}
} catch (error) {
console.error('Sync failed:', error);
} finally {
this.syncInProgress = false;
}
}
async syncAction(action) {
const response = await fetch(action.url, {
method: action.method || 'POST',
headers: {
'Content-Type': 'application/json',
...action.headers
},
body: JSON.stringify(action.data)
});
if (!response.ok) {
throw new Error(`Sync failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
// 监听网络状态变化
setupNetworkListener() {
window.addEventListener('online', () => {
console.log('Back online, starting sync...');
this.syncData();
});
}
}
// 创建离线数据管理器
const offlineDataManager = new OfflineDataManager(offlineStorage);
offlineDataManager.setupNetworkListener();
// 导出
window.offlineDataManager = offlineDataManager;
推送通知系统
推送通知实现
// push-notifications.js - 推送通知管理
class PushNotificationManager {
constructor() {
this.registration = null;
this.subscription = null;
this.publicKey = 'YOUR_VAPID_PUBLIC_KEY'; // 替换为实际的VAPID公钥
this.isSupported = 'serviceWorker' in navigator && 'PushManager' in window;
this.permission = Notification.permission;
}
async init(serviceWorkerRegistration) {
if (!this.isSupported) {
console.warn('Push notifications not supported');
return false;
}
this.registration = serviceWorkerRegistration;
// 检查现有订阅
await this.checkExistingSubscription();
return true;
}
async checkExistingSubscription() {
try {
this.subscription = await this.registration.pushManager.getSubscription();
if (this.subscription) {
console.log('Existing push subscription found');
await this.sendSubscriptionToServer(this.subscription);
}
} catch (error) {
console.error('Failed to check existing subscription:', error);
}
}
async requestPermission() {
if (this.permission === 'granted') {
return true;
}
if (this.permission === 'denied') {
console.warn('Push notifications denied by user');
return false;
}
// 请求权限
const permission = await Notification.requestPermission();
this.permission = permission;
if (permission === 'granted') {
console.log('Push notification permission granted');
return true;
} else {
console.warn('Push notification permission denied');
return false;
}
}
async subscribe() {
if (!this.isSupported || !this.registration) {
throw new Error('Push notifications not supported or Service Worker not registered');
}
// 请求权限
const hasPermission = await this.requestPermission();
if (!hasPermission) {
throw new Error('Push notification permission denied');
}
try {
// 创建订阅
this.subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.publicKey)
});
console.log('Push subscription created:', this.subscription);
// 发送订阅信息到服务器
await this.sendSubscriptionToServer(this.subscription);
return this.subscription;
} catch (error) {
console.error('Failed to subscribe to push notifications:', error);
throw error;
}
}
async unsubscribe() {
if (!this.subscription) {
console.warn('No active push subscription');
return false;
}
try {
const successful = await this.subscription.unsubscribe();
if (successful) {
console.log('Push subscription cancelled');
// 通知服务器取消订阅
await this.removeSubscriptionFromServer(this.subscription);
this.subscription = null;
}
return successful;
} catch (error) {
console.error('Failed to unsubscribe from push notifications:', error);
throw 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(),
userAgent: navigator.userAgent,
timestamp: Date.now()
})
});
if (!response.ok) {
throw new Error(`Failed to send subscription: ${response.status}`);
}
console.log('Subscription sent to server');
} catch (error) {
console.error('Failed to send subscription to server:', error);
throw error;
}
}
async removeSubscriptionFromServer(subscription) {
try {
const response = await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscription: subscription.toJSON()
})
});
if (!response.ok) {
throw new Error(`Failed to remove subscription: ${response.status}`);
}
console.log('Subscription removed from server');
} catch (error) {
console.error('Failed to remove subscription from server:', error);
}
}
// 显示本地通知
async showLocalNotification(title, options = {}) {
if (this.permission !== 'granted') {
console.warn('Cannot show notification: permission not granted');
return;
}
const defaultOptions = {
body: '',
icon: '/images/icon-192.png',
badge: '/images/badge.png',
vibrate: [100, 50, 100],
data: {
timestamp: Date.now()
},
actions: [],
requireInteraction: false,
silent: false
};
const notificationOptions = { ...defaultOptions, ...options };
try {
if (this.registration) {
// 通过Service Worker显示通知
await this.registration.showNotification(title, notificationOptions);
} else {
// 直接显示通知
new Notification(title, notificationOptions);
}
console.log('Local notification shown:', title);
} catch (error) {
console.error('Failed to show local notification:', 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;
}
// 获取订阅状态
getSubscriptionStatus() {
return {
isSupported: this.isSupported,
permission: this.permission,
isSubscribed: !!this.subscription,
subscription: this.subscription
};
}
// 测试通知
async testNotification() {
await this.showLocalNotification('Test Notification', {
body: 'This is a test notification from your PWA!',
actions: [
{
action: 'open',
title: 'Open App'
},
{
action: 'close',
title: 'Close'
}
]
});
}
}
// 通知UI管理器
class NotificationUI {
constructor(pushManager) {
this.pushManager = pushManager;
this.createUI();
}
createUI() {
// 创建通知设置面板
const panel = document.createElement('div');
panel.id = 'notification-panel';
panel.className = 'notification-panel';
panel.innerHTML = `
<div class="panel-header">
<h3>Push Notifications</h3>
<button id="closePanel" class="close-btn">×</button>
</div>
<div class="panel-content">
<div class="status-info" id="statusInfo"></div>
<div class="controls">
<button id="subscribeBtn" class="btn btn-primary">Enable Notifications</button>
<button id="unsubscribeBtn" class="btn btn-secondary">Disable Notifications</button>
<button id="testBtn" class="btn btn-outline">Test Notification</button>
</div>
</div>
`;
// 添加样式
const style = document.createElement('style');
style.textContent = `
.notification-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 10000;
width: 90%;
max-width: 400px;
display: none;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #eee;
}
.panel-header h3 {
margin: 0;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.panel-content {
padding: 16px;
}
.status-info {
margin-bottom: 16px;
padding: 12px;
border-radius: 4px;
font-size: 14px;
}
.status-info.granted {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-info.denied {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status-info.default {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-outline {
background: transparent;
color: #007bff;
border: 1px solid #007bff;
}
.btn:hover {
opacity: 0.8;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
document.head.appendChild(style);
document.body.appendChild(panel);
this.panel = panel;
this.attachEventListeners();
this.updateUI();
}
attachEventListeners() {
const subscribeBtn = this.panel.querySelector('#subscribeBtn');
const unsubscribeBtn = this.panel.querySelector('#unsubscribeBtn');
const testBtn = this.panel.querySelector('#testBtn');
const closeBtn = this.panel.querySelector('#closePanel');
subscribeBtn.addEventListener('click', async () => {
try {
subscribeBtn.disabled = true;
subscribeBtn.textContent = 'Subscribing...';
await this.pushManager.subscribe();
this.updateUI();
this.showMessage('Notifications enabled successfully!', 'success');
} catch (error) {
this.showMessage('Failed to enable notifications: ' + error.message, 'error');
} finally {
subscribeBtn.disabled = false;
subscribeBtn.textContent = 'Enable Notifications';
}
});
unsubscribeBtn.addEventListener('click', async () => {
try {
unsubscribeBtn.disabled = true;
unsubscribeBtn.textContent = 'Unsubscribing...';
await this.pushManager.unsubscribe();
this.updateUI();
this.showMessage('Notifications disabled successfully!', 'success');
} catch (error) {
this.showMessage('Failed to disable notifications: ' + error.message, 'error');
} finally {
unsubscribeBtn.disabled = false;
unsubscribeBtn.textContent = 'Disable Notifications';
}
});
testBtn.addEventListener('click', async () => {
try {
await this.pushManager.testNotification();
this.showMessage('Test notification sent!', 'success');
} catch (error) {
this.showMessage('Failed to send test notification: ' + error.message, 'error');
}
});
closeBtn.addEventListener('click', () => {
this.hide();
});
}
updateUI() {
const status = this.pushManager.getSubscriptionStatus();
const statusInfo = this.panel.querySelector('#statusInfo');
const subscribeBtn = this.panel.querySelector('#subscribeBtn');
const unsubscribeBtn = this.panel.querySelector('#unsubscribeBtn');
const testBtn = this.panel.querySelector('#testBtn');
// 更新状态信息
statusInfo.className = `status-info ${status.permission}`;
if (!status.isSupported) {
statusInfo.textContent = 'Push notifications are not supported in this browser.';
subscribeBtn.style.display = 'none';
unsubscribeBtn.style.display = 'none';
testBtn.style.display = 'none';
} else if (status.permission === 'denied') {
statusInfo.textContent = 'Push notifications are blocked. Please enable them in your browser settings.';
subscribeBtn.style.display = 'none';
unsubscribeBtn.style.display = 'none';
testBtn.style.display = 'none';
} else if (status.isSubscribed) {
statusInfo.textContent = 'Push notifications are enabled.';
subscribeBtn.style.display = 'none';
unsubscribeBtn.style.display = 'inline-block';
testBtn.style.display = 'inline-block';
} else {
statusInfo.textContent = 'Push notifications are disabled.';
subscribeBtn.style.display = 'inline-block';
unsubscribeBtn.style.display = 'none';
testBtn.style.display = 'none';
}
}
show() {
this.panel.style.display = 'block';
this.updateUI();
}
hide() {
this.panel.style.display = 'none';
}
showMessage(message, type) {
// 创建临时消息提示
const messageEl = document.createElement('div');
messageEl.className = `message message-${type}`;
messageEl.textContent = message;
messageEl.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 16px;
border-radius: 4px;
color: white;
z-index: 10001;
background: ${type === 'success' ? '#28a745' : '#dc3545'};
`;
document.body.appendChild(messageEl);
setTimeout(() => {
messageEl.remove();
}, 3000);
}
}
// 初始化推送通知管理器
let pushNotificationManager;
let notificationUI;
// 等待Service Worker注册完成后初始化
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => {
pushNotificationManager = new PushNotificationManager();
pushNotificationManager.init(registration);
notificationUI = new NotificationUI(pushNotificationManager);
// 导出全局访问
window.pushNotificationManager = pushNotificationManager;
window.notificationUI = notificationUI;
});
}
// 添加显示通知设置的按钮
document.addEventListener('DOMContentLoaded', () => {
const showNotificationBtn = document.createElement('button');
showNotificationBtn.textContent = 'Notification Settings';
showNotificationBtn.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
z-index: 1000;
`;
showNotificationBtn.addEventListener('click', () => {
if (notificationUI) {
notificationUI.show();
}
});
document.body.appendChild(showNotificationBtn);
});
Web App Manifest
Manifest配置
{
"name": "Progressive Web App Demo",
"short_name": "PWA Demo",
"description": "A comprehensive PWA demonstration with offline capabilities",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#007bff",
"background_color": "#ffffff",
"lang": "en-US",
"dir": "ltr",
"categories": ["productivity", "utilities"],
"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-desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Desktop view of the application"
},
{
"src": "/images/screenshot-mobile.png",
"sizes": "375x667",
"type": "image/png",
"form_factor": "narrow",
"label": "Mobile view of the application"
}
],
"shortcuts": [
{
"name": "New Document",
"short_name": "New Doc",
"description": "Create a new document",
"url": "/new-document",
"icons": [
{
"src": "/images/new-doc-icon.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Settings",
"short_name": "Settings",
"description": "Open application settings",
"url": "/settings",
"icons": [
{
"src": "/images/settings-icon.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
],
"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-protocol?url=%s"
}
],
"file_handlers": [
{
"action": "/handle-file",
"accept": {
"text/plain": [".txt"],
"application/json": [".json"]
}
}
],
"edge_side_panel": {
"preferred_width": 400
},
"launch_handler": {
"client_mode": "navigate-existing"
}
}
总结
PWA开发的核心要点:
- Service Worker:离线缓存、后台同步、推送通知的核心
- 缓存策略:缓存优先、网络优先、离线回退等策略
- 离线存储:IndexedDB数据持久化和同步机制
- 推送通知:VAPID密钥、订阅管理、通知显示
- Web App Manifest:应用元数据、图标、启动配置
PWA技术让Web应用具备了接近原生应用的能力,为用户提供更好的体验,是现代Web开发的重要方向。