PWA 让网页拥有原生应用的体验

📌 一句话定义

PWA(Progressive Web App,渐进式 Web 应用)是一组 Web 技术标准的组合------通过 Service Worker、Web App Manifest、HTTPS 三大核心技术,让网页获得离线可用、可安装到桌面、可推送通知等"原生应用级"能力,同时保留 Web 的即时访问和跨平台优势。

基本信息 内容
全称 Progressive Web App(渐进式 Web 应用)
提出者 Google Chrome 团队(Alex Russell & Frances Berriman)
提出时间 2015 年
三大核心 Service Worker + Manifest + HTTPS
安装方式 无需应用商店,浏览器直接"添加到主屏幕/安装"
更新方式 发布即更新,无需用户手动升级
浏览器支持 Chrome / Edge / Safari / Firefox 全支持
代表案例 Twitter Lite、Starbucks、Spotify Web、美团、饿了么
关键标准 Service Workers / Web App Manifest / Push API / Cache Storage

一、为什么需要 PWA?

1.1 Web 与原生 App 的长期割裂

复制代码
        Web                          原生 App
  ┌─────────────┐              ┌─────────────┐
  │ ✅ 即点即用   │              │ ✅ 离线可用   │
  │ ✅ 无需安装   │              │ ✅ 桌面图标   │
  │ ✅ 一处发布   │              │ ✅ 推送通知   │
  │ ✅ 链接分享   │              │ ✅ 硬件能力   │
  │ ✅ 跨平台    │              │ ✅ 流畅体验   │
  │ ❌ 离线不可用 │              │ ❌ 安装门槛高 │
  │ ❌ 无桌面入口 │              │ ❌ 更新需审核 │
  │ ❌ 无推送    │              │ ❌ 双端开发   │
  └─────────────┘              └─────────────┘
                    │
                    ▼
              ┌──────────┐
              │   PWA    │  ← 取两者之长
              │ 渐进式增强 │
              └──────────┘

1.2 移动 Web 的转化漏斗困境

复制代码
用户看到广告 → 搜索应用 → 跳转商店 → 下载安装 → 注册使用

转化率漏斗(传统模式):
  每多一步,流失 ~20%
  最终安装转化率:仅 ~5%

PWA 模式:
  点击链接 → 直接使用 → (体验好后)一键安装
  
  转化率提升数倍

💡 Starbucks 的著名数据:其 PWA 体积仅 233KB(原生 App 的 1/200),订单量显著增长;Twitter Lite 上线后用户停留时长增加 65%,跳出率降低 20%。

1.3 "渐进式"的真正含义

复制代码
PWA 的 P = Progressive(渐进式)

核心哲学:能力检测 + 渐进增强

  基础浏览器 → 看到一个正常的网页
  现代浏览器 → 获得离线能力
  支持的浏览器 → 可以安装到桌面
  支持的浏览器 → 可以接收推送

  任何设备都能用,好的设备体验更好。
  ------ 这就是"渐进式"

二、PWA 的三大核心特性

2.1 可安装(Installable)

复制代码
满足条件后,浏览器会主动弹出安装提示:
  ✅ 有 Web App Manifest
  ✅ 注册了 Service Worker(带 fetch 处理)
  ✅ 通过 HTTPS 提供服务
  ✅ 用户与页面有交互

安装后:
  • 桌面/主屏幕出现图标
  • 独立窗口运行(无浏览器地址栏)
  • 从任务栏/Dock 启动
  • 体验与原生 App 几乎一致

2.2 离线可用(Offline Capable)

复制代码
Service Worker 拦截网络请求 → 优先返回本地缓存

  第一次访问:正常联网加载 + 后台缓存关键资源
  之后访问:即使完全断网也能打开
  弱网环境:秒开(缓存命中)

2.3 可推送(Push Capable)

复制代码
即使用户关闭了页面:
  服务器 → Push Service → Service Worker → 显示系统通知

  与原生 App 推送体验完全一致

三、核心架构与技术栈

3.1 整体架构图

复制代码
┌────────────────────────────────────────────────────────┐
│                      用户浏览器                          │
│                                                        │
│  ┌──────────────────────────────────────────────────┐  │
│  │                  网页(Page)                     │  │
│  │         HTML / CSS / JS / React / Vue            │  │
│  └──────────────────┬───────────────────────────────┘  │
│                     │ 拦截 fetch 事件                   │
│                     ▼                                  │
│  ┌──────────────────────────────────────────────────┐  │
│  │            Service Worker(独立线程)              │  │
│  │   • 请求拦截与缓存策略                             │  │
│  │   • 后台同步(Background Sync)                    │  │
│  │   • 推送处理(Push Events)                        │  │
│  │   • 生命周期管理                                   │  │
│  └────────┬──────────────────────────┬──────────────┘  │
│           │                          │                 │
│           ▼                          ▼                 │
│  ┌─────────────────┐      ┌─────────────────────┐     │
│  │  Cache Storage  │      │   IndexedDB         │     │
│  │  (静态资源缓存) │      │  (结构化数据存储)   │     │
│  └─────────────────┘      └─────────────────────┘     │
│                                                        │
│  ┌──────────────────────────────────────────────────┐  │
│  │           Web App Manifest(应用身份)             │  │
│  └──────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────┘
                     ▲
                     │ HTTPS(强制)
                     ▼
┌────────────────────────────────────────────────────────┐
│                 服务器 / CDN / Push Service              │
└────────────────────────────────────────────────────────┘

3.2 关键技术清单

技术 作用 标准状态
Service Worker 离线缓存、请求拦截、后台任务 ✅ 全浏览器支持
Web App Manifest 应用名称、图标、启动方式 ✅ 全浏览器支持
Cache Storage API 静态资源缓存 ✅ 全浏览器支持
IndexedDB 离线结构化数据 ✅ 全浏览器支持
Push API 消息推送 ✅ Chrome/Edge/Firefox,Safari 16+
Background Sync 后台数据同步 ⚠️ Chromium 系
Periodic Sync 周期性后台更新 ⚠️ Chromium 系
Web Share API 调用系统分享 ✅ 移动端普遍支持

3.3 为什么必须 HTTPS?

复制代码
Service Worker 拥有拦截和篡改网络请求的能力
  → 如果被中间人注入恶意 SW,后果灾难性
  
因此标准强制:
  ✅ https:// 域名
  ✅ localhost(开发环境豁免)
  ❌ http:// 一律不注册 SW

四、Web App Manifest 详解

4.1 完整配置示例

json 复制代码
{
  "name": "我的超级应用",
  "short_name": "超级应用",
  "description": "一个支持离线的渐进式 Web 应用",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#ffffff",
  "theme_color": "#4a90d9",
  "lang": "zh-CN",

  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/maskable-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],

  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "首页"
    }
  ],

  "shortcuts": [
    {
      "name": "快速搜索",
      "short_name": "搜索",
      "url": "/search",
      "icons": [{ "src": "/icons/search-96.png", "sizes": "96x96" }]
    }
  ]
}

4.2 关键字段说明

字段 作用 注意事项
name / short_name 应用全名 / 桌面图标下的短名 short_name ≤ 12 字符
start_url 安装后启动的页面 建议加来源参数便于统计
scope SW 和页面可导航的范围 不能超出 start_url 所在域
display 显示模式 见下表
theme_color 状态栏/标题栏颜色 品牌色
background_color 启动画面背景色 应与启动页一致
icons 应用图标 192 和 512 必需
purpose: maskable 自适应图标(Android) 需留安全边距

4.3 display 模式对比

模式 效果
browser 普通浏览器标签页(默认)
minimal-ui 有极简导航按钮,无地址栏
standalone 独立窗口,像原生 App(推荐)
fullscreen 全屏,隐藏所有浏览器 UI

4.4 在 HTML 中引入

html 复制代码
<head>
  <link rel="manifest" href="/manifest.webmanifest">
  
  <!-- iOS 不支持完整 manifest,需要单独适配 -->
  <link rel="apple-touch-icon" href="/icons/icon-192.png">
  <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="超级应用">
  
  <!-- 主题色 -->
  <meta name="theme-color" content="#4a90d9">
</head>

五、Service Worker 深度拆解

5.1 Service Worker 是什么?

复制代码
一个运行在浏览器和网络之间的"代理层"
  • 独立的后台线程(不阻塞页面)
  • 无 DOM 访问权限
  • 可拦截所有 fetch 请求
  • 可被浏览器随时终止和重启(不能存全局状态!)
  • 生命周期由浏览器管理

5.2 生命周期

复制代码
┌─────────┐    ┌───────────┐    ┌───────────┐
│ 注册     │───►│安装install│───►│ 激活 activate│
│ register│    │(缓存资源)  │    │ (清理旧缓存) │
└─────────┘    └───────────┘    └─────┬─────┘
                                      │
                                      ▼
                          ┌──────────────────────┐
                          │ 运行中 running        │
                          │ • fetch 事件拦截       │
                          │ • push / sync 事件    │
                          └───────┬──────────────┘
                                  │ 空闲一段时间后
                                  ▼
                          ┌──────────────┐
                          │ 终止terminated│ ──► 有新事件时自动重启
                          └──────────────┘
                                  │
                                  │ 检测到 sw.js 文件变化
                                  ▼
                          ┌───────────────────────┐
                          │ 等待激活 waiting       │
                          │ (旧页面全部关闭后接管)  │
                          └───────────────────────┘

5.3 注册与基础代码

javascript 复制代码
// main.js --- 注册 Service Worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/'
      });
      console.log('SW 注册成功,scope:', registration.scope);
      
      // 监听更新
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
            // 新版本已缓存完毕,提示用户刷新
            if (confirm('发现新版本,是否立即更新?')) {
              newWorker.postMessage({ type: 'SKIP_WAITING' });
              window.location.reload();
            }
          }
        });
      });
    } catch (error) {
      console.error('SW 注册失败:', error);
    }
  });
}
javascript 复制代码
// sw.js --- Service Worker 主体
const CACHE_NAME = 'app-v1';
const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/css/main.css',
  '/js/app.js',
  '/icons/icon-192.png'
];

// ===== 安装阶段:预缓存核心资源 =====
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(PRECACHE_URLS))
      .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((cached) => {
      // 缓存优先
      return cached || fetch(event.request);
    })
  );
});

5.4 SW 的黄金法则

复制代码
1. 永远不要在 SW 中存储状态到全局变量(随时被终止)
2. 永远使用 event.waitUntil() 延长生命周期
3. 缓存名称必须带版本号(否则更新不生效)
4. 只缓存同源或 CORS 允许的资源
5. POST 请求默认不缓存(不可靠)
6. sw.js 必须放在根路径(或正确设置 scope)

六、缓存策略实战

6.1 四大经典策略

复制代码
┌──────────────────────────────────────────────────────────┐
│ 1. Cache First(缓存优先)                                 │
│    缓存 → 命中返回 │ 未命中 → 网络 → 存入缓存                 │
│    适用:静态资源(JS/CSS/图片/字体)                        │
│    特点:最快,但可能拿到旧版本                               │
├──────────────────────────────────────────────────────────┤
│ 2. Network First(网络优先)                               │
│    网络 → 成功返回并更新缓存 │ 失败 → 返回缓存                 │
│    适用:HTML、API 数据(时效性要求高)                      │
│    特点:最新,但慢网络体验差                                │ 
├──────────────────────────────────────────────────────────┤
│ 3. Stale While Revalidate(SWR 先旧后新)                  │
│    立即返回缓存(旧的)+ 后台请求网络更新缓存                  │
│    适用:非关键数据、图片列表                                │
│    特点:秒开 + 最终一致                                    │
├──────────────────────────────────────────────────────────┤
│ 4. Cache Only / Network Only                             │
│    仅缓存 / 仅网络                                         │
│    适用:离线包 / 埋点上报                                  │
└──────────────────────────────────────────────────────────┘

6.2 手写完整策略实现

javascript 复制代码
// sw.js 中的完整缓存策略

// 策略一:Cache First(带超时回退)
async function cacheFirst(request, cacheName, timeout = 3000) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  if (cached) return cached;

  try {
    const response = await fetchWithTimeout(request, timeout);
    if (response.ok) cache.put(request, response.clone());
    return response;
  } catch (err) {
    return new Response('离线且无缓存', { status: 503 });
  }
}

// 策略二:Network First(带缓存兜底)
async function networkFirst(request, cacheName, timeout = 5000) {
  const cache = await caches.open(cacheName);
  try {
    const response = await fetchWithTimeout(request, timeout);
    if (response.ok) cache.put(request, response.clone());
    return response;
  } catch (err) {
    const cached = await cache.match(request);
    return cached || new Response('离线不可用', { status: 503 });
  }
}

// 策略三:Stale While Revalidate
async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  const networkFetch = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  }).catch(() => null);

  // 有缓存先用缓存,无缓存等网络
  return cached || networkFetch;
}

// 工具:带超时的 fetch
function fetchWithTimeout(request, timeout) {
  return Promise.race([
    fetch(request),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('timeout')), timeout)
    )
  ]);
}

// 按资源类型路由到不同策略
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  if (request.method !== 'GET') return;  // 只处理 GET

  // 导航请求(HTML)→ Network First
  if (request.mode === 'navigate') {
    event.respondWith(networkFirst(request, 'html-cache'));
    return;
  }

  // 静态资源 → Cache First
  if (/\.(js|css|woff2?|png|jpg|svg)$/.test(url.pathname)) {
    event.respondWith(cacheFirst(request, 'static-cache'));
    return;
  }

  // API 数据 → Stale While Revalidate
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(staleWhileRevalidate(request, 'api-cache'));
    return;
  }
});

6.3 Workbox:Google 官方生产级方案

javascript 复制代码
// sw.js --- 使用 Workbox(强烈推荐,不要手写生产级 SW)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// 预缓存构建产物(由构建工具注入清单)
precacheAndRoute(self.__WB_MANIFEST);

// 图片:Cache First + 30 天过期 + 最多 60 张
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 }),
      new CacheableResponsePlugin({ statuses: [0, 200] })
    ]
  })
);

// API:Network First + 超时兜底缓存
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 4,
    plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 })]
  })
);

// Google Fonts:Stale While Revalidate
registerRoute(
  ({ url }) => url.origin === 'https://fonts.gstatic.com',
  new StaleWhileRevalidate({ cacheName: 'google-fonts' })
);

七、从零构建一个完整 PWA

7.1 项目结构

复制代码
my-pwa/
├── public/
│   ├── manifest.webmanifest
│   ├── icons/
│   │   ├── icon-192.png
│   │   ├── icon-512.png
│   │   └── maskable-512.png
│   └── offline.html          # 离线兜底页
├── src/
│   ├── main.js
│   ├── sw.js                 # Service Worker 源码
│   └── ...
├── vite.config.ts
└── package.json

7.2 使用 Vite + vite-plugin-pwa(推荐)

bash 复制代码
npm create vite@latest my-pwa -- --template vue-ts
cd my-pwa
npm install -D vite-plugin-pwa
typescript 复制代码
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    vue(),
    VitePWA({
      registerType: 'autoUpdate',   // 自动更新
      includeAssets: ['favicon.ico', 'robots.txt'],
      manifest: {
        name: '我的 PWA 应用',
        short_name: 'MyPWA',
        description: '支持离线的渐进式 Web 应用',
        theme_color: '#4a90d9',
        background_color: '#ffffff',
        display: 'standalone',
        start_url: '/',
        icons: [
          { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
          { src: '/icons/maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
        ]
      },
      workbox: {
        // 预缓存所有构建产物
        globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
        // 运行时缓存 API
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              networkTimeoutSeconds: 5,
              expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 }
            }
          }
        ]
      },
      devOptions: { enabled: true }  // 开发模式也启用,方便调试
    })
  ]
});

7.3 安装提示处理(beforeinstallprompt)

javascript 复制代码
// src/install-prompt.js
let deferredPrompt = null;

// 捕获安装事件(阻止默认横幅,自己控制时机)
window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;
  // 显示自定义安装按钮
  showInstallButton();
});

async function showInstallButton() {
  const btn = document.getElementById('install-btn');
  btn.style.display = 'block';
  
  btn.addEventListener('click', async () => {
    deferredPrompt.prompt();  // 弹出系统安装对话框
    const { outcome } = await deferredPrompt.userChoice;
    
    if (outcome === 'accepted') {
      console.log('用户安装了 PWA');
    }
    deferredPrompt = null;
    btn.style.display = 'none';
  });
}

// 检测是否已安装(standalone 模式运行中)
const isInstalled = window.matchMedia('(display-mode: standalone)').matches
  || window.navigator.standalone === true;  // iOS 兼容

// iOS Safari 没有 beforeinstallprompt,需要引导提示
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS && !isInstalled) {
  // 显示引导:"点击分享按钮 → 添加到主屏幕"
  showIOSInstallGuide();
}

7.4 在线/离线状态处理

javascript 复制代码
// 监听网络状态
function updateOnlineStatus() {
  const badge = document.getElementById('network-badge');
  if (navigator.onLine) {
    badge.textContent = '🟢 在线';
    syncPendingData();  // 恢复联网时同步离线期间暂存的数据
  } else {
    badge.textContent = '🔴 离线模式';
  }
}

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

八、框架集成方案

框架 方案 说明
Vite(Vue/React) vite-plugin-pwa 最流行,开箱即用
Next.js @ducanh2912/next-pwa / Serwist App Router 兼容
Nuxt 3 @vite-pwa/nuxt 官方推荐
Angular @angular/pwa ng add @angular/pwa 一条命令
React CRA 内置模板 --template cra-template-pwa 官方模板
SvelteKit @vite-pwa/sveltekit 官方适配
Astro @vite-pwa/astro 官方适配

Angular 一键接入示例

bash 复制代码
ng add @angular/pwa
# 自动生成:
#   • ngsw-config.json(SW 配置)
#   • manifest.webmanifest
#   • 图标文件
#   • 在 app.module.ts 注册 ServiceWorkerModule
json 复制代码
// ngsw-config.json
{
  "dataGroups": [
    {
      "name": "api-freshness",
      "urls": ["/api/**"],
      "cacheConfig": {
        "strategy": "freshness",       // Network First
        "maxSize": 100,
        "maxAge": "1d",
        "timeout": "5s"
      }
    }
  ],
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",        // 安装时预取
      "resources": { "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"] }
    },
    {
      "name": "assets",
      "installMode": "lazy",            // 用到时才缓存
      "resources": { "files": ["/assets/**"] }
    }
  ]
}

九、高级能力:推送、后台同步、离线表单

9.1 Web Push 推送通知

复制代码
完整链路:
用户订阅 → 订阅信息存服务器 → 服务器发推送 → Push Service 转发
→ SW 收到 push 事件 → 显示 Notification → 用户点击 → 打开页面

           ┌──────────┐
           │ Push 服务 │ (FCM / Mozilla autopush / Web Push)
           └────┬─────┘
                │
   订阅信息     │     推送消息
  ┌─────────────┼──────────────┐
  ▼             ▼              ▼
┌──────┐   ┌──────────┐   ┌──────────┐
│服务器 │──►│ SW push  │──►│ 系统通知  │
└──────┘   │ 事件处理  │   └────┬─────┘
           └──────────┘        │ 用户点击
                               ▼
                          ┌──────────┐
                          │ 打开页面  │
                          └──────────┘
javascript 复制代码
// ===== 前端:订阅推送 =====
async function subscribePush() {
  const registration = await navigator.serviceWorker.ready;
  
  // VAPID 公钥(服务器生成)
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,   // 必须显示可见通知(浏览器强制)
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
  });
  
  // 把订阅信息发给你的服务器保存
  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription)
  });
}
javascript 复制代码
// ===== sw.js:处理推送 =====
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : { title: '新消息', body: '您有一条通知' };
  
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url },
      actions: [
        { action: 'open', title: '查看' },
        { action: 'close', title: '忽略' }
      ]
    })
  );
});

// 用户点击通知
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  if (event.action === 'close') return;
  
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((list) => {
      // 已有窗口则聚焦,否则新开
      const existing = list.find((c) => c.url.includes(event.notification.data.url));
      if (existing) return existing.focus();
      return clients.openWindow(event.notification.data.url || '/');
    })
  );
});
javascript 复制代码
// ===== 服务器(Node.js):发送推送 =====
// npm install web-push
const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:admin@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function sendPush(subscription, payload) {
  try {
    await webpush.sendNotification(subscription, JSON.stringify(payload));
  } catch (err) {
    if (err.statusCode === 404 || err.statusCode === 410) {
      // 订阅已失效,从数据库删除
      await removeSubscription(subscription.endpoint);
    }
  }
}

9.2 Background Sync 后台同步(离线表单)

javascript 复制代码
// ===== 页面:离线时暂存数据 =====
async function submitForm(formData) {
  try {
    await fetch('/api/submit', { method: 'POST', body: formData });
  } catch (err) {
    // 网络失败 → 存入 IndexedDB → 注册后台同步
    await saveToIndexedDB(formData);
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('submit-forms');  // 网络恢复后自动触发
  }
}

// ===== sw.js:网络恢复后自动重试 =====
self.addEventListener('sync', (event) => {
  if (event.tag === 'submit-forms') {
    event.waitUntil(submitPendingForms());
  }
});

async function submitPendingForms() {
  const pending = await getAllFromIndexedDB();
  for (const item of pending) {
    await fetch('/api/submit', { method: 'POST', body: item.data });
    await removeFromIndexedDB(item.id);
  }
}

9.3 Periodic Background Sync(周期性更新)

javascript 复制代码
// 让浏览器定期在后台更新内容(如新闻 App 预取头条)
const registration = await navigator.serviceWorker.ready;
await registration.periodicSync.register('fetch-news', {
  minInterval: 12 * 60 * 60 * 1000  // 至少 12 小时一次(浏览器决定实际频率)
});

// sw.js
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'fetch-news') {
    event.waitUntil(updateNewsCache());
  }
});

十、PWA vs 原生 App vs 小程序

10.1 三方对比

维度 PWA 原生 App 小程序
安装 浏览器一键添加 应用商店下载 平台内搜索/扫码
体积 KB~MB 级 几十~几百 MB < 2MB(平台限制)
更新 发布即生效 商店审核 平台审核
分发 链接/二维码,无门槛 商店/链接 依赖宿主平台
离线 ✅ Service Worker ✅ 本地存储 ⚠️ 有限支持
推送 ✅(iOS 16.4+ 支持) ✅ 完善 ✅ 模板消息(受限)
硬件能力 ⚠️ 有限(相机/地理位置可用) ✅ 全部 ⚠️ 平台封装
上架审核 ❌ 无需 ✅ 严格 ✅ 平台审核
开发成本 一套代码全平台 iOS + Android 双端 依赖平台框架
入口 桌面图标/浏览器 桌面图标 微信/支付宝内
数据归属 自己 自己 平台生态内

10.2 选型决策

复制代码
你的产品需要什么?
│
├── 全球分发、无商店依赖、内容/工具类产品
│   └── ✅ PWA
│
├── 深度硬件能力(蓝牙/NFC/后台常驻/AR)
│   └── ✅ 原生 App
│
├── 中国市场、依赖微信生态、社交裂变
│   └── ✅ 小程序
│
├── 电商/新闻/工具,追求快速触达
│   └── ✅ PWA(可与小程序并行)
│
└── 预算有限、团队小、想覆盖最多用户
    └── ✅ PWA(一套代码,处处运行)

十一、实际应用场景与案例

11.1 国际经典案例

产品 做法 成果
Twitter Lite 完整 PWA,替代移动 Web 版 停留时长 +65%,跳出率 -20%,安装包仅 600KB
Starbucks 点单 PWA 体积 233KB(原生 App 的 1/200),订单转化率翻倍
Spotify Web PWA 桌面应用 可离线听歌,安装后与桌面 App 体验一致
Uber 移动 Web PWA 弱网环境下 3 秒内加载,核心包仅 50KB
Pinterest PWA 重构移动 Web 停留时长 +40%,广告收入 +44%
Forbes 新闻 PWA 加载从 16 秒降到 3 秒,回访率 +43%
Flipkart 印度电商 PWA 转化率 +70%,停留时长 3.5x

11.2 国内实践

复制代码
• 美团/饿了么 H5 页面:广泛使用 SW 缓存静态资源加速
• 抖音/快手 Web 版:Service Worker 预加载视频列表
• 各大银行 H5:离线兜底页 + 静态资源缓存
• 钉钉文档 Web:manifest + 桌面安装入口

11.3 最适合 PWA 的六类场景

复制代码
1. 📰 内容资讯类(新闻/博客/文档)
   → 离线阅读 + 推送新内容

2. 🛒 轻量电商
   → 弱网可用 + 安装后复购路径短

3. 🛠️ 工具类应用(计算器/笔记/转换器)
   → 完全离线可用,替代桌面软件

4. 🏢 企业内部系统(OA/报表/巡检)
   → 无需分发安装包,统一发布更新
   → 弱网车间/野外也能录入数据(后台同步)

5. 🎮 轻度游戏/H5 营销页
   → 资源预缓存,秒开

6. 📱 出海产品
   → 无 Google Play/App Store 审核依赖
   → 东南亚/非洲弱网市场友好

11.4 一个企业内部巡检 PWA 的完整设计

复制代码
场景:工厂设备巡检,车间网络不稳定

技术设计:
┌────────────────────────────────────────┐
│ 预缓存:整个应用外壳(App Shell)         │
│   → 断网也能打开应用                     │
│                                        │
│ 数据层:IndexedDB 存储巡检任务列表        │
│   → 上班前在有网处同步当天任务            │
│                                        │
│ 表单提交:Background Sync               │
│   → 车间断网照常录入,网络恢复自动上传     │
│                                        │
│ 拍照:Input capture + 本地压缩           │
│   → 照片存 IndexedDB,随表单一起同步      │
│                                        │
│ 推送:设备告警通知                        │
│   → 维修人员即时收到                     │
│                                        │
│ 部署:内网 HTTPS + 企业域名              │
│   → 员工扫码/输网址即可安装,IT 无需分发   │
└────────────────────────────────────────┘

收益:
• 无需开发 Android/iOS 两个版本
• 无需企业 MDM 分发
• 版本更新即发即生效
• 开发成本降低约 60%

十二、性能数据与收益

12.1 典型收益数据

指标 传统 Web PWA 提升
二次访问加载 2-5 秒 <0.5 秒(缓存命中) 80%+
弱网可用性 白屏 离线兜底页/App Shell 质的飞跃
流量消耗 每次全量下载 增量更新 ↓ 70%
安装转化 跳转商店 ~5% 页内一键 ~25% 5x
存储占用 App 100MB+ PWA <5MB ↓ 95%
开发维护 双端 + Web 一套代码 ↓ 60% 成本

12.2 App Shell 架构

复制代码
把应用拆成两层:

┌─────────────────────────────┐
│  App Shell(外壳,极少变化)   │ ← 预缓存,离线秒开
│  HTML + CSS + 核心 JS + 图标  │
├─────────────────────────────┤
│  数据层(动态内容)            │ ← 网络获取 + SWR 缓存
│  列表、详情、用户数据          │
└─────────────────────────────┘

效果:断网时打开应用 → 外壳立即渲染 → 显示"离线模式"
     → 用户不会看到白屏或浏览器错误页

十三、调试与发布

13.1 Chrome DevTools 调试

复制代码
1. Manifest 检查:
   DevTools → Application → Manifest
   • 一键检测所有配置问题
   • 预览图标、启动画面
   • "Installability" 面板显示为何无法安装

2. Service Worker 调试:
   DevTools → Application → Service Workers
   • 查看 SW 状态(installing/active/redundant)
   • "Update on reload":每次刷新强制更新(开发必开)
   • "Bypass for network":绕过 SW 直连网络
   • 点击 "inspect" 打开 SW 专属 DevTools

3. 缓存查看:
   Application → Cache Storage → 展开各个缓存桶
   → 手动删除某条缓存测试回退逻辑

4. 离线模拟:
   Network 面板 → Throttling 下拉 → "Offline"
   → 验证离线体验

5. Lighthouse PWA 审计:
   Lighthouse → 勾选 PWA 类别 → 一键生成报告

13.2 更新机制设计(最容易出错的部分)

javascript 复制代码
// SW 更新流程:
// 1. 浏览器每 24 小时(或导航时)检查 sw.js 是否变化
// 2. 发现变化 → 安装新 SW → 进入 waiting 状态
// 3. 所有旧页面关闭后 → 新 SW 才激活
// 4. 用户可能长期开着旧标签页 → 永远拿不到新版本!

// ✅ 解决方案 A:自动更新(vite-plugin-pwa registerType: 'autoUpdate')
// 新 SW install 完成后自动 skipWaiting + clients.claim

// ✅ 解决方案 B:提示用户刷新(体验更可控)
navigator.serviceWorker.addEventListener('controllerchange', () => {
  // 新 SW 已接管 → 刷新页面加载新资源
  window.location.reload();
});

// ✅ 解决方案 C:Workbox 内置
// workbox-window 提供 updatefound/needsUpdate 事件

13.3 发布前 Checklist

复制代码
□ Lighthouse PWA 分数 ≥ 90
□ manifest 通过 Manifest 面板检查无警告
□ 192px 和 512px 图标齐全,maskable 图标有安全边距
□ HTTPS 证书有效(Let's Encrypt 免费)
□ sw.js 位于站点根路径
□ 离线模式实测通过(DevTools → Offline → 刷新)
□ SW 更新机制验证(改版本号 → 刷新两次 → 确认生效)
□ 真实设备测试安装流程(Android Chrome + iOS Safari)
□ Cache-Control 配置正确(sw.js 必须 no-cache 或短缓存)
□ 所有 API 走 HTTPS,无混合内容

十四、踩坑指南

14.1 高频问题清单

症状 原因 解决
SW 不更新 用户永远看到旧版本 sw.js 被 HTTP 缓存 服务器设置 Cache-Control: no-cache
缓存膨胀 用户设备存储被吃满 无过期策略 ExpirationPlugin 限制条数和时长
iOS 无法弹出安装 没有 beforeinstallprompt iOS 不支持该事件 引导用户"分享 → 添加到主屏幕"
iOS 推送收不到 通知不送达 iOS 16.4+ 才支持,且必须先"添加到主屏幕" 文档说明限制,做降级
开发时改了代码不生效 页面永远是旧的 SW 缓存了旧资源 开发开启 "Update on reload" 或 Bypass
缓存了错误响应 离线时返回 404 页面 未校验 response.ok CacheableResponsePlugin
跨域资源缓存失败 opaque 响应占空间且不可读 CORS 缺失 加 crossorigin 属性或 CDN 配 CORS 头
更新后新旧资源混用 页面报错 chunk 加载失败 HTML 是新的、JS chunk 缓存是旧的 HTML 用 NetworkFirst,构建产物带 hash
localStorage 数据丢失 iOS 7 天未访问清空存储 Safari ITP 存储配额策略 关键数据提示用户备份/使用 IndexedDB 并声明持久化
scope 错误 SW 注册失败 sw.js 不在根路径且未设 Service-Worker-Allowed 头 放根路径或加响应头

14.2 iOS Safari 特殊限制(重点)

复制代码
iOS PWA 的"坑"比 Android 多得多:

1. 必须用户手动"添加到主屏幕"才算 PWA
   → 在 Safari 中打开的页面即使有 manifest 也没有推送能力
   
2. Web Push 需要 iOS 16.4+ 且已添加到主屏幕

3. 存储配额限制严格(约 1GB,且 7 天不用可能被清)
   → 申请持久化:navigator.storage.persist()

4. 后台任务能力几乎为零(Background Sync 不支持)

5. 欧盟地区因 DMA 政策,PWA 安装体验有过反复调整

应对策略:
• 功能检测优先,所有 PWA 能力都要有降级路径
• iOS 上显示自定义安装引导蒙层
• 不要依赖 iOS 上的推送送达率

14.3 持久化存储申请

javascript 复制代码
// 防止浏览器自动清理 IndexedDB/CacheStorage
async function requestPersistentStorage() {
  if (navigator.storage && navigator.storage.persist) {
    const isPersisted = await navigator.storage.persist();
    console.log(`持久化存储申请${isPersisted ? '成功' : '被拒绝'}`);
  }
  
  // 查看配额
  if (navigator.storage && navigator.storage.estimate) {
    const { quota, usage } = await navigator.storage.estimate();
    console.log(`已用 ${(usage / 1048576).toFixed(1)}MB / 配额 ${(quota / 1048576).toFixed(1)}MB`);
  }
}

十五、最佳实践 Checklist

15.1 架构层面

复制代码
✅ 采用 App Shell 架构,外壳与数据分离
✅ 使用 Workbox 而非手写 SW(生产环境)
✅ 构建产物文件名带 content hash(长期强缓存 + 精准失效)
✅ sw.js 本身设置 no-cache
✅ 制定明确的缓存分层策略:
     HTML       → NetworkFirst(保证拿到新入口)
     JS/CSS     → CacheFirst(hash 文件名,永不过期)
     图片/字体   → CacheFirst + 过期上限
     API        → SWR 或 NetworkFirst + 超时
✅ 提供精心设计的离线兜底页

15.2 体验层面

复制代码
✅ 首屏 LCP < 2.5s(弱网也要达标)
✅ 自定义安装引导按钮(别只依赖浏览器横幅)
✅ iOS 显示"添加到主屏幕"教程
✅ 在线/离线状态可视化提示
✅ 离线操作给予明确反馈("已保存,联网后自动提交")
✅ 通知推送征得用户同意后再订阅,说明用途

15.3 运维层面

复制代码
✅ 监控 SW 注册成功率与缓存命中率
✅ 版本发布策略:灰度 → 全量,缓存名带版本号
✅ 定期清理过期缓存(ExpirationPlugin)
✅ 建立"紧急下线 SW"预案:
     发布一个空的 sw.js + skipWaiting,强制清除问题版本
✅ 使用 Lighthouse CI 在流水线中卡 PWA 分数

15.4 快速起步模板

bash 复制代码
# 30 秒拥有一个可用的 PWA 骨架
npm create vite@latest my-pwa -- --template vue-ts
cd my-pwa
npm i -D vite-plugin-pwa
# 按第 7.2 节配置 vite.config.ts
npm run dev   # devOptions.enabled = true,开发即可调试

参考资源

资源 链接
MDN PWA 文档 https://developer.mozilla.org/zh-CN/docs/Web/Progressive_web_apps
web.dev PWA 专区 https://web.dev/explore/progressive-web-apps
Workbox 官方文档 https://developer.chrome.com/docs/workbox
vite-plugin-pwa https://vite-pwa-org.netlify.app/
Web App Manifest 规范 https://www.w3.org/TR/appmanifest/
Service Worker 规范 https://www.w3.org/TR/service-workers/
PWA 兼容性查询 https://caniuse.com/serviceworkers
Maskable Icons 编辑器 https://maskable.app/editor
PWA Builder(一键生成) https://www.pwabuilder.com/
Lighthouse PWA 审计 Chrome DevTools 内置

文档整理时间:2026-09-05

相关推荐
雪芽蓝域zzs39 分钟前
第十七节:面包屑导航组件(适配多级嵌套路由)
前端·javascript·vue.js
Csvn1 小时前
类型驱动开发(Type-Driven Development):让类型约束先行为导向
前端
计算机魔术师1 小时前
AI 代理把德国维基当留言板,OpenAI 闷声几周才承认
前端
yume_sibai1 小时前
Element Plus 其他实用组件完全指南(10个核心组件)
前端·javascript·vue.js
雪芽蓝域zzs2 小时前
第二十二节:进阶:用户管理新增、编辑弹窗 + 表单校验
前端·javascript·vue.js
IT_陈寒2 小时前
Java字符串判等踩坑记:==和equals真的不能乱用
前端·人工智能·后端
晴天162 小时前
前端虚拟滚动(虚拟列表)原理与实战
前端
晴天162 小时前
前端 RAIL 性能模型实战分享-Day37
前端
变与不变8062 小时前
JS作用域,作用域链
前端·javascript·es6