CC 咖啡猫的工作空间 Coding Space

PWA — Progressive Web App

1. 核心概念

三大支柱

支柱 作用 必要条件
Service Worker 网络代理、离线缓存、后台同步 HTTPS(或 localhost)
Web App Manifest 应用元数据、安装能力 JSON 配置文件
HTTPS 安全传输、SW 注册前提 生产环境必须 TLS

PWA 能力层级

基础层 ─── 离线缓存(Cache API) + 可安装(Manifest)
   ↓
进阶层 ─── 推送通知(Push API) + 后台同步(Background Sync)
   ↓
高级层 ─── 文件系统(File System Access)、Web Bluetooth、Web USB、WebAssembly

浏览器兼容性现状

能力 Chrome/Edge Safari/iOS Safari Firefox
Service Worker 完整支持 支持(有限调试) 支持
Manifest + 安装 完整支持 display: standalone 受限 部分支持
Push Notification 完整支持 不支持 支持
Background Sync 支持 不支持 不支持
Periodic Sync 支持(需 flags) 不支持 不支持
Cache API 完整支持 支持(配额严苛) 支持
IndexedDB 完整支持 支持(50MB 限额提示) 支持

2. Service Worker(重点)

生命周期

register ──→ install ──→ waiting ──→ activate ──→ fetch/idle ──→ redundant
                 │                       │
            pre-cache             清理旧缓存
           (waitUntil)          (clients.claim)

事件详解

install — 预缓存

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('my-app-v1').then((cache) => {
      return cache.addAll([
        '/',
        '/index.html',
        '/styles/main.css',
        '/scripts/app.js',
      ]);
    })
  );
});
  • waitUntil 保证缓存完成前 SW 不会进入 activated
  • 失败则本次安装作废,下次更新继续

activate — 清理旧缓存 + 立即接管

self.addEventListener('activate', (event) => {
  const cacheWhitelist = ['my-app-v2'];

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((name) => {
          if (!cacheWhitelist.includes(name)) return caches.delete(name);
        })
      );
    }).then(() => clients.claim()) // 立即控制所有客户端
  );
});
  • clients.claim() 让新 SW 立即接管所有页面,无需等待页面刷新
  • activate 中清理旧缓存是最佳实践

fetch — 拦截请求 + 缓存策略

self.addEventListener('fetch', (event) => {
  event.respondWith(
    // 各种缓存策略见第 3 节
  );
});

push — 推送通知

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? { title: '新消息', body: '' };

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icon-192.png',
      badge: '/badge-72.png',
      data: { url: data.url },
    })
  );
});

sync — 后台同步

self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-messages') {
    event.waitUntil(syncPendingMessages());
  }
});

Scope 与注册

// scope 默认为 SW 文件所在目录(或子目录)
navigator.serviceWorker.register('/sw.js', { scope: '/' });
// 若 SW 在 /scripts/sw.js,默认 scope = /scripts/
// 要扩大 scope,需要服务端响应头:
// Service-Worker-Allowed: /
路径 注册 scope 可控制的页面
/sw.js / /, /about/, /blog/
/scripts/sw.js /scripts/ /scripts/**
/scripts/sw.js + Service-Worker-Allowed: / / 全部

更新机制

// 每次导航自动检查更新,最多 24h 一次
// 手动触发:
navigator.serviceWorker.register('/sw.js').then((reg) => {
  reg.update(); // 强制检查更新
});

// 检测到新版本,立即激活
self.addEventListener('install', () => {
  self.skipWaiting(); // 跳过 waiting,直接进入 activate
});
  • 浏览器每 24h 自动检查一次 SW 更新(字节对比)
  • 新 SW 默认进入 waiting 状态,等老页面全部关闭后接管
  • skipWaiting() + clients.claim() 组合拳实现无缝升级

调试工具

工具 用途
Chrome DevTools > Application > Service Workers 手动更新 / unregister / skipWaiting / 模拟离线 / 查看缓存
chrome://serviceworker-internals 查看所有 SW 状态
chrome://inspect/#service-workers 远程调试 SW 上下文

3. 缓存策略

六大策略

1. Cache Only

event.respondWith(caches.match(event.request));
  • 时机:从不请求网络
  • 场景:静态资源(已预缓存的内容)

2. Network Only

event.respondWith(fetch(event.request));
  • 时机:不存缓存
  • 场景:数据面板、实时信息

3. Cache First(离线优先)

event.respondWith(
  caches.match(event.request).then((response) => {
    return response || fetch(event.request).then((res) => {
      return caches.open('dynamic-v1').then((cache) => {
        cache.put(event.request, res.clone());
        return res;
      });
    });
  })
);
  • 时机:优先命中缓存,缓存未命中则回退网络并写入缓存
  • 场景:不变的静态资源(字体、CSS、图片)

4. Network First(网络优先)

event.respondWith(
  fetch(event.request).then((res) => {
    return caches.open('dynamic-v1').then((cache) => {
      cache.put(event.request, res.clone());
      return res;
    });
  }).catch(() => caches.match(event.request))
);
  • 时机:优先请求网络,失败回退缓存
  • 场景:需要最新数据的 API、新闻页

5. Stale-While-Revalidate

event.respondWith(
  caches.match(event.request).then((cached) => {
    const fetchPromise = fetch(event.request).then((res) => {
      return caches.open('dynamic-v1').then((cache) => {
        cache.put(event.request, res.clone());
        return res;
      });
    });
    return cached || fetchPromise;
  })
);
  • 时机:立即展示缓存,后台静默更新
  • 场景:头像、UI 资源、用户资料(可接受短暂陈旧)

6. Cache & Network Race

event.respondWith(
  new Promise((resolve) => {
    let resolved = false;
    const settle = (response) => {
      if (!resolved) { resolved = true; resolve(response); }
    };
    caches.match(event.request).then(settle);
    fetch(event.request).then((res) => {
      caches.open('dynamic-v1').then((cache) => cache.put(event.request, res.clone()));
      settle(res);
    }).catch(() => {});
  })
);
  • 时机:缓存和网络同时抢答,取先返回的
  • 场景:低延迟优先的场景(竞速)

预缓存 vs 运行时缓存

预缓存(precaching) 运行时缓存(runtime caching)
时机 install 阶段 fetch 阶段
内容 App Shell(HTML/CSS/JS/Logo) API 响应、图片
目的 保证离线基础可用 提高重复访问性能
管理 版本化 + 批量更新 动态 + LRU 淘汰

缓存版本管理

const CACHE_NAME = 'my-app-v2';

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((n) => n !== CACHE_NAME)
          .map((n) => caches.delete(n))
      )
    )
  );
});
  • 每次修改 SW 逻辑或资源列表,更新 CACHE_NAME 版本号
  • activate 中遍历所有缓存,删除非当前版本

缓存空间限制

浏览器 配额规则
Chrome ~60% 磁盘可用空间(单 origin,全局共享)
Safari ~1GB(iOS 更严,7 天不交互可能清空)
Firefox ~50% 磁盘可用空间
navigator.storage.estimate().then(({ usage, quota }) => {
  console.log(`已用: ${(usage / 1024 / 1024).toFixed(1)}MB / 配额: ${(quota / 1024 / 1024).toFixed(1)}MB`);
});

4. Workbox(Google 官方工具)

为什么用 Workbox

  • 封装 SW 生命周期,避免手写 install / activate / fetch 模板代码
  • 预缓存清单自动生成,配合构建工具实现哈希指纹
  • 内置 6 种缓存策略和路由匹配
  • 处理了边界情况(重定向、opaque response、跨域请求)

核心模块

模块 功能
workbox-precaching 预缓存管理,自动生成 manifest
workbox-routing 路由注册,URL 匹配规则
workbox-strategies 6 种缓存策略(StaleWhileRevalidate / NetworkFirst / CacheFirst / NetworkOnly / CacheOnly)
workbox-expiration 缓存条目数 / 时间限制
workbox-cacheable-response 基于状态码 / header 限制哪些响应可缓存
workbox-background-sync 离线请求队列,恢复网络自动重放
workbox-google-analytics 离线 GA 事件回放

示例配置

// sw.js — 手动模式
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');

workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);

workbox.routing.registerRoute(
  /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
  new workbox.strategies.CacheFirst({
    cacheName: 'images',
    plugins: [
      new workbox.expiration.ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 }),
      new workbox.cacheableResponse.CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
  })
);

workbox.routing.registerRoute(
  /\/api\/.*/,
  new workbox.strategies.NetworkFirst({ cacheName: 'api-cache' })
);

workbox.routing.registerRoute(
  ({ request }) => request.destination === 'document',
  new workbox.strategies.StaleWhileRevalidate({ cacheName: 'documents' })
);

构建集成

构建工具 集成方式
Webpack workbox-webpack-pluginGenerateSW(自动生成 SW)/ InjectManifest(注入 manifest,自定义 SW)
Vite vite-plugin-pwa(基于 Workbox,零配置可用)
CLI workbox-cli 生成 SW + 构建配置

Vite 集成

// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico'],
      manifest: { /* ... */ },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        runtimeCaching: [
          {
            urlPattern: /\/api\//,
            handler: 'NetworkFirst',
            options: { cacheName: 'api-cache' },
          },
        ],
      },
    }),
  ],
});

Webpack 集成(GenerateSW)

// webpack.config.js
const { GenerateSW } = require('workbox-webpack-plugin');

module.exports = {
  plugins: [
    new GenerateSW({
      clientsClaim: true,
      skipWaiting: true,
      runtimeCaching: [
        {
          urlPattern: /\.(?:png|jpg|jpeg|svg)$/,
          handler: 'CacheFirst',
          options: { cacheName: 'images', expiration: { maxEntries: 60 } },
        },
      ],
    }),
  ],
};

Recipes

SPA 缓存方案

// App Shell (HTML) — NetworkFirst 或 CacheFirst + 网络回退
workbox.routing.registerRoute(
  ({ request }) => request.mode === 'navigate',
  new workbox.strategies.NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new workbox.expiration.ExpirationPlugin({ maxAgeSeconds: 5 * 60 }), // 5 分钟
    ],
  })
);

// API — NetworkFirst
workbox.routing.registerRoute(
  /\/api\/.*/,
  new workbox.strategies.NetworkFirst({ cacheName: 'api' })
);

Google Fonts 缓存

workbox.routing.registerRoute(
  /^https:\/\/fonts\.googleapis\.com\/.*/i,
  new workbox.strategies.StaleWhileRevalidate({ cacheName: 'google-fonts' })
);

workbox.routing.registerRoute(
  /^https:\/\/fonts\.gstatic\.com\/.*/i,
  new workbox.strategies.CacheFirst({
    cacheName: 'google-fonts',
    plugins: [
      new workbox.expiration.ExpirationPlugin({ maxAgeSeconds: 365 * 24 * 60 * 60 }),
      new workbox.cacheableResponse.CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
  })
);

5. Web App Manifest

核心字段

{
  "name": "我的应用",
  "short_name": "我的应用",
  "description": "这是一个 PWA 示例",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "orientation": "portrait-primary",
  "theme_color": "#4285f4",
  "background_color": "#ffffff",
  "scope": "/",
  "icons": [
    {
      "src": "/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "categories": ["productivity"],
  "lang": "zh-CN"
}
字段 说明 必填
name 应用全名,安装提示显示 推荐
short_name 桌面图标下方名称,12 字符以内 推荐
icons 至少包含 192x192 和 512x512
start_url 启动时打开的 URL 推荐
display standalone / fullscreen / minimal-ui / browser
theme_color 地址栏 / 任务切换器颜色 推荐
background_color Splash Screen 背景色 推荐
scope 限制哪些 URL 属于应用范围 推荐
orientation portrait-primary / landscape 选填

install 安装提示 (beforeinstallprompt)

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  // 阻止浏览器默认安装栏
  e.preventDefault();
  deferredPrompt = e;

  // 显示自定义安装按钮
  installBtn.style.display = 'block';

  installBtn.addEventListener('click', async () => {
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    console.log(outcome); // 'accepted' | 'dismissed'
    deferredPrompt = null;
    installBtn.style.display = 'none';
  });
});

// 监听安装完成
window.addEventListener('appinstalled', () => {
  console.log('PWA 已安装');
});

iOS Safari 特殊适配

<!-- iOS 专用 meta 标签 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/icon-192.png">
<link rel="apple-touch-startup-image" href="/splash.png">

PWA 安装条件(Chrome)

  1. 页面通过 HTTPS 提供
  2. 存在有效的 Web App Manifest(含 name / icons / start_url / display
  3. 已注册 Service Worker(含有效的 fetch handler)
  4. 用户有足够的交互(Chrome 自动判断,非自定义)

6. 推送通知

架构

浏览器 ←→ Push Service(Chrome → FCM, Firefox → Autopush, Edge → WNS)
  ↑
Service Worker ←→ 后端推送

订阅流程

// 请求通知权限
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;

// 订阅推送
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});

// 发送订阅信息到后端
await fetch('/api/push/subscribe', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(subscription),
});

subscription 返回对象结构:

{
  "endpoint": "https://fcm.googleapis.com/...",
  "keys": {
    "p256dh": "BOr...",
    "auth": "aBc..."
  }
}

显示通知

self.registration.showNotification(title, {
  body: '通知正文',
  icon: '/icon-192.png',
  badge: '/badge-72.png',
  image: '/notification-image.png',       // 大图
  vibrate: [200, 100, 200],               // 振动模式
  tag: 'message-group',                   // 相同 tag 自动折叠
  renotify: true,                         // 同 tag 重复通知是否提醒
  data: { url: '/messages' },             // 携带数据
  actions: [                              // 通知按钮
    { action: 'reply', title: '回复', icon: '/reply.png' },
    { action: 'dismiss', title: '忽略' },
  ],
  silent: false,
  requireInteraction: true,               // 不自动消失
});

通知交互

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const url = event.notification.data?.url ?? '/';

  if (event.action === 'reply') {
    // 打开特定回复界面
    clients.openWindow('/messages/reply');
    return;
  }

  // 检查是否已有窗口打开,有则 focus,否则新开
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((windowClients) => {
      const target = windowClients.find((c) => c.url === url);
      if (target) return target.focus();
      return clients.openWindow(url);
    })
  );
});

权限管理

// 检测权限状态
const state = Notification.permission;
// 'granted' | 'denied' | 'default'

// 用户拒绝后的处理策略
if (state === 'denied') {
  showPermissionGuide(); // 提示用户去设置中手动开启
}

// 再次请求(仅当状态为 default 时可触发弹窗)
if (state === 'default') {
  Notification.requestPermission();
}

7. 离线与后台能力

Background Sync

// 页面注册同步任务
document.getElementById('sendBtn').addEventListener('click', async () => {
  await saveToIndexedDB({ id: Date.now(), text: '待发送消息' });
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('sync-messages');
});

// SW 处理同步
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-messages') {
    event.waitUntil(syncPendingToServer());
  }
});
特性 说明
触发条件 网络恢复后立即执行
事件保留 未处理的 sync 事件不会丢失(下次上网再试)
限制 无定时/定频保证;iOS 不支持
场景 表单提交、消息发送、评论系统

Periodic Background Sync(定时同步)

// 注册
const registration = await navigator.serviceWorker.ready;
await registration.periodicSync.register('content-sync', {
  minInterval: 12 * 60 * 60 * 1000, // 最小间隔 12h
});

// SW 处理
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'content-sync') {
    event.waitUntil(fetchAndUpdateContent());
  }
});
  • 需要 Chrome(Flag 开启)+ 站点满足 engagement 条件(用户安装 + 有交互)
  • minInterval 仅为建议值,浏览器可延长
  • iOS / Safari 不支持

IndexedDB 离线存储

存储方式 适合 不适合
Cache API HTTP 请求/响应对 结构化业务数据
IndexedDB 结构化数据(JSON、Blob) 简单键值对
localStorage 小量配置(同步 API,5MB 上限) 大量离线数据
// 简单封装 IndexedDB 操作
const db = await idb.openDB('my-app', 1, {
  upgrade(db) {
    db.createObjectStore('messages', { keyPath: 'id', autoIncrement: true });
  },
});

await db.add('messages', { text: 'hello', timestamp: Date.now() });
const all = await db.getAll('messages');

离线 UX 设计

方案 实现
离线提示 Toast 监听 window.online + window.offline 显示浮层
优雅降级 缓存内容优先展示,网络错误显示重试按钮 + 错误页面
骨架屏 SW 先返回缓存的 App Shell + 骨架屏,数据到达后替换
window.addEventListener('online', () => showToast('网络已恢复'));
window.addEventListener('offline', () => showToast('当前离线,展示缓存内容'));

8. 实践与优化

App Shell 架构

┌─────────────────────────┐
│  Header (缓存)           │ ← App Shell
├─────────────────────────┤
│  Sidebar (缓存)          │ ← 静态 UI 框架
├─────────────────────────┤
│  Content (API 数据)       │ ← 运行时缓存/Network First
└─────────────────────────┘
  • :HTML / CSS / JS / Logo 在 install 阶段预缓存
  • 数据:API 响应走 Network First 或 StaleWhileRevalidate
  • 首屏秒开(无需网络即可展示完整 UI 框架)

PWA 检测与引导安装

// 检测是否已作为 PWA 运行
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
                    || window.navigator.standalone; // iOS

// 检测是否支持 PWA
const supportsPWA = 'serviceWorker' in navigator;

// 自定义安装提示按钮
let installPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  installPrompt = e;
  document.getElementById('install-btn').hidden = false;
});

document.getElementById('install-btn').addEventListener('click', async () => {
  installPrompt?.prompt();
  const { outcome } = await installPrompt.userChoice;
  if (outcome === 'dismissed') {
    // 用户拒绝,降低安装提示频率
  }
  installPrompt = null;
});

iOS PWA 特殊处理

问题 解决
独立状态栏 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
存储限制 iOS 约 1GB,超过 7 天无交互可能清空 SW 缓存
Safari 无 beforeinstallprompt 自行检测 + 提示用户通过"分享 → 添加到主屏幕"
navigator.standalone 用此检测 iOS PWA 环境
Service Worker 调试困难 Safari > 开发 > 连接设备后查看
localStorage 不持久 推荐使用 IndexedDB(iOS 13+ 完善)
<!-- iOS 必备 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="/icon-192.png">

性能指标 / Lighthouse PWA 评分项

检查项 要求
HTTPS 重定向到 HTTPS,证书有效
可安装 有效 manifest + SW + HTTPS
离线可用 200 响应来自 Cache(SW 返回缓存)
Splash Screen name + background_color + icons 512x512 配置
注册 SW 页面注册了 SW
请求预缓存(start_url SW 预缓存了 start_url 的响应
页面加载速度 首屏内容(LCP)< 3s(硬件影响)
视口配置 width=device-width
robots 不禁止 X-Robots-Tag 无 noindex
Lighthouse PWA 评分 ≈ (可安装 + 离线 + Splash + HTTPS + 速度) / 总分
Chrome 的 PWA 评分标准已从独立 Lighthouse 分类合并到 Best Practices / SEO / Performance

参考