渐进式网页应用 progressive-web-app

渐进式网页应用是一种结合了网页和移动应用特性的技术,它支持离线工作、可被安装到主屏幕、并通过推送通知与用户互动。关键词包括:离线支持、安装性、推送通知、服务工作线程、Web应用清单。

前端开发 0 次安装 0 次浏览 更新于 3/4/2026

渐进式网页应用

概述

使用服务工作线程、Web 应用清单、离线支持和可安装性构建渐进式网页应用,以在浏览器中提供类似应用的体验。

使用场景

  • 类似应用的网页体验
  • 需要离线功能
  • 移动设备安装要求
  • 推送通知
  • 快速加载体验

实施示例

1. Web 应用清单

// public/manifest.json
{
  "name": "我的超棒应用",
  "short_name": "AwesomeApp",
  "description": "一个渐进式网页应用",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "background_color": "#ffffff",
  "theme_color": "#007bff",
  "icons": [
    {
      "src": "/images/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/images/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/images/icon-maskable-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable"
    },
    {
      "src": "/images/icon-maskable-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/images/screenshot-1.png",
      "sizes": "540x720",
      "type": "image/png",
      "form_factor": "narrow"
    },
    {
      "src": "/images/screenshot-2.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    }
  ],
  "categories": ["productivity", "utilities"],
  "shortcuts": [
    {
      "name": "快速笔记",
      "short_name": "Note",
      "description": "创建一个快速笔记",
      "url": "/new-note",
      "icons": [
        {
          "src": "/images/note-icon.png",
          "sizes": "192x192"
        }
      ]
    }
  ]
}
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="theme-color" content="#007bff">
  <link rel="manifest" href="/manifest.json">
  <link rel="icon" href="/favicon.ico">
  <link rel="apple-touch-icon" href="/images/icon-192.png">
  <title>我的超棒应用</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

2. 服务工作线程实现

// public/service-worker.ts
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/css/main.css',
  '/js/app.js',
  '/images/icon-192.png',
  '/offline.html'
];

// 安装事件
self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => {
      return cache.addAll(STATIC_ASSETS);
    })
  );
  self.skipWaiting();
});

// 激活事件
self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames
          .filter(name => name !== CACHE_NAME)
          .map(name => caches.delete(name))
      );
    })
  );
  self.clients.claim();
});

// 使用缓存优先策略处理静态资源的获取事件
self.addEventListener('fetch', (event: FetchEvent) => {
  const { request } = event;

  // 跳过非GET请求
  if (request.method !== 'GET') {
    return;
  }

  // 静态资源缓存优先
  if (request.destination === 'image' || request.destination === 'font') {
    event.respondWith(
      caches.match(request).then(response => {
        return response || fetch(request).then(res => {
          if (res.ok) {
            const clone = res.clone();
            caches.open(CACHE_NAME).then(cache => {
              cache.put(request, clone);
            });
          }
          return res;
        });
      }).catch(() => {
        return caches.match('/offline.html');
      })
    );
  }

  // API调用网络优先
  if (request.url.includes('/api/')) {
    event.respondWith(
      fetch(request)
        .then(response => {
          if (response.ok) {
            const clone = response.clone();
            caches.open(CACHE_NAME).then(cache => {
              cache.put(request, clone);
            });
          }
          return response;
        })
        .catch(() => {
          return caches.match(request);
        })
    );
  }

  // HTML缓存失效重验证
  if (request.destination === 'document') {
    event.respondWith(
      caches.match(request).then(cachedResponse => {
        const fetchPromise = fetch(request).then(response => {
          if (response.ok) {
            caches.open(CACHE_NAME).then(cache => {
              cache.put(request, response.clone());
            });
          }
          return response;
        });

        return cachedResponse || fetchPromise;
      })
    );
  }
});

// 后台同步
self.addEventListener('sync', (event: any) => {
  if (event.tag === 'sync-notes') {
    event.waitUntil(syncNotes());
  }
});

async function syncNotes() {
  const db = await openDB('notes');
  const unsynced = await db.getAll('keyval', IDBKeyRange.bound('pending_', 'pending_\uffff'));

  for (const item of unsynced) {
    try {
      await fetch('/api/notes', {
        method: 'POST',
        body: JSON.stringify(item.value)
      });
      await db.delete('keyval', item.key);
    } catch (error) {
      console.error('同步失败:', error);
    }
  }
}

3. 安装提示和应用安装

// hooks/useInstallPrompt.ts
import { useState, useEffect } from 'react';

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

export const useInstallPrompt = () => {
  const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
  const [isInstalled, setIsInstalled] = useState(false);
  const [isIOSInstalled, setIsIOSInstalled] = useState(false);

  useEffect(() => {
    const handleBeforeInstallPrompt = (e: Event) => {
      e.preventDefault();
      setPromptEvent(e as BeforeInstallPromptEvent);
    };

    const handleAppInstalled = () => {
      setIsInstalled(true);
      setPromptEvent(null);
    };

    window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
    window.addEventListener('appinstalled', handleAppInstalled);

    // 检查是否作为安装应用运行
    if (window.matchMedia('(display-mode: standalone)').matches) {
      setIsInstalled(true);
    }

    // 检查iOS
    const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent);
    const isIOSApp = navigator.standalone === true;
    if (isIOSDevice && !isIOSApp) {
      setIsIOSInstalled(false);
    }

    return () => {
      window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
      window.removeEventListener('appinstalled', handleAppInstalled);
    };
  }, []);

  const installApp = async () => {
    if (promptEvent) {
      await promptEvent.prompt();
      const { outcome } = await promptEvent.userChoice;
      if (outcome === 'accepted') {
        setIsInstalled(true);
      }
      setPromptEvent(null);
    }
  };

  return {
    promptEvent,
    canInstall: promptEvent !== null,
    isInstalled,
    isIOSInstalled,
    installApp
  };
};

// components/InstallPrompt.tsx
export const InstallPrompt: React.FC = () => {
  const { canInstall, isInstalled, installApp } = useInstallPrompt();

  if (isInstalled || !canInstall) return null;

  return (
    <div className="install-prompt">
      <h2>安装应用</h2>
      <p>安装我们的应用以快速访问和离线支持</p>
      <button onClick={installApp}>安装</button>
    </div>
  );
};

4. 使用IndexedDB的离线支持

// db/notesDB.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';

interface Note {
  id: string;
  title: string;
  content: string;
  timestamp: number;
  synced: boolean;
}

interface NotesDB extends DBSchema {
  notes: {
    key: string;
    value: Note;
    indexes: { 'by-timestamp': number; 'by-synced': boolean };
  };
}

let db: IDBPDatabase<NotesDB>;

export async function initDB() {
  db = await openDB<NotesDB>('notes-db', 1, {
    upgrade(db) {
      const store = db.createObjectStore('notes', { keyPath: 'id' });
      store.createIndex('by-timestamp', 'timestamp');
      store.createIndex('by-synced', 'synced');
    }
  });
  return db;
}

export async function addNote(note: Omit<Note, 'timestamp'>) {
  return db.add('notes', {
    ...note,
    timestamp: Date.now(),
    synced: false
  });
}

export async function getNotes(): Promise<Note[]> {
  return db.getAll('notes');
}

export async function getUnsyncedNotes(): Promise<Note[]> {
  return db.getAllFromIndex('notes', 'by-synced', false);
}

export async function updateNote(id: string, updates: Partial<Note>) {
  const note = await db.get('notes', id);
  if (note) {
    await db.put('notes', { ...note, ...updates });
  }
}

export async function markAsSynced(id: string) {
  await updateNote(id, { synced: true });
}

5. 推送通知

// services/pushNotification.ts
export async function subscribeToPushNotifications() {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    console.log('推送通知不支持');
    return;
  }

  try {
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: process.env.REACT_APP_VAPID_PUBLIC_KEY
    });

    // 发送订阅到服务器
    await fetch('/api/push-subscription', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(subscription)
    });

    return subscription;
  } catch (error) {
    console.error('推送订阅失败:', error);
  }
}

// service-worker.ts
self.addEventListener('push', (event: PushEvent) => {
  const data = event.data?.json() ?? {};
  const options: NotificationOptions = {
    title: data.title || '新通知',
    body: data.message || '',
    icon: '/images/icon-192.png',
    badge: '/images/badge-72.png',
    tag: data.tag || 'notification'
  };

  event.waitUntil(
    self.registration.showNotification(options.title, options)
  );
});

self.addEventListener('notificationclick', (event: NotificationEvent) => {
  event.notification.close();
  event.waitUntil(
    self.clients.matchAll({ type: 'window' }).then(clients => {
      if (clients.length > 0) {
        return clients[0].focus();
      }
      return self.clients.openWindow('/');
    })
  );
});

最佳实践

  • 实现服务工作线程以支持离线功能
  • 创建全面的Web应用清单
  • 使用适合内容类型的缓存策略
  • 提供离线回退页面
  • 在各种网络条件下进行测试
  • 针对慢速3G网络进行优化
  • 包括安装提示
  • 使用IndexedDB进行本地存储
  • 监控同步状态和连接性
  • 优雅地处理更新通知

资源