Skip to content

15.2 Progressive Web Apps

关键词: Progressive Web Apps, PWA, Service Worker, Web App Manifest, 离线功能, 原生应用体验, 缓存策略, 推送通知

学习目标

  • 理解Progressive Web Apps的核心概念和特性
  • 掌握Service Worker的使用方法和缓存策略
  • 学会配置Web App Manifest实现应用安装
  • 了解离线功能的实现原理和技术
  • 掌握PWA的性能优化和用户体验提升技巧

15.2.1 PWA概述

什么是Progressive Web Apps

Progressive Web Apps(PWA)是一种使用现代Web技术构建的应用程序,它结合了Web应用的开放性和原生应用的用户体验。

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>PWA入门示例</title>
    
    <!-- PWA基本配置 -->
    <link rel="manifest" href="/manifest.json">
    <meta name="theme-color" content="#2196F3">
    
    <!-- iOS Safari配置 -->
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="default">
    <meta name="apple-mobile-web-app-title" content="PWA示例">
    <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
    
    <!-- 样式 -->
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            margin: 0;
            padding: 0;
            background-color: #f5f5f5;
        }
        
        .app-header {
            background-color: #2196F3;
            color: white;
            padding: 16px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        
        .app-header h1 {
            margin: 0;
            font-size: 20px;
        }
        
        .app-content {
            padding: 16px;
            max-width: 600px;
            margin: 0 auto;
        }
        
        .feature-card {
            background: white;
            border-radius: 8px;
            padding: 16px;
            margin-bottom: 16px;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
        }
        
        .feature-card h3 {
            margin: 0 0 8px 0;
            color: #333;
        }
        
        .feature-card p {
            margin: 0;
            color: #666;
            line-height: 1.5;
        }
        
        .install-button {
            background-color: #4CAF50;
            color: white;
            border: none;
            padding: 12px 24px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
            margin: 16px 0;
            display: none;
        }
        
        .install-button:hover {
            background-color: #45a049;
        }
        
        .status-indicator {
            position: fixed;
            top: 10px;
            right: 10px;
            padding: 8px 16px;
            border-radius: 20px;
            font-size: 12px;
            font-weight: bold;
        }
        
        .online {
            background-color: #4CAF50;
            color: white;
        }
        
        .offline {
            background-color: #f44336;
            color: white;
        }
    </style>
</head>
<body>
    <div class="status-indicator online" id="status">在线</div>
    
    <header class="app-header">
        <h1>PWA示例应用</h1>
    </header>
    
    <main class="app-content">
        <button class="install-button" id="installButton">安装应用</button>
        
        <div class="feature-card">
            <h3>🚀 快速加载</h3>
            <p>通过Service Worker缓存,应用能够快速加载,即使在网络条件不佳的情况下也能提供良好体验。</p>
        </div>
        
        <div class="feature-card">
            <h3>📱 响应式设计</h3>
            <p>适配各种屏幕尺寸,在手机、平板和桌面设备上都能完美显示。</p>
        </div>
        
        <div class="feature-card">
            <h3>🔒 安全连接</h3>
            <p>运行在HTTPS上,确保数据传输的安全性。</p>
        </div>
        
        <div class="feature-card">
            <h3>📶 离线工作</h3>
            <p>即使在没有网络连接的情况下,应用仍能正常工作。</p>
        </div>
        
        <div class="feature-card">
            <h3>🏠 可安装</h3>
            <p>可以安装到设备主屏幕,像原生应用一样启动。</p>
        </div>
    </main>
    
    <script>
        // 注册Service Worker
        if ('serviceWorker' in navigator) {
            window.addEventListener('load', function() {
                navigator.serviceWorker.register('/sw.js')
                    .then(function(registration) {
                        console.log('Service Worker注册成功:', registration.scope);
                    })
                    .catch(function(error) {
                        console.log('Service Worker注册失败:', error);
                    });
            });
        }
        
        // 处理应用安装
        let deferredPrompt;
        const installButton = document.getElementById('installButton');
        
        window.addEventListener('beforeinstallprompt', (e) => {
            e.preventDefault();
            deferredPrompt = e;
            installButton.style.display = 'block';
        });
        
        installButton.addEventListener('click', async () => {
            if (deferredPrompt) {
                deferredPrompt.prompt();
                const { outcome } = await deferredPrompt.userChoice;
                console.log('用户选择:', outcome);
                deferredPrompt = null;
                installButton.style.display = 'none';
            }
        });
        
        // 监听应用安装事件
        window.addEventListener('appinstalled', (evt) => {
            console.log('应用已安装');
        });
        
        // 网络状态监听
        const statusIndicator = document.getElementById('status');
        
        function updateOnlineStatus() {
            if (navigator.onLine) {
                statusIndicator.textContent = '在线';
                statusIndicator.className = 'status-indicator online';
            } else {
                statusIndicator.textContent = '离线';
                statusIndicator.className = 'status-indicator offline';
            }
        }
        
        window.addEventListener('online', updateOnlineStatus);
        window.addEventListener('offline', updateOnlineStatus);
        
        // 初始化状态
        updateOnlineStatus();
    </script>
</body>
</html>

manifest.json配置

json
{
  "name": "PWA示例应用",
  "short_name": "PWA示例",
  "description": "一个完整的PWA应用示例",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2196F3",
  "orientation": "portrait-primary",
  "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"
    }
  ],
  "shortcuts": [
    {
      "name": "新建笔记",
      "short_name": "新建",
      "description": "快速创建新笔记",
      "url": "/new-note",
      "icons": [
        {
          "src": "/icons/new-note.png",
          "sizes": "192x192"
        }
      ]
    }
  ],
  "categories": ["productivity", "utilities"],
  "lang": "zh-CN",
  "dir": "ltr"
}

15.2.2 Service Worker详解

Service Worker基础

Service Worker是PWA的核心技术,它运行在后台,充当Web应用和网络之间的代理。

javascript
// sw.js - Service Worker文件
const CACHE_NAME = 'pwa-cache-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/styles.css',
  '/script.js',
  '/manifest.json',
  '/icons/icon-192x192.png',
  '/icons/icon-512x512.png'
];

// 安装事件
self.addEventListener('install', function(event) {
  console.log('Service Worker安装中...');
  
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('缓存已打开');
        return cache.addAll(urlsToCache);
      })
  );
});

// 激活事件
self.addEventListener('activate', function(event) {
  console.log('Service Worker激活中...');
  
  event.waitUntil(
    caches.keys().then(function(cacheNames) {
      return Promise.all(
        cacheNames.map(function(cacheName) {
          if (cacheName !== CACHE_NAME) {
            console.log('删除旧缓存:', cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

// 拦截网络请求
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        // 如果缓存中有,直接返回
        if (response) {
          return response;
        }
        
        // 否则发起网络请求
        return fetch(event.request)
          .then(function(response) {
            // 检查是否为有效响应
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }
            
            // 克隆响应,因为响应流只能使用一次
            const responseToCache = response.clone();
            
            caches.open(CACHE_NAME)
              .then(function(cache) {
                cache.put(event.request, responseToCache);
              });
            
            return response;
          });
      })
  );
});

高级缓存策略

javascript
// 高级Service Worker - 多种缓存策略
const CACHE_NAME = 'pwa-cache-v2';
const API_CACHE = 'api-cache-v1';
const IMAGE_CACHE = 'image-cache-v1';

// 缓存策略枚举
const CACHE_STRATEGIES = {
  CACHE_FIRST: 'cache-first',
  NETWORK_FIRST: 'network-first',
  STALE_WHILE_REVALIDATE: 'stale-while-revalidate',
  NETWORK_ONLY: 'network-only',
  CACHE_ONLY: 'cache-only'
};

// 路由配置
const ROUTES = [
  {
    pattern: /^https:\/\/api\.example\.com\/.*$/,
    strategy: CACHE_STRATEGIES.NETWORK_FIRST,
    cache: API_CACHE,
    maxAge: 5 * 60 * 1000 // 5分钟
  },
  {
    pattern: /.*\.(?:png|jpg|jpeg|svg|gif|webp)$/,
    strategy: CACHE_STRATEGIES.CACHE_FIRST,
    cache: IMAGE_CACHE,
    maxAge: 30 * 24 * 60 * 60 * 1000 // 30天
  },
  {
    pattern: /.*\.(?:js|css|html)$/,
    strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
    cache: CACHE_NAME,
    maxAge: 24 * 60 * 60 * 1000 // 24小时
  }
];

// 安装事件
self.addEventListener('install', function(event) {
  console.log('Service Worker安装中...');
  
  const urlsToCache = [
    '/',
    '/offline.html',
    '/styles.css',
    '/script.js'
  ];
  
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(urlsToCache))
  );
  
  // 立即激活新的Service Worker
  self.skipWaiting();
});

// 激活事件
self.addEventListener('activate', function(event) {
  console.log('Service Worker激活中...');
  
  event.waitUntil(
    Promise.all([
      // 清理旧缓存
      caches.keys().then(cacheNames => {
        return Promise.all(
          cacheNames.map(cacheName => {
            if (![CACHE_NAME, API_CACHE, IMAGE_CACHE].includes(cacheName)) {
              console.log('删除旧缓存:', cacheName);
              return caches.delete(cacheName);
            }
          })
        );
      }),
      // 立即控制所有页面
      self.clients.claim()
    ])
  );
});

// 拦截网络请求
self.addEventListener('fetch', function(event) {
  const request = event.request;
  const url = request.url;
  
  // 找到匹配的路由
  const route = ROUTES.find(route => route.pattern.test(url));
  
  if (route) {
    event.respondWith(handleRequest(request, route));
  } else {
    // 默认策略
    event.respondWith(
      caches.match(request)
        .then(response => response || fetch(request))
        .catch(() => caches.match('/offline.html'))
    );
  }
});

// 处理请求的函数
async function handleRequest(request, route) {
  const { strategy, cache, maxAge } = route;
  
  switch (strategy) {
    case CACHE_STRATEGIES.CACHE_FIRST:
      return cacheFirst(request, cache, maxAge);
    
    case CACHE_STRATEGIES.NETWORK_FIRST:
      return networkFirst(request, cache, maxAge);
    
    case CACHE_STRATEGIES.STALE_WHILE_REVALIDATE:
      return staleWhileRevalidate(request, cache, maxAge);
    
    case CACHE_STRATEGIES.NETWORK_ONLY:
      return fetch(request);
    
    case CACHE_STRATEGIES.CACHE_ONLY:
      return caches.match(request);
    
    default:
      return fetch(request);
  }
}

// 缓存优先策略
async function cacheFirst(request, cacheName, maxAge) {
  const cachedResponse = await caches.match(request);
  
  if (cachedResponse && !isExpired(cachedResponse, maxAge)) {
    return cachedResponse;
  }
  
  try {
    const networkResponse = await fetch(request);
    const cache = await caches.open(cacheName);
    cache.put(request, networkResponse.clone());
    return networkResponse;
  } catch (error) {
    return cachedResponse || new Response('离线时无法访问', { status: 503 });
  }
}

// 网络优先策略
async function networkFirst(request, cacheName, maxAge) {
  try {
    const networkResponse = await fetch(request);
    const cache = await caches.open(cacheName);
    cache.put(request, networkResponse.clone());
    return networkResponse;
  } catch (error) {
    const cachedResponse = await caches.match(request);
    return cachedResponse || new Response('离线时无法访问', { status: 503 });
  }
}

// 过期重新验证策略
async function staleWhileRevalidate(request, cacheName, maxAge) {
  const cachedResponse = await caches.match(request);
  
  const fetchPromise = fetch(request).then(networkResponse => {
    const cache = caches.open(cacheName);
    cache.then(c => c.put(request, networkResponse.clone()));
    return networkResponse;
  });
  
  return cachedResponse || fetchPromise;
}

// 检查缓存是否过期
function isExpired(response, maxAge) {
  if (!maxAge) return false;
  
  const cachedTime = response.headers.get('sw-cache-time');
  if (!cachedTime) return false;
  
  return Date.now() - parseInt(cachedTime) > maxAge;
}

// 后台同步
self.addEventListener('sync', function(event) {
  if (event.tag === 'background-sync') {
    event.waitUntil(doBackgroundSync());
  }
});

async function doBackgroundSync() {
  // 处理离线时的数据同步
  const pendingRequests = await getPendingRequests();
  
  for (const request of pendingRequests) {
    try {
      await fetch(request);
      await removePendingRequest(request);
    } catch (error) {
      console.log('同步失败:', error);
    }
  }
}

// 推送通知
self.addEventListener('push', function(event) {
  if (event.data) {
    const data = event.data.json();
    const options = {
      body: data.body,
      icon: data.icon || '/icons/icon-192x192.png',
      badge: '/icons/badge.png',
      vibrate: [100, 50, 100],
      data: {
        dateOfArrival: Date.now(),
        primaryKey: data.primaryKey
      },
      actions: [
        {
          action: 'explore',
          title: '查看详情',
          icon: '/icons/checkmark.png'
        },
        {
          action: 'close',
          title: '关闭',
          icon: '/icons/xmark.png'
        }
      ]
    };
    
    event.waitUntil(
      self.registration.showNotification(data.title, options)
    );
  }
});

// 通知点击事件
self.addEventListener('notificationclick', function(event) {
  event.notification.close();
  
  if (event.action === 'explore') {
    event.waitUntil(
      clients.openWindow('/notification-details')
    );
  }
});

15.2.3 离线功能实现

离线页面和数据管理

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>离线笔记应用</title>
    <link rel="manifest" href="/manifest.json">
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
        }
        
        .app-header {
            background-color: #2196F3;
            color: white;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
        }
        
        .app-header h1 {
            margin: 0;
        }
        
        .note-form {
            background: white;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        
        .note-form input,
        .note-form textarea {
            width: 100%;
            padding: 10px;
            margin-bottom: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 16px;
        }
        
        .note-form textarea {
            height: 120px;
            resize: vertical;
        }
        
        .note-form button {
            background-color: #4CAF50;
            color: white;
            padding: 10px 20px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
        }
        
        .note-form button:hover {
            background-color: #45a049;
        }
        
        .note-form button:disabled {
            background-color: #cccccc;
            cursor: not-allowed;
        }
        
        .notes-container {
            display: grid;
            gap: 16px;
        }
        
        .note-item {
            background: white;
            padding: 16px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            position: relative;
        }
        
        .note-item h3 {
            margin: 0 0 8px 0;
            color: #333;
        }
        
        .note-item p {
            margin: 0 0 12px 0;
            color: #666;
            line-height: 1.5;
        }
        
        .note-meta {
            display: flex;
            justify-content: space-between;
            align-items: center;
            font-size: 14px;
            color: #999;
        }
        
        .note-actions {
            display: flex;
            gap: 8px;
        }
        
        .note-actions button {
            padding: 4px 8px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 12px;
        }
        
        .edit-btn {
            background-color: #2196F3;
            color: white;
        }
        
        .delete-btn {
            background-color: #f44336;
            color: white;
        }
        
        .sync-status {
            position: fixed;
            top: 20px;
            right: 20px;
            padding: 8px 16px;
            border-radius: 20px;
            font-size: 14px;
            font-weight: bold;
            z-index: 1000;
        }
        
        .synced {
            background-color: #4CAF50;
            color: white;
        }
        
        .syncing {
            background-color: #FF9800;
            color: white;
        }
        
        .offline {
            background-color: #f44336;
            color: white;
        }
        
        .pending-sync {
            background-color: #2196F3;
            color: white;
        }
        
        .empty-state {
            text-align: center;
            padding: 40px;
            color: #999;
        }
        
        .offline-indicator {
            background-color: #f44336;
            color: white;
            padding: 8px;
            text-align: center;
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            z-index: 1000;
            transform: translateY(-100%);
            transition: transform 0.3s ease;
        }
        
        .offline-indicator.show {
            transform: translateY(0);
        }
    </style>
</head>
<body>
    <div class="offline-indicator" id="offlineIndicator">
        您当前处于离线状态,数据将在恢复网络连接后自动同步
    </div>
    
    <div class="sync-status" id="syncStatus">已同步</div>
    
    <header class="app-header">
        <h1>离线笔记应用</h1>
        <p>支持离线编辑,数据自动同步</p>
    </header>
    
    <div class="note-form">
        <input type="text" id="noteTitle" placeholder="笔记标题">
        <textarea id="noteContent" placeholder="笔记内容"></textarea>
        <button id="saveNote">保存笔记</button>
    </div>
    
    <div class="notes-container" id="notesContainer">
        <div class="empty-state">
            <h3>暂无笔记</h3>
            <p>创建您的第一个笔记吧!</p>
        </div>
    </div>
    
    <script>
        class OfflineNoteApp {
            constructor() {
                this.notes = [];
                this.pendingSync = [];
                this.isOnline = navigator.onLine;
                this.syncStatus = document.getElementById('syncStatus');
                this.offlineIndicator = document.getElementById('offlineIndicator');
                
                this.init();
            }
            
            async init() {
                // 加载本地数据
                await this.loadLocalNotes();
                
                // 绑定事件
                this.bindEvents();
                
                // 渲染笔记
                this.renderNotes();
                
                // 监听网络状态
                this.setupNetworkListeners();
                
                // 尝试同步
                if (this.isOnline) {
                    this.syncNotes();
                }
            }
            
            bindEvents() {
                const saveButton = document.getElementById('saveNote');
                const titleInput = document.getElementById('noteTitle');
                const contentInput = document.getElementById('noteContent');
                
                saveButton.addEventListener('click', () => {
                    this.saveNote();
                });
                
                // 快捷键保存
                document.addEventListener('keydown', (e) => {
                    if (e.ctrlKey && e.key === 's') {
                        e.preventDefault();
                        this.saveNote();
                    }
                });
            }
            
            setupNetworkListeners() {
                window.addEventListener('online', () => {
                    this.isOnline = true;
                    this.offlineIndicator.classList.remove('show');
                    this.syncNotes();
                });
                
                window.addEventListener('offline', () => {
                    this.isOnline = false;
                    this.offlineIndicator.classList.add('show');
                    this.updateSyncStatus('离线');
                });
                
                // 初始状态
                if (!this.isOnline) {
                    this.offlineIndicator.classList.add('show');
                }
            }
            
            async saveNote() {
                const title = document.getElementById('noteTitle').value.trim();
                const content = document.getElementById('noteContent').value.trim();
                
                if (!title || !content) {
                    alert('请输入标题和内容');
                    return;
                }
                
                const note = {
                    id: Date.now(),
                    title: title,
                    content: content,
                    createdAt: new Date().toISOString(),
                    updatedAt: new Date().toISOString(),
                    synced: false
                };
                
                // 保存到本地
                this.notes.unshift(note);
                await this.saveToLocal();
                
                // 添加到待同步队列
                this.pendingSync.push(note);
                
                // 清空表单
                document.getElementById('noteTitle').value = '';
                document.getElementById('noteContent').value = '';
                
                // 重新渲染
                this.renderNotes();
                
                // 尝试同步
                if (this.isOnline) {
                    this.syncNotes();
                } else {
                    this.updateSyncStatus('待同步');
                }
            }
            
            async deleteNote(id) {
                if (confirm('确定要删除这个笔记吗?')) {
                    this.notes = this.notes.filter(note => note.id !== id);
                    await this.saveToLocal();
                    this.renderNotes();
                    
                    // 如果是已同步的笔记,需要发送删除请求
                    if (this.isOnline) {
                        try {
                            await fetch(`/api/notes/${id}`, {
                                method: 'DELETE'
                            });
                        } catch (error) {
                            console.log('删除同步失败:', error);
                        }
                    }
                }
            }
            
            async editNote(id) {
                const note = this.notes.find(n => n.id === id);
                if (note) {
                    document.getElementById('noteTitle').value = note.title;
                    document.getElementById('noteContent').value = note.content;
                    
                    // 删除原笔记
                    this.notes = this.notes.filter(n => n.id !== id);
                    await this.saveToLocal();
                    this.renderNotes();
                }
            }
            
            renderNotes() {
                const container = document.getElementById('notesContainer');
                
                if (this.notes.length === 0) {
                    container.innerHTML = `
                        <div class="empty-state">
                            <h3>暂无笔记</h3>
                            <p>创建您的第一个笔记吧!</p>
                        </div>
                    `;
                    return;
                }
                
                container.innerHTML = this.notes.map(note => `
                    <div class="note-item">
                        <h3>${note.title}</h3>
                        <p>${note.content}</p>
                        <div class="note-meta">
                            <span>创建于: ${new Date(note.createdAt).toLocaleString()}</span>
                            <div class="note-actions">
                                <button class="edit-btn" onclick="app.editNote(${note.id})">编辑</button>
                                <button class="delete-btn" onclick="app.deleteNote(${note.id})">删除</button>
                            </div>
                        </div>
                        ${!note.synced ? '<div style="color: #FF9800; font-size: 12px;">待同步</div>' : ''}
                    </div>
                `).join('');
            }
            
            async syncNotes() {
                if (!this.isOnline || this.pendingSync.length === 0) {
                    return;
                }
                
                this.updateSyncStatus('同步中');
                
                try {
                    for (const note of this.pendingSync) {
                        const response = await fetch('/api/notes', {
                            method: 'POST',
                            headers: {
                                'Content-Type': 'application/json'
                            },
                            body: JSON.stringify(note)
                        });
                        
                        if (response.ok) {
                            // 标记为已同步
                            const localNote = this.notes.find(n => n.id === note.id);
                            if (localNote) {
                                localNote.synced = true;
                            }
                        }
                    }
                    
                    // 清空待同步队列
                    this.pendingSync = [];
                    
                    // 保存到本地
                    await this.saveToLocal();
                    
                    // 重新渲染
                    this.renderNotes();
                    
                    this.updateSyncStatus('已同步');
                } catch (error) {
                    console.error('同步失败:', error);
                    this.updateSyncStatus('同步失败');
                }
            }
            
            async loadLocalNotes() {
                try {
                    const saved = localStorage.getItem('offline-notes');
                    if (saved) {
                        this.notes = JSON.parse(saved);
                        // 加载待同步队列
                        this.pendingSync = this.notes.filter(note => !note.synced);
                    }
                } catch (error) {
                    console.error('加载本地数据失败:', error);
                }
            }
            
            async saveToLocal() {
                try {
                    localStorage.setItem('offline-notes', JSON.stringify(this.notes));
                } catch (error) {
                    console.error('保存到本地失败:', error);
                }
            }
            
            updateSyncStatus(status) {
                this.syncStatus.textContent = status;
                this.syncStatus.className = 'sync-status';
                
                switch (status) {
                    case '已同步':
                        this.syncStatus.classList.add('synced');
                        break;
                    case '同步中':
                        this.syncStatus.classList.add('syncing');
                        break;
                    case '离线':
                        this.syncStatus.classList.add('offline');
                        break;
                    case '待同步':
                        this.syncStatus.classList.add('pending-sync');
                        break;
                    default:
                        this.syncStatus.classList.add('offline');
                }
            }
        }
        
        // 初始化应用
        const app = new OfflineNoteApp();
        
        // 注册Service Worker
        if ('serviceWorker' in navigator) {
            navigator.serviceWorker.register('/sw.js')
                .then(registration => {
                    console.log('Service Worker注册成功');
                    
                    // 监听Service Worker更新
                    registration.addEventListener('updatefound', () => {
                        const newWorker = registration.installing;
                        newWorker.addEventListener('statechange', () => {
                            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
                                // 有新版本可用
                                if (confirm('发现新版本,是否刷新页面?')) {
                                    window.location.reload();
                                }
                            }
                        });
                    });
                })
                .catch(error => {
                    console.error('Service Worker注册失败:', error);
                });
        }
        
        // 后台同步
        if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
            navigator.serviceWorker.ready.then(registration => {
                registration.sync.register('background-sync');
            });
        }
    </script>
</body>
</html>

15.2.4 推送通知

推送通知实现

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>PWA推送通知示例</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 600px;
            margin: 0 auto;
            padding: 20px;
        }
        
        .notification-demo {
            background: #f9f9f9;
            padding: 20px;
            border-radius: 8px;
            margin: 20px 0;
        }
        
        .notification-demo h3 {
            margin-top: 0;
        }
        
        .btn {
            background-color: #2196F3;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 4px;
            cursor: pointer;
            margin-right: 10px;
            margin-bottom: 10px;
        }
        
        .btn:hover {
            background-color: #1976D2;
        }
        
        .btn:disabled {
            background-color: #cccccc;
            cursor: not-allowed;
        }
        
        .status {
            margin-top: 10px;
            padding: 10px;
            border-radius: 4px;
            background-color: #e3f2fd;
            border-left: 4px solid #2196F3;
        }
        
        .permission-status {
            font-weight: bold;
            margin-bottom: 10px;
        }
        
        .granted {
            color: #4CAF50;
        }
        
        .denied {
            color: #f44336;
        }
        
        .default {
            color: #FF9800;
        }
    </style>
</head>
<body>
    <h1>PWA推送通知示例</h1>
    
    <div class="notification-demo">
        <h3>通知权限状态</h3>
        <div class="permission-status" id="permissionStatus">检查中...</div>
        <button class="btn" id="requestPermission">请求通知权限</button>
    </div>
    
    <div class="notification-demo">
        <h3>本地通知</h3>
        <button class="btn" onclick="showSimpleNotification()">简单通知</button>
        <button class="btn" onclick="showRichNotification()">富媒体通知</button>
        <button class="btn" onclick="showActionNotification()">操作按钮通知</button>
    </div>
    
    <div class="notification-demo">
        <h3>推送通知</h3>
        <button class="btn" id="subscribePush">订阅推送</button>
        <button class="btn" id="unsubscribePush">取消订阅</button>
        <button class="btn" id="sendPushNotification">发送推送通知</button>
        <div class="status" id="pushStatus">推送状态:未订阅</div>
    </div>
    
    <div class="notification-demo">
        <h3>定时提醒</h3>
        <button class="btn" onclick="scheduleNotification(5000)">5秒后提醒</button>
        <button class="btn" onclick="scheduleNotification(10000)">10秒后提醒</button>
        <button class="btn" onclick="scheduleNotification(30000)">30秒后提醒</button>
    </div>
    
    <script>
        // 检查通知权限
        function checkNotificationPermission() {
            const status = Notification.permission;
            const statusElement = document.getElementById('permissionStatus');
            
            statusElement.textContent = `权限状态: ${status}`;
            statusElement.className = `permission-status ${status}`;
            
            return status;
        }
        
        // 请求通知权限
        async function requestNotificationPermission() {
            if (!('Notification' in window)) {
                alert('此浏览器不支持通知功能');
                return false;
            }
            
            const permission = await Notification.requestPermission();
            checkNotificationPermission();
            return permission === 'granted';
        }
        
        // 显示简单通知
        function showSimpleNotification() {
            if (Notification.permission === 'granted') {
                new Notification('简单通知', {
                    body: '这是一个简单的通知示例',
                    icon: '/icons/icon-192x192.png'
                });
            } else {
                alert('请先授予通知权限');
            }
        }
        
        // 显示富媒体通知
        function showRichNotification() {
            if (Notification.permission === 'granted') {
                new Notification('富媒体通知', {
                    body: '这个通知包含了图片、图标和徽章',
                    icon: '/icons/icon-192x192.png',
                    badge: '/icons/badge.png',
                    image: '/images/notification-image.jpg',
                    tag: 'rich-notification',
                    renotify: true,
                    vibrate: [200, 100, 200],
                    timestamp: Date.now(),
                    requireInteraction: true
                });
            } else {
                alert('请先授予通知权限');
            }
        }
        
        // 显示带操作按钮的通知
        function showActionNotification() {
            if (Notification.permission === 'granted') {
                navigator.serviceWorker.ready.then(registration => {
                    registration.showNotification('操作通知', {
                        body: '点击按钮进行操作',
                        icon: '/icons/icon-192x192.png',
                        actions: [
                            {
                                action: 'reply',
                                title: '回复',
                                icon: '/icons/reply.png'
                            },
                            {
                                action: 'archive',
                                title: '归档',
                                icon: '/icons/archive.png'
                            }
                        ]
                    });
                });
            } else {
                alert('请先授予通知权限');
            }
        }
        
        // 定时通知
        function scheduleNotification(delay) {
            if (Notification.permission === 'granted') {
                setTimeout(() => {
                    new Notification('定时提醒', {
                        body: `${delay / 1000}秒的定时提醒`,
                        icon: '/icons/icon-192x192.png'
                    });
                }, delay);
                
                alert(`已设置${delay / 1000}秒后的提醒`);
            } else {
                alert('请先授予通知权限');
            }
        }
        
        // 推送通知相关
        let isSubscribed = false;
        let swRegistration = null;
        
        // 初始化推送
        function initializePush() {
            if ('serviceWorker' in navigator && 'PushManager' in window) {
                navigator.serviceWorker.ready.then(registration => {
                    swRegistration = registration;
                    return registration.pushManager.getSubscription();
                }).then(subscription => {
                    isSubscribed = subscription !== null;
                    updatePushStatus();
                    
                    if (isSubscribed) {
                        console.log('已订阅推送');
                    } else {
                        console.log('未订阅推送');
                    }
                }).catch(error => {
                    console.error('初始化推送失败:', error);
                });
            }
        }
        
        // 订阅推送
        async function subscribeToPush() {
            try {
                const subscription = await swRegistration.pushManager.subscribe({
                    userVisibleOnly: true,
                    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
                });
                
                // 发送订阅信息到服务器
                const response = await fetch('/api/push-subscription', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify(subscription)
                });
                
                if (response.ok) {
                    isSubscribed = true;
                    updatePushStatus();
                    console.log('推送订阅成功');
                } else {
                    throw new Error('订阅失败');
                }
            } catch (error) {
                console.error('订阅推送失败:', error);
                alert('订阅失败,请重试');
            }
        }
        
        // 取消订阅
        async function unsubscribeFromPush() {
            try {
                const subscription = await swRegistration.pushManager.getSubscription();
                
                if (subscription) {
                    await subscription.unsubscribe();
                    
                    // 通知服务器取消订阅
                    await fetch('/api/push-subscription', {
                        method: 'DELETE',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify(subscription)
                    });
                    
                    isSubscribed = false;
                    updatePushStatus();
                    console.log('取消订阅成功');
                }
            } catch (error) {
                console.error('取消订阅失败:', error);
                alert('取消订阅失败,请重试');
            }
        }
        
        // 发送推送通知
        async function sendPushNotification() {
            if (!isSubscribed) {
                alert('请先订阅推送');
                return;
            }
            
            try {
                const response = await fetch('/api/send-notification', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({
                        title: '测试推送通知',
                        body: '这是一条测试推送通知',
                        icon: '/icons/icon-192x192.png'
                    })
                });
                
                if (response.ok) {
                    alert('推送通知已发送');
                } else {
                    throw new Error('发送失败');
                }
            } catch (error) {
                console.error('发送推送通知失败:', error);
                alert('发送失败,请重试');
            }
        }
        
        // 更新推送状态
        function updatePushStatus() {
            const statusElement = document.getElementById('pushStatus');
            statusElement.textContent = `推送状态:${isSubscribed ? '已订阅' : '未订阅'}`;
            
            document.getElementById('subscribePush').disabled = isSubscribed;
            document.getElementById('unsubscribePush').disabled = !isSubscribed;
            document.getElementById('sendPushNotification').disabled = !isSubscribed;
        }
        
        // VAPID公钥转换
        function 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;
        }
        
        // VAPID公钥(实际使用时需要替换为真实的公钥)
        const VAPID_PUBLIC_KEY = 'BFxzgSJYzOadpzjm0eKQCzYSqHZ3QTUzCzYSqHZ3QTUzCzYSqHZ3QTUzCzYSqHZ3QTUzCzYSqHZ3QTUz';
        
        // 事件绑定
        document.getElementById('requestPermission').addEventListener('click', requestNotificationPermission);
        document.getElementById('subscribePush').addEventListener('click', subscribeToPush);
        document.getElementById('unsubscribePush').addEventListener('click', unsubscribeFromPush);
        document.getElementById('sendPushNotification').addEventListener('click', sendPushNotification);
        
        // 初始化
        checkNotificationPermission();
        
        // 注册Service Worker
        if ('serviceWorker' in navigator) {
            navigator.serviceWorker.register('/sw.js').then(() => {
                initializePush();
            });
        }
    </script>
</body>
</html>

本节要点回顾

  • PWA基础:理解PWA的核心概念、特性和技术要求
  • Service Worker:掌握Service Worker的生命周期、缓存策略和网络拦截
  • Web App Manifest:学会配置应用清单实现应用安装和原生体验
  • 离线功能:实现离线数据管理、后台同步和离线页面
  • 推送通知:掌握本地通知和推送通知的实现方法
  • 性能优化:了解PWA的性能优化策略和最佳实践

相关学习资源

常见问题FAQ

Q: PWA与原生应用有什么区别?

A: PWA运行在浏览器中,具有跨平台特性,但在某些原生功能访问上可能有限制;原生应用性能更好,但开发和维护成本更高。

Q: Service Worker的缓存策略如何选择?

A: 根据资源类型选择:静态资源用Cache First,API数据用Network First,频繁更新的内容用Stale While Revalidate。

Q: 如何测试PWA的离线功能?

A: 可以使用浏览器开发者工具的Network面板模拟离线状态,或者使用Chrome DevTools的Application面板测试Service Worker。

Q: PWA的推送通知需要什么条件?

A: 需要HTTPS环境、用户授权、Service Worker注册,以及推送服务器的支持(如FCM)。

Q: 如何优化PWA的性能?

A: 使用合适的缓存策略、预缓存关键资源、实现资源懒加载、压缩资源文件,以及使用性能监控工具。


下一节预览:下一节我们将学习WebAssembly,重点介绍WebAssembly的基本概念、性能优势和实际应用场景。