PWA 渐进式 Web 应用

PWA(Progressive Web App)让 Web 应用具备类似原生 App 的能力:离线访问、桌面图标、推送通知、后台同步。在移动端,iOS 从 11.3 开始支持 Service Worker。

PWA 核心技术栈

技术作用兼容性
Service Worker 离线缓存、后台同步、推送通知 iOS 11.3+, Android Chrome 全支持
Web App Manifest 添加到桌面、图标、启动画面、全屏模式 Android Chrome 完整支持,iOS Safari 部分支持
HTTPS PWA 强制要求 Service Worker 只能在 HTTPS 或 localhost 下注册
Cache API 编程式缓存管理 与 Service Worker 兼容性相同
IndexedDB 结构化离线数据存储 iOS 8+, Android 全支持

1. Web App Manifest(添加到桌面)

<!-- ===== manifest.json(放在项目根目录)===== -->
{
  "name": "我的应用",
  "short_name": "MyApp",
  "description": "一个移动端 Web 应用",
  "start_url": "/?utm_source=pwa",
  "scope": "/",
  "display": "standalone",       // standalone: 隐藏浏览器UI
                                  // fullscreen: 完全全屏
                                  // minimal-ui: 最小浏览器UI
                                  // browser: 普通浏览器
  "orientation": "portrait",     // 锁定屏幕方向
  "background_color": "#ffffff",
  "theme_color": "#4A90D9",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"   // maskable: 适配 Android 自适应图标
    }
  ],
  "screenshots": [               // 应用商店展示图
    {
      "src": "/screenshots/home.png",
      "sizes": "750x1334",
      "type": "image/png"
    }
  ],
  "categories": ["utilities"],
  "lang": "zh-CN"
}

<!-- HTML 中引入 -->
<link rel="manifest" href="/manifest.json">

<!-- iOS 特殊 meta 标签(iOS 不读取 manifest.json) -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="我的应用">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png">
<link rel="apple-touch-startup-image" href="/splash/splash-750x1334.png">

2. Service Worker(离线缓存)

// ===== sw.js(Service Worker 文件)=====

const CACHE_NAME = 'my-app-v1';
const urlsToCache = [
  '/',
  '/css/style.css',
  '/js/main.js',
  '/offline.html',        // 离线兜底页
];

// ① install: 预缓存关键资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(urlsToCache);
    })
  );
  // 立即激活,不等待旧 SW 释放
  self.skipWaiting();
});

// ② activate: 清理旧版本缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
  // 立即接管所有页面
  self.clients.claim();
});

// ③ fetch: 缓存策略 - 网络优先,缓存兜底
self.addEventListener('fetch', (event) => {
  // 只处理 GET 请求
  if (event.request.method !== 'GET') return;

  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // 网络成功:缓存响应副本
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => {
        // 网络失败:返回缓存
        return caches.match(event.request).then((cachedResponse) => {
          return cachedResponse || caches.match('/offline.html');
        });
      })
  );
});

// ===== 注册 Service Worker(在主 JS 中)=====
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js').then((registration) => {
      console.log('SW 注册成功:', registration.scope);

      // 监听更新
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            // 有新版本可用,提示用户刷新
            if (confirm('有新版本可用,是否刷新?')) {
              window.location.reload();
            }
          }
        });
      });
    }).catch((err) => {
      console.error('SW 注册失败:', err);
    });
  });
}

3. 缓存策略对比

策略模式适用场景
Cache First(缓存优先) 先查缓存,未命中再网络 静态资源(CSS/JS/图片/字体),版本号控制更新
Network First(网络优先) 先请求网络,失败用缓存 API 数据、需要实时性的内容
Stale While Revalidate 返回缓存,后台更新 不要求即时性的内容,用户体验最好
Network Only 仅网络请求 不需要缓存的请求(如上报、统计)
Cache Only 仅从缓存获取 离线应用的纯本地资源

Stale While Revalidate 策略实现

// ===== 最佳用户体验策略:先返回缓存,后台更新 =====
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cachedResponse) => {
        // 后台发起网络请求更新缓存
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });

        // 立即返回缓存(如果有),否则等网络
        return cachedResponse || fetchPromise;
      });
    })
  );
});

4. iOS 添加到桌面引导

// ===== iOS Safari 添加到桌面引导 =====
// iOS 不支持自动弹出安装提示,需要手动引导用户

function showInstallGuide() {
  const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
  const isStandalone = window.navigator.standalone; // iOS 已添加到桌面

  if (isIOS && !isStandalone) {
    // 显示引导浮层
    const guide = document.createElement('div');
    guide.className = 'ios-install-guide';
    guide.innerHTML = `
      <div class="guide-content">
        <p>点击下方 <strong>分享按钮</strong></p>
        <p>选择 <strong>添加到主屏幕</strong></p>
        <span class="guide-arrow">↓</span>
        <button onclick="this.parentElement.parentElement.remove()">知道了</button>
      </div>
    `;
    document.body.appendChild(guide);
  }
}

// CSS 样式
.ios-install-guide {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  background: rgba(0,0,0,0.85);
  color: #fff;
  padding: 16px 24px;
  border-radius: 12px;
  z-index: 9999;
  text-align: center;
}
.guide-arrow {
  font-size: 24px;
  display: block;
  animation: bounce 1s infinite;
}
@keyframes bounce {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(8px); }
}

5. PWA 离线检测与提示

// ===== 网络状态监听 =====
function initNetworkListener() {
  const updateOnlineStatus = () => {
    const isOnline = navigator.onLine;
    const toast = document.getElementById('network-toast');

    if (!isOnline) {
      toast.textContent = '📡 当前处于离线状态,部分功能可能不可用';
      toast.classList.add('show');
    } else {
      toast.textContent = '✅ 网络已恢复';
      toast.classList.add('show');
      setTimeout(() => toast.classList.remove('show'), 2000);
    }
  };

  window.addEventListener('online', updateOnlineStatus);
  window.addEventListener('offline', updateOnlineStatus);
}

// HTML 结构
<div id="network-toast" style="
  position:fixed; top:0; left:0; right:0;
  background:#ff6b6b; color:#fff; text-align:center;
  padding:8px; transform:translateY(-100%);
  transition:transform 0.3s; z-index:9999;
"></div>
<style>
#network-toast.show { transform: translateY(0); }
</style>
💡 PWA 移动端实践建议
  • iOS 限制:iOS 的 Service Worker 缓存上限约 50MB,超出会被系统清理
  • 缓存策略分层:HTML 用 Network First,CSS/JS/字体用 Cache First(带版本号),API 用 Stale While Revalidate
  • App 壳方案:PWA 在 iOS 体验不如 Android,可考虑 Trusted Web Activity(TWA)上架 Google Play
  • 渐进增强:先确保无 SW 时页面正常工作,PWA 能力作为增强项
  • 推送通知:iOS 16.4+ 才开始支持 Web Push,Android 完整支持
  • Workbox:生产环境推荐使用 Google Workbox 库管理 SW,避免手动处理缓存细节