PWA渐进式Web应用开发

PWA架构示意

渐进式Web应用(Progressive Web App,简称PWA)是Web技术的一次重要进化,它让网站能够提供接近原生应用的体验。离线访问、桌面安装、推送通知——这些曾经是原生应用专属的能力,现在Web应用也能实现。作为一个深度参与过多个PWA项目的开发者,我想带你全面了解这项技术,从基础概念到实战应用,让你也能打造出原生级别的Web体验。

什么是PWA?

PWA是一种使用现代Web技术构建的应用,它结合了Web和原生应用的优点:

  • 渐进式:适用于所有浏览器,渐进增强用户体验
  • 响应式:适配各种设备,手机、平板、桌面
  • 离线可用:通过Service Worker实现离线功能
  • 类原生体验:可以添加到主屏幕,全屏运行
  • 可更新:无需应用商店审核,即时更新
  • 可发现:通过搜索引擎索引,可分享URL
  • 推送通知:即使用户未打开应用也能接收消息

PWA核心组件

PWA技术架构:

┌─────────────────────────────────────────┐
│              Web Application             │
├─────────────────────────────────────────┤
│  Web App Manifest                        │
│  - 应用名称、图标、主题色                │
│  - 启动画面、显示模式                    │
├─────────────────────────────────────────┤
│  Service Worker                          │
│  - 离线缓存                              │
│  - 推送通知                              │
│  - 后台同步                              │
├─────────────────────────────────────────┤
│  HTTPS                                   │
│  - 安全传输                              │
│  - Service Worker必需                    │
└─────────────────────────────────────────┘

Web App Manifest

Web App Manifest是一个JSON文件,描述应用的元数据。它让浏览器知道如何将网站作为应用安装。

基本配置

// manifest.json
{
  "name": "HEPH工具箱",
  "short_name": "HEPH",
  "description": "魔法网站开发工具箱",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0a0a12",
  "theme_color": "#8b5cf6",
  "orientation": "any",
  "scope": "/",
  "lang": "zh-CN",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "categories": ["utilities", "productivity"],
  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "1280x720",
      "type": "image/png"
    }
  ],
  "shortcuts": [
    {
      "name": "JSON格式化",
      "short_name": "JSON",
      "url": "/tools/json-formatter.html",
      "icons": [{ "src": "/icons/json.png", "sizes": "96x96" }]
    }
  ]
}

在HTML中引用

<head>
  <link rel="manifest" href="/manifest.json">
  <meta name="theme-color" content="#8b5cf6">
  <!-- iOS支持 -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <meta name="apple-mobile-web-app-title" content="HEPH">
  <link rel="apple-touch-icon" href="/icons/icon-192.png">
</head>

显示模式

  • fullscreen:全屏模式,无浏览器UI
  • standalone:独立应用窗口,推荐使用
  • minimal-ui:保留最小浏览器UI
  • browser:常规浏览器标签页

Service Worker基础

Service Worker生命周期

Service Worker是PWA的核心,它是一个在浏览器后台运行的脚本,独立于Web页面,可以拦截网络请求、管理缓存。

注册Service Worker

// main.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/'
      });
      
      console.log('Service Worker 注册成功:', registration.scope);
      
      // 检查更新
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            // 新版本可用,提示用户刷新
            console.log('新版本可用,请刷新页面');
          }
        });
      });
    } catch (error) {
      console.error('Service Worker 注册失败:', error);
    }
  });
}

Service Worker生命周期

// sw.js - Service Worker生命周期事件

// 安装事件 - 预缓存关键资源
const CACHE_NAME = 'heph-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/css/style.css',
  '/js/main.js',
  '/offline.html'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(STATIC_ASSETS))
      .then(() => self.skipWaiting()) // 立即激活
  );
});

// 激活事件 - 清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys()
      .then(keys => Promise.all(
        keys.filter(key => key !== CACHE_NAME)
          .map(key => caches.delete(key))
      ))
      .then(() => self.clients.claim()) // 立即控制页面
  );
});

// 请求拦截
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(response => response || fetch(event.request))
      .catch(() => caches.match('/offline.html'))
  );
});

缓存策略

不同的资源类型需要不同的缓存策略:

缓存优先(Cache First)

适用于静态资源,优先使用缓存,缓存不存在时才请求网络:

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  
  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
  }
  return response;
}

网络优先(Network First)

适用于动态内容,优先使用网络,网络失败时使用缓存:

async function networkFirst(request) {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch (error) {
    const cached = await caches.match(request);
    return cached || caches.match('/offline.html');
  }
}

网络优先+缓存更新

快速响应的同时后台更新缓存:

async function staleWhileRevalidate(request) {
  const cached = await caches.match(request);
  
  const fetchPromise = fetch(request).then(response => {
    if (response.ok) {
      caches.open(CACHE_NAME).then(cache => {
        cache.put(request, response.clone());
      });
    }
    return response;
  });
  
  // 立即返回缓存,同时后台更新
  return cached || fetchPromise;
}

综合缓存策略

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);
  
  // API请求:网络优先
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }
  
  // 静态资源:缓存优先
  if (isStaticAsset(url.pathname)) {
    event.respondWith(cacheFirst(request));
    return;
  }
  
  // HTML页面:网络优先+离线回退
  if (request.headers.get('accept').includes('text/html')) {
    event.respondWith(networkFirst(request));
    return;
  }
  
  // 其他:stale-while-revalidate
  event.respondWith(staleWhileRevalidate(request));
});

function isStaticAsset(pathname) {
  return /\.(js|css|png|jpg|jpeg|gif|svg|woff2?)$/.test(pathname);
}

离线功能实现

离线页面

<!-- offline.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>离线 - HEPH</title>
  <style>
    body {
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      margin: 0;
      background: #0a0a12;
      color: #e8e8f0;
      font-family: sans-serif;
      text-align: center;
    }
    .offline-icon { font-size: 4rem; margin-bottom: 1rem; }
  </style>
</head>
<body>
  <div>
    <div class="offline-icon">📡</div>
    <h1>您当前处于离线状态</h1>
    <p>请检查网络连接后重试</p>
    <button onclick="location.reload()">重试</button>
  </div>
</body>
</html>

后台同步

// 注册后台同步
async function syncData() {
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('sync-data');
}

// Service Worker处理同步
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-data') {
    event.waitUntil(
      // 从IndexedDB获取待同步数据
      getPendingData().then(data => 
        // 发送到服务器
        fetch('/api/sync', {
          method: 'POST',
          body: JSON.stringify(data)
        })
      )
    );
  }
});

推送通知

订阅推送

async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;
  
  // 请求通知权限
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    throw new Error('通知权限被拒绝');
  }
  
  // 订阅推送服务
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
  });
  
  // 发送订阅信息到服务器
  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription)
  });
  
  return subscription;
}

// VAPID密钥转换
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = atob(base64);
  return new Uint8Array([...rawData].map(char => char.charCodeAt(0)));
}

处理推送事件

// sw.js
self.addEventListener('push', (event) => {
  const data = event.data.json();
  
  const options = {
    body: data.body,
    icon: '/icons/icon-192.png',
    badge: '/icons/badge.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url
    },
    actions: [
      { action: 'open', title: '查看' },
      { action: 'close', title: '关闭' }
    ]
  };
  
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// 处理通知点击
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  
  if (event.action === 'open' || !event.action) {
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});

安装提示

自定义安装提示

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (event) => {
  // 阻止默认提示
  event.preventDefault();
  deferredPrompt = event;
  
  // 显示自定义安装按钮
  showInstallButton();
});

async function installApp() {
  if (!deferredPrompt) return;
  
  // 显示安装提示
  deferredPrompt.prompt();
  
  // 等待用户响应
  const { outcome } = await deferredPrompt.userChoice;
  
  if (outcome === 'accepted') {
    console.log('用户同意安装');
  }
  
  deferredPrompt = null;
}

// 安装成功事件
window.addEventListener('appinstalled', () => {
  console.log('应用安装成功');
  hideInstallButton();
});

数据存储

IndexedDB封装

// 使用IndexedDB存储离线数据
class OfflineDB {
  constructor() {
    this.dbName = 'heph-offline';
    this.version = 1;
  }
  
  async open() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);
      
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        if (!db.objectStoreNames.contains('pending')) {
          db.createObjectStore('pending', { keyPath: 'id', autoIncrement: true });
        }
      };
      
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
  
  async add(store, data) {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction(store, 'readwrite');
      const objectStore = transaction.objectStore(store);
      const request = objectStore.add(data);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
  
  async getAll(store) {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction(store, 'readonly');
      const objectStore = transaction.objectStore(store);
      const request = objectStore.getAll();
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

const offlineDB = new OfflineDB();

最佳实践

  1. 渐进增强:核心功能在无Service Worker时也能工作
  2. 版本化缓存:使用版本号管理缓存,便于更新
  3. 优雅降级:提供离线页面和错误处理
  4. 合理缓存:不要缓存敏感数据或过大文件
  5. 通知权限:在合适时机请求,不要一打开就弹
  6. 测试离线:使用Chrome DevTools模拟离线场景
  7. 性能监控:追踪Service Worker安装和缓存命中率

总结

PWA让Web应用获得了前所未有的能力——离线访问、桌面安装、推送通知,这些曾经是原生应用的专利。通过Service Worker和Web App Manifest,我们可以打造出体验接近原生的Web应用。

但PWA不是万能的。在某些场景下,原生应用仍然有优势(如深度系统集成、后台任务)。选择技术方案时,需要根据项目需求和目标用户做出权衡。希望这篇指南能帮助你理解PWA的核心概念,在实际项目中做出明智的选择。