📌 一句话定义
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 完整配置示例
{
"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 中引入
<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 注册与基础代码
// 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);
}
});
}
// 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 手写完整策略实现
// 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 官方生产级方案
// 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(推荐)
npm create vite@latest my-pwa -- --template vue-ts
cd my-pwa
npm install -D vite-plugin-pwa
// 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)
// 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 在线/离线状态处理
// 监听网络状态
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 一键接入示例
ng add @angular/pwa
# 自动生成:
# • ngsw-config.json(SW 配置)
# • manifest.webmanifest
# • 图标文件
# • 在 app.module.ts 注册 ServiceWorkerModule
// 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 │──►│ 系统通知 │
└──────┘ │ 事件处理 │ └────┬─────┘
└──────────┘ │ 用户点击
▼
┌──────────┐
│ 打开页面 │
└──────────┘
// ===== 前端:订阅推送 =====
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)
});
}
// ===== 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 || '/');
})
);
});
// ===== 服务器(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 后台同步(离线表单)
// ===== 页面:离线时暂存数据 =====
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(周期性更新)
// 让浏览器定期在后台更新内容(如新闻 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 缓存
│ 列表、详情、用户数据 │
└─────────────────────────────┘
效果:断网时打开应用 → 外壳立即渲染 → 显示"离线模式"
→ 用户不会看到白屏或浏览器错误页
十三、调试与发布
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 更新机制设计(最容易出错的部分)
// 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 持久化存储申请
// 防止浏览器自动清理 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 快速起步模板
# 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,开发即可调试
参考资源
文档整理时间:2026-09-05