发布于

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

作者

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开发的核心要点:

  1. Service Worker:离线缓存、后台同步、推送通知的核心
  2. 缓存策略:缓存优先、网络优先、离线回退等策略
  3. 离线存储:IndexedDB数据持久化和同步机制
  4. 推送通知:VAPID密钥、订阅管理、通知显示
  5. Web App Manifest:应用元数据、图标、启动配置

PWA技术让Web应用具备了接近原生应用的能力,为用户提供更好的体验,是现代Web开发的重要方向。