跳到主要内容

PWA 渐进式 Web 应用

问题

PWA(Progressive Web App)的核心技术是什么?如何实现离线访问、推送通知和类原生体验?

答案

1. PWA 是什么

PWA(Progressive Web App) 是一种利用现代 Web 技术构建的应用形态,能在浏览器中提供类原生的用户体验。它不是某个具体的 API 或框架,而是一组技术方案和设计理念的集合。

PWA 的核心特征:

特征说明
渐进增强在任何浏览器中可用,功能逐步增强
响应式适配桌面、移动端、平板等各种设备
离线可用Service Worker 实现离线访问能力
类原生体验可添加到主屏幕,全屏运行
可安装无需应用商店,通过浏览器直接安装
可推送支持 Push API 推送通知
安全必须通过 HTTPS 提供服务
可链接通过 URL 分享,无需安装
PWA 的核心三大技术
  1. Service Worker —— 离线缓存与网络代理
  2. Web App Manifest —— 安装与外观配置
  3. HTTPS —— 安全保障(Service Worker 强制要求)

2. Service Worker 详解

Service Worker 是 PWA 的核心。它是一个在浏览器后台运行的独立线程,充当 Web 应用、浏览器和网络之间的代理服务器

2.1 Service Worker 生命周期

sw.ts — Service Worker 注册与生命周期
// 1. 在主线程注册 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 === 'activated') {
// 新 SW 已激活,提示用户刷新
showUpdateNotification();
}
});
});
} catch (error) {
console.error('SW 注册失败:', error);
}
});
}
sw.ts — Service Worker 文件
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;

const CACHE_NAME = 'app-cache-v1';
const PRECACHE_URLS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.svg',
];

// 2. install 事件 —— 预缓存关键资源
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('预缓存资源');
return cache.addAll(PRECACHE_URLS);
})
);
self.skipWaiting(); // 跳过等待,立即激活
});

// 3. activate 事件 —— 清理旧缓存
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(); // 立即接管所有页面
});

// 4. fetch 事件 —— 拦截网络请求
self.addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
Service Worker 注意事项
  • 必须 HTTPS(localhost 除外)
  • 作用域限制:只能拦截 scope 目录及子目录下的请求
  • 独立线程:无法直接操作 DOM,需通过 postMessage 通信
  • 生命周期独立:即使页面关闭,SW 仍可在后台运行
  • 更新机制:浏览器每 24 小时至少检查一次 SW 文件更新

2.2 缓存策略

Service Worker 的核心能力是通过 Cache API 实现灵活的缓存策略:

缓存策略实现
// 策略 1:Cache First(缓存优先)—— 适用于静态资源
function cacheFirst(request: Request): Promise<Response> {
return caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return response;
});
});
}

// 策略 2:Network First(网络优先)—— 适用于 API 请求
async function networkFirst(request: Request): Promise<Response> {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
// 返回离线页面
return caches.match('/offline.html') as Promise<Response>;
}
}

// 策略 3:Stale While Revalidate(SWR)—— 适用于频繁更新的资源
function staleWhileRevalidate(request: Request): Promise<Response> {
return caches.open(CACHE_NAME).then((cache) => {
return cache.match(request).then((cached) => {
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
});
});
}

// 在 fetch 事件中根据请求类型选择策略
self.addEventListener('fetch', (event: FetchEvent) => {
const { request } = event;
const url = new URL(request.url);

if (request.destination === 'image' || url.pathname.match(/\.(css|js)$/)) {
// 静态资源:缓存优先
event.respondWith(cacheFirst(request));
} else if (url.pathname.startsWith('/api/')) {
// API 请求:网络优先
event.respondWith(networkFirst(request));
} else {
// 页面和其他资源:SWR
event.respondWith(staleWhileRevalidate(request));
}
});
策略适用场景优点缺点
Cache First静态资源(CSS/JS/图片)快速响应,节省带宽可能展示过期内容
Network FirstAPI、实时数据优先展示最新数据网络慢时体验差
Stale While Revalidate频繁更新但非实时快速响应 + 后台更新第一次访问略慢
Network Only需要实时的请求(支付)数据始终最新离线不可用
Cache Only预缓存的静态资源极快响应无法更新

2.3 Workbox 工具库

Workbox 是 Google 开发的 Service Worker 工具库,大幅简化缓存管理:

npm install workbox-webpack-plugin workbox-recipes workbox-routing workbox-strategies workbox-precaching
sw.ts — 使用 Workbox
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';

// 1. 预缓存 —— 构建时注入的资源列表
precacheAndRoute(self.__WB_MANIFEST);

// 2. 图片缓存策略:缓存优先 + 过期控制
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 60, // 最多缓存 60 张
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天过期
}),
new CacheableResponsePlugin({
statuses: [0, 200], // 只缓存成功响应
}),
],
})
);

// 3. API 请求:网络优先
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 3, // 3 秒超时回退缓存
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5 分钟
}),
],
})
);

// 4. CSS/JS:SWR
registerRoute(
({ request }) =>
request.destination === 'style' || request.destination === 'script',
new StaleWhileRevalidate({
cacheName: 'static-resources',
})
);

// 5. Google Fonts 缓存
registerRoute(
({ url }) =>
url.origin === 'https://fonts.googleapis.com' ||
url.origin === 'https://fonts.gstatic.com',
new CacheFirst({
cacheName: 'google-fonts',
plugins: [
new ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 年
}),
],
})
);
webpack.config.ts — Workbox Webpack 插件
import { InjectManifest } from 'workbox-webpack-plugin';

const config = {
plugins: [
new InjectManifest({
swSrc: './src/sw.ts', // SW 源文件
swDest: 'sw.js', // 输出文件
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 最大 5MB
exclude: [/\.map$/, /manifest\.json$/],
}),
],
};
Workbox 的两种模式
  • GenerateSW:自动生成 SW 文件,适合简单场景
  • InjectManifest:注入预缓存清单到自定义 SW,适合复杂需求(推荐)

3. Web App Manifest

Web App Manifest 是一个 JSON 文件,定义 PWA 的安装信息和外观:

manifest.json
{
"name": "我的 PWA 应用",
"short_name": "MyPWA",
"description": "一个渐进式 Web 应用示例",
"start_url": "/?source=pwa",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#1976d2",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "主页"
},
{
"src": "/screenshots/mobile-home.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow",
"label": "移动端主页"
}
],
"shortcuts": [
{
"name": "搜索",
"url": "/search",
"icons": [{ "src": "/icons/search.png", "sizes": "96x96" }]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [{ "name": "media", "accept": ["image/*", "video/*"] }]
}
}
}
index.html — 引用 manifest
<link rel="manifest" href="/manifest.json" />
<!-- iOS 兼容 -->
<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="MyPWA" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
<!-- 主题色 -->
<meta name="theme-color" content="#1976d2" />

display 属性对比:

display 模式地址栏系统 UI适用场景
fullscreen隐藏隐藏游戏、沉浸式体验
standalone隐藏显示状态栏大多数 PWA(推荐)
minimal-ui显示简化显示状态栏内容网站
browser显示完整浏览器普通网页

4. 安装与更新机制

4.1 安装提示(A2HS)

install-prompt.ts — 自定义安装体验
// 拦截浏览器默认安装提示
let deferredPrompt: BeforeInstallPromptEvent | null = null;

interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
prompt(): Promise<void>;
}

window.addEventListener('beforeinstallprompt', (e: Event) => {
// 阻止默认提示
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;

// 显示自定义安装按钮
showInstallButton();
});

async function handleInstallClick(): Promise<void> {
if (!deferredPrompt) return;

// 显示安装提示
await deferredPrompt.prompt();

// 等待用户选择
const { outcome } = await deferredPrompt.userChoice;
console.log(`用户选择: ${outcome}`);

if (outcome === 'accepted') {
// 用户接受安装
hideInstallButton();
trackEvent('pwa_install', { source: 'custom_prompt' });
}

deferredPrompt = null;
}

// 检测 PWA 是否已安装
window.addEventListener('appinstalled', () => {
console.log('PWA 已成功安装');
hideInstallButton();
deferredPrompt = null;
});

// 检测是否以 PWA 模式运行
function isPWAMode(): boolean {
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true // iOS Safari
);
}

4.2 更新策略

sw-update.ts — Service Worker 更新处理
// 主线程:检测并提示更新
async function checkForUpdate(): Promise<void> {
const registration = await navigator.serviceWorker.getRegistration();
if (!registration) return;

// 手动检查更新
await registration.update();

// 监听新 SW 安装
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;

newWorker.addEventListener('statechange', () => {
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
// 新 SW 已安装但还未激活(旧 SW 仍控制页面)
showUpdateBanner({
message: '新版本可用,是否立即更新?',
onAccept: () => {
// 通知新 SW 跳过等待
newWorker.postMessage({ type: 'SKIP_WAITING' });
},
});
}
});
});
}

// 监听 SW 控制权变更 → 刷新页面
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!refreshing) {
refreshing = true;
window.location.reload();
}
});
sw.ts — SW 端处理 skipWaiting
self.addEventListener('message', (event: ExtendableMessageEvent) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});

5. Push Notifications 推送通知

push.ts — 推送通知完整流程
// 1. 请求通知权限并订阅
async function subscribePush(): Promise<PushSubscription | null> {
// 检查权限
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('用户拒绝了通知权限');
return null;
}

const registration = await navigator.serviceWorker.ready;

// VAPID 公钥(服务端生成的密钥对的公钥部分)
const applicationServerKey = urlBase64ToUint8Array(
'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkPs7gjJi3RYgkBMDR...'
);

// 订阅推送
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // 必须为 true
applicationServerKey,
});

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

return subscription;
}

// 辅助函数:Base64 → Uint8Array
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from(rawData, (char) => char.charCodeAt(0));
}
sw.ts — 处理推送事件
// 2. Service Worker 接收推送
self.addEventListener('push', (event: PushEvent) => {
const data = event.data?.json() ?? {
title: '新消息',
body: '您有一条新通知',
};

const options: NotificationOptions = {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
image: data.image,
tag: data.tag || 'default', // 同 tag 的通知会被替换
renotify: true,
data: { url: data.url || '/' },
actions: [
{ action: 'open', title: '查看详情' },
{ action: 'dismiss', title: '忽略' },
],
};

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

// 3. 处理通知点击
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close();

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

if (event.action === 'dismiss') return;

// 如果已有窗口则聚焦,否则新开
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clients) => {
for (const client of clients) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
return self.clients.openWindow(url);
})
);
});
server.ts — 服务端推送(Node.js + web-push)
import webPush from 'web-push';

// 生成 VAPID 密钥对(只需生成一次)
// const vapidKeys = webPush.generateVAPIDKeys();

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

async function sendPushNotification(
subscription: webPush.PushSubscription,
payload: { title: string; body: string; url?: string }
): Promise<void> {
try {
await webPush.sendNotification(subscription, JSON.stringify(payload));
} catch (error: any) {
if (error.statusCode === 410) {
// 订阅已过期,从数据库中删除
await removeSubscription(subscription.endpoint);
}
throw error;
}
}

6. Background Sync 后台同步

Background Sync API 允许在网络恢复时自动执行待处理的操作:

background-sync.ts — 离线操作排队
// 主线程:注册后台同步
async function sendMessageOffline(message: {
text: string;
timestamp: number;
}): Promise<void> {
// 将消息存入 IndexedDB
const db = await openDB('offline-messages', 1, {
upgrade(db) {
db.createObjectStore('messages', { autoIncrement: true });
},
});
await db.add('messages', message);

// 注册后台同步
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-messages');
}
sw.ts — 处理后台同步事件
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncMessages());
}
});

async function syncMessages(): Promise<void> {
const db = await openDB('offline-messages', 1);
const tx = db.transaction('messages', 'readwrite');
const store = tx.objectStore('messages');
const messages = await store.getAll();

for (const message of messages) {
try {
await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message),
});
// 发送成功,从 IndexedDB 删除
await store.delete(message.id);
} catch {
// 网络仍不可用,下次同步时重试
break;
}
}
}

7. App Shell 架构

App Shell 是 PWA 推荐的架构模式,将 UI 框架(Header、Sidebar、Footer)与动态内容分离:

app-shell.ts — App Shell 缓存策略
const APP_SHELL_CACHE = 'app-shell-v1';
const APP_SHELL_FILES = [
'/',
'/index.html',
'/styles/shell.css',
'/scripts/app.js',
'/scripts/router.js',
'/images/logo.svg',
'/offline.html',
];

// install 时预缓存 App Shell
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(APP_SHELL_CACHE).then((cache) => cache.addAll(APP_SHELL_FILES))
);
});

// 导航请求返回 App Shell
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.mode === 'navigate') {
// 所有页面导航都返回同一个 App Shell HTML
// 然后由前端路由处理具体页面
event.respondWith(
caches.match('/index.html').then(
(cached) => cached || fetch(event.request)
)
);
}
});

8. PWA 与主流框架集成

8.1 Next.js + PWA

next.config.ts — Next.js PWA 配置
import withPWA from 'next-pwa';

const nextConfig = withPWA({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
},
},
},
],
})({
// Next.js 配置
});

export default nextConfig;

8.2 Vite + PWA

vite.config.ts — Vite PWA 配置
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate', // 自动更新
includeAssets: ['favicon.ico', 'robots.txt'],
manifest: {
name: '我的 PWA 应用',
short_name: 'MyPWA',
theme_color: '#1976d2',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
cacheableResponse: { statuses: [0, 200] },
},
},
],
},
}),
],
});
main.ts — 注册 SW(Vite PWA)
import { registerSW } from 'virtual:pwa-register';

const updateSW = registerSW({
onNeedRefresh() {
// 新版本可用,提示用户
if (confirm('新版本可用,是否更新?')) {
updateSW(true);
}
},
onOfflineReady() {
console.log('应用已可离线使用');
},
onRegistered(registration) {
// 每小时检查一次更新
setInterval(() => registration?.update(), 60 * 60 * 1000);
},
});

9. 离线功能与 IndexedDB

offline-storage.ts — IndexedDB 离线数据管理
import { openDB, DBSchema, IDBPDatabase } from 'idb';

interface MyDB extends DBSchema {
articles: {
key: string;
value: {
id: string;
title: string;
content: string;
updatedAt: number;
synced: boolean;
};
indexes: { 'by-synced': boolean };
};
}

class OfflineStore {
private dbPromise: Promise<IDBPDatabase<MyDB>>;

constructor() {
this.dbPromise = openDB<MyDB>('my-app', 1, {
upgrade(db) {
const store = db.createObjectStore('articles', { keyPath: 'id' });
store.createIndex('by-synced', 'synced');
},
});
}

async saveArticle(article: MyDB['articles']['value']): Promise<void> {
const db = await this.dbPromise;
await db.put('articles', { ...article, synced: false });
}

async getUnsyncedArticles(): Promise<MyDB['articles']['value'][]> {
const db = await this.dbPromise;
return db.getAllFromIndex('articles', 'by-synced', false);
}

async markSynced(id: string): Promise<void> {
const db = await this.dbPromise;
const article = await db.get('articles', id);
if (article) {
await db.put('articles', { ...article, synced: true });
}
}
}

export const offlineStore = new OfflineStore();

10. 平台差异与兼容性

特性Chrome/EdgeFirefoxSafari/iOSSamsung Internet
Service Worker完整支持完整支持支持(限制)完整支持
Web App Manifest完整支持部分支持部分支持完整支持
Push Notifications支持支持iOS 16.4+支持
Background Sync支持不支持不支持支持
Periodic Sync支持不支持不支持不支持
安装提示自动地址栏图标手动添加自动
badging API支持不支持支持支持
iOS Safari PWA 限制
  • 无推送通知(iOS 16.4 之前),16.4+ 支持但需要用户手动添加到主屏幕
  • 存储限制:每个域名 50MB,7 天不使用可能被清理
  • 无后台同步:不支持 Background Sync API
  • beforeinstallprompt:需引导用户手动"添加到主屏幕"
  • 独立会话:PWA 模式下 Cookie/Storage 与 Safari 不共享
  • WKWebView 限制:部分 Web API 表现与 Safari 不同
ios-compat.ts — iOS PWA 兼容处理
// 检测 iOS
function isIOS(): boolean {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
}

// 检测是否以 PWA 模式运行(iOS)
function isIOSPWA(): boolean {
return isIOS() && (window.navigator as any).standalone === true;
}

// iOS PWA 安装引导
function showIOSInstallGuide(): void {
if (isIOS() && !isIOSPWA()) {
showBanner({
message: '点击 Safari 分享按钮,选择"添加到主屏幕"即可安装',
icon: 'share-icon',
});
}
}

11. TWA(Trusted Web Activity)

TWA 是将 PWA 发布到 Google Play 的方式,在 Android 上使用 Chrome Custom Tabs 渲染 PWA:

# 使用 Bubblewrap CLI 将 PWA 打包为 Android 应用
npx @nicolo-ribaudo/bubblewrap init --manifest https://example.com/manifest.json
npx @nicolo-ribaudo/bubblewrap build
# 生成 app-release-signed.apk → 上传 Google Play
PWA 的发布渠道
  • 浏览器安装:最基本的方式,所有支持 PWA 的浏览器
  • Google Play(TWA):通过 Bubblewrap 打包为 Android 应用
  • Microsoft Store:Windows 上可将 PWA 上架到 Microsoft Store
  • App Store:iOS 不支持直接上架 PWA,需包装为 WKWebView 应用

12. 性能优化

performance.ts — PWA 性能优化策略
// 1. 预缓存关键路由
const PRECACHE_ROUTES = ['/', '/dashboard', '/settings'];

// 2. 使用 Navigation Preload 加速
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
// 启用 Navigation Preload
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
})()
);
});

self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
try {
// 使用预加载的响应
const preloadResponse = await event.preloadResponse;
if (preloadResponse) return preloadResponse;

return await fetch(event.request);
} catch {
// 离线回退
const cache = await caches.open(APP_SHELL_CACHE);
return (await cache.match('/offline.html'))!;
}
})()
);
}
});

// 3. 流式响应组合 —— 先返回 Shell,再拼接内容
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.url.includes('/articles/')) {
event.respondWith(
(async () => {
const [header, content, footer] = await Promise.all([
caches.match('/shell/header.html'),
fetch(event.request).catch(() => caches.match('/offline-content.html')),
caches.match('/shell/footer.html'),
]);

const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const encoder = new TextEncoder();

(async () => {
for (const part of [header, content, footer]) {
if (part) {
const text = await part.text();
await writer.write(encoder.encode(text));
}
}
await writer.close();
})();

return new Response(readable, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
})()
);
}
});
PWA 性能优化清单
  • 使用 App Shell 模式分离静态框架和动态内容
  • 启用 Navigation Preload 减少 SW 启动延迟
  • 合理选择缓存策略:静态资源 Cache First,API Network First
  • 使用 Workbox 的预缓存和运行时缓存
  • 懒加载非关键资源(图片、字体、非首屏 JS)
  • 压缩资源:Brotli/Gzip,WebP/AVIF 图片
  • 使用 Lighthouse 定期审计 PWA 得分

13. PWA vs 其他跨端方案

维度PWAReact NativeFlutter小程序Electron
技术栈Web 标准React + JSDartJS(受限)Web + Node.js
安装浏览器/应用商店应用商店应用商店宿主 App安装包
离线能力Service Worker原生存储原生存储有限缓存完整文件系统
推送通知Push API(受限)原生推送原生推送模板消息系统通知
性能接近原生接近原生接近原生中等较重
包体积0(Web)较大较大受限很大(100MB+)
开发成本
发布更新即时需审核需审核需审核需下载更新
什么场景适合 PWA
  • 内容型网站(新闻、博客、文档)—— 离线阅读、快速加载
  • 电商网站 —— 添加到主屏幕、推送通知提高留存
  • 内部工具 —— 免安装、免上架、快速迭代
  • 轻量级应用 —— 不需要深度原生能力的场景
  • 新兴市场 —— 低端设备、网络不稳定、流量昂贵

常见面试问题

Q1: Service Worker 和 Web Worker 有什么区别?

答案

对比维度Service WorkerWeb Worker
生命周期独立于页面,可在后台运行随页面关闭而销毁
作用域可拦截 scope 内所有页面的请求只服务于创建它的页面
网络拦截可拦截 fetch 请求不能拦截网络请求
缓存控制可操作 Cache API可操作但通常不用
推送通知支持 push 事件不支持
HTTPS 要求必须 HTTPS不要求
复用性多个页面共享同一个 SW每个页面独立创建

核心区别是 Service Worker 是网络代理,位于浏览器和网络之间,可以拦截和缓存请求;Web Worker 是计算线程,用于将 CPU 密集任务移出主线程。

Q2: 如何处理 Service Worker 更新后的缓存失效问题?

答案

// 方案 1:版本化缓存名
const CACHE_VERSION = 'v2'; // 更新时修改版本号
const CACHE_NAME = `app-cache-${CACHE_VERSION}`;

// activate 时清理旧版本缓存
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => name.startsWith('app-cache-') && name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
)
);
});

// 方案 2:使用 Workbox 的 precache 自动管理
// Workbox 会根据文件 hash 自动更新缓存

// 方案 3:SW 更新后通知用户刷新
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
// 主线程监听 controllerchange 后 reload

关键原则:

  • 缓存名加版本号,activate 时清理旧缓存
  • 使用 skipWaiting() + clients.claim() 立即接管
  • 通知用户刷新页面获取最新内容

Q3: PWA 的离线方案如何设计?

答案

分三层设计:

  1. App Shell 预缓存:在 install 事件中缓存 HTML 骨架、核心 CSS/JS
  2. 运行时缓存策略
    • 静态资源(图片、字体)→ Cache First
    • API 数据 → Network First + 离线回退
    • CDN 资源 → Stale While Revalidate
  3. 离线数据同步
    • IndexedDB 本地存储用户操作
    • Background Sync 网络恢复时自动同步
    • 冲突解决策略(最后写入胜出 / 服务端合并)
// 离线回退页面
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() =>
caches.match('/offline.html') as Promise<Response>
)
);
}
});

Q4: 如何提高 PWA 的 Lighthouse 得分?

答案

PWA Lighthouse 审计项主要包括:

审计项满足条件
可安装有效的 manifest.json + Service Worker + HTTPS
离线可用离线时返回 200 状态码的页面
启动 URLstart_url 在离线时可访问
HTTPS 重定向HTTP 自动重定向到 HTTPS
自定义闪屏manifest 中配置 name + background_color + theme_color + icons
视口设置有 viewport meta 标签
主题色manifest 和 meta 标签中有 theme_color

优化建议:

  • 确保 manifest.json 字段完整(name、icons 至少 192px 和 512px)
  • SW 注册成功且拦截 fetch 事件
  • 离线页面有意义的内容(不是浏览器默认错误页)
  • 配置 apple-touch-icon 兼容 iOS

Q5: beforeinstallprompt 事件在什么条件下触发?

答案

Chrome 的安装条件(其他浏览器类似):

  1. Web App Manifest 包含:name/short_name、icons(至少 192px 和 512px)、start_url、display(standalone/fullscreen/minimal-ui)
  2. 注册了 Service Worker,且有 fetch 事件监听
  3. HTTPS 提供服务
  4. 用户参与度达到阈值(用户与页面有一定交互)
  5. 未被安装
// 监听 beforeinstallprompt
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault(); // 阻止自动弹出
// 保存事件,在合适的时机手动触发
deferredPrompt = e as BeforeInstallPromptEvent;
});

// 最佳实践:在用户完成关键操作后提示安装
// 例如:浏览了 3 篇文章、完成了注册、使用了核心功能
注意
  • iOS Safari 不支持 beforeinstallprompt
  • Firefox 使用地址栏小图标而非弹窗提示
  • 用户拒绝安装后,短期内不会再次触发

Q6: Service Worker 的作用域(scope)是如何工作的?

答案

// SW 的 scope 决定它能拦截哪些请求
navigator.serviceWorker.register('/sw.js', { scope: '/' });
// 拦截整个站点

navigator.serviceWorker.register('/app/sw.js', { scope: '/app/' });
// 只拦截 /app/ 下的请求

// 注意:SW 的 scope 不能超过 SW 文件所在目录
// /scripts/sw.js 的最大 scope 是 /scripts/
// 除非服务器返回 Service-Worker-Allowed 头

scope 规则:

  • 默认 scope 是 SW 文件所在目录
  • 可以缩小 scope 但不能超出 SW 文件目录(除非设置响应头)
  • 一个页面只能被一个 SW 控制
  • 如果多个 SW 的 scope 有重叠,最精确的匹配优先

Q7: PWA 和 SPA 的关系是什么?

答案

PWA 和 SPA 不是互斥的概念:

  • SPA(Single Page Application)是一种前端架构模式,页面不重载,通过 JS 动态更新内容
  • PWA 是一种应用增强能力,赋予 Web 应用离线、安装、推送等原生能力

它们可以组合使用:

  • SPA + PWA:最常见的组合,App Shell 架构天然适合 SPA
  • MPA + PWA:多页应用也可以使用 Service Worker 和 Manifest
  • SSR + PWA:Next.js 等框架可以结合 PWA 能力

Q8: 如何调试 Service Worker?

答案

  1. Chrome DevTools

    • Application > Service Workers:查看 SW 状态、手动更新/取消注册
    • Application > Cache Storage:查看缓存内容
    • Application > Manifest:检查 manifest 配置
    • 勾选 "Update on reload" 方便开发调试
  2. 调试技巧

// 开发环境跳过 SW
if (process.env.NODE_ENV === 'development') {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((reg) => reg.unregister());
});
}

// SW 内部调试日志
self.addEventListener('fetch', (event: FetchEvent) => {
console.log('[SW] Fetch:', event.request.url);
// ...
});
  1. Lighthouse PWA 审计F12 > Lighthouse > Progressive Web App
  2. chrome://serviceworker-internals/:查看所有已注册的 SW
  3. 远程调试chrome://inspect/#service-workers

Q9: 如何实现 PWA 的增量更新?

答案

// 1. 使用 Workbox precache —— 自动增量更新
// Workbox 会为每个文件生成 revision hash
// 只更新变化的文件
precacheAndRoute([
{ url: '/index.html', revision: 'abc123' },
{ url: '/styles/main.css', revision: 'def456' },
// 构建工具自动注入
]);

// 2. 手动增量更新
const CACHE_VERSION = 2;
const NEW_FILES = ['/scripts/feature-x.js']; // 本次更新的文件
const DELETED_FILES = ['/scripts/old-feature.js']; // 需要删除的文件

self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(`app-v${CACHE_VERSION}`).then(async (cache) => {
// 添加新文件
await cache.addAll(NEW_FILES);
// 删除旧文件
for (const file of DELETED_FILES) {
await cache.delete(file);
}
})
);
});

Q10: PWA 在弱网环境下的优化策略?

答案

// 1. 使用 Network Information API 检测网络质量
function getNetworkStrategy(): 'aggressive-cache' | 'normal' | 'save-data' {
const conn = (navigator as any).connection;
if (!conn) return 'normal';

if (conn.saveData) return 'save-data';
if (conn.effectiveType === 'slow-2g' || conn.effectiveType === '2g') {
return 'aggressive-cache';
}
return 'normal';
}

// 2. 根据网络质量调整策略
self.addEventListener('fetch', (event: FetchEvent) => {
const strategy = getNetworkStrategy();

if (strategy === 'aggressive-cache') {
// 弱网:优先使用缓存,设置短超时
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
const controller = new AbortController();
setTimeout(() => controller.abort(), 3000); // 3秒超时
return fetch(event.request, { signal: controller.signal })
.catch(() => caches.match('/offline.html') as Promise<Response>);
})
);
} else if (strategy === 'save-data') {
// 省流模式:不加载非关键资源
if (event.request.destination === 'image') {
event.respondWith(
caches.match('/placeholder.svg') as Promise<Response>
);
return;
}
}
});

// 3. 预加载关键资源
async function preloadCriticalResources(): Promise<void> {
const cache = await caches.open('preload');
const criticalUrls = ['/api/user/profile', '/api/feed?page=1'];
await Promise.allSettled(
criticalUrls.map((url) =>
fetch(url).then((res) => cache.put(url, res))
)
);
}

Q11: 如何实现 PWA 的主题切换和偏好同步?

答案

// manifest.json 动态主题色
function updateThemeColor(color: string): void {
// 更新 meta 标签
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', color);
}

// 监听系统主题偏好
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
darkQuery.addEventListener('change', (e) => {
updateThemeColor(e.matches ? '#1a1a1a' : '#ffffff');
});

// 偏好同步到 Service Worker
navigator.serviceWorker.controller?.postMessage({
type: 'SET_PREFERENCE',
payload: { theme: 'dark', language: 'zh-CN' },
});

// SW 端接收偏好
self.addEventListener('message', (event: ExtendableMessageEvent) => {
if (event.data?.type === 'SET_PREFERENCE') {
// 存储到 IndexedDB,影响缓存策略
savePreference(event.data.payload);
}
});

Q12: PWA 安全性需要注意什么?

答案

  1. HTTPS 强制要求:Service Worker 只在 HTTPS 环境下工作(localhost 除外)

  2. CSP(Content Security Policy)

<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';">
  1. SW 安全考虑

    • SW 可以拦截所有网络请求,恶意 SW 可能窃取数据
    • 浏览器会验证 SW 文件的完整性(字节比较)
    • SW 不能跨域注册
  2. 缓存安全

// 不要缓存敏感数据
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);
// 不缓存认证相关请求
if (url.pathname.startsWith('/api/auth/')) {
event.respondWith(fetch(event.request));
return;
}
// 不缓存带 Authorization 头的请求
if (event.request.headers.has('Authorization')) {
event.respondWith(fetch(event.request));
return;
}
});
  1. 推送安全:使用 VAPID 认证防止未授权推送

Q13: Service Worker 中如何处理跨域请求的缓存?

答案

self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);

// 同源请求:正常缓存
if (url.origin === self.location.origin) {
event.respondWith(cacheFirst(event.request));
return;
}

// 跨域请求:opaque response
// 注意:跨域响应(no-cors)是 opaque 的
// - 无法读取响应内容或状态码
// - Cache API 仍可存储,但每个 opaque 响应占用 ~7MB 配额
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
// 只缓存成功的响应
if (response.status === 0 || response.status === 200) {
const cache = caches.open('cross-origin');
cache.then((c) => c.put(event.request, response.clone()));
}
return response;
});
})
);
});
opaque 响应的坑
  • response.status 为 0(无法判断是否成功)
  • response.body 为 null(无法读取内容)
  • 每个 opaque 缓存项占用 ~7MB 存储配额
  • 解决方案:尽量使用 CORS 或代理服务器

Q14: 如何检测用户是否通过 PWA 方式访问?

答案

// 方法 1:CSS media query
// @media (display-mode: standalone) { ... }
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;

// 方法 2:iOS Safari
const isIOSStandalone = (window.navigator as any).standalone === true;

// 方法 3:通过 start_url 参数
// manifest.json: "start_url": "/?utm_source=pwa"
const isPWAFromURL = new URLSearchParams(window.location.search)
.get('utm_source') === 'pwa';

// 综合检测
function detectPWAMode(): 'standalone' | 'browser' | 'twa' {
if (document.referrer.startsWith('android-app://')) return 'twa';
if (isStandalone || isIOSStandalone) return 'standalone';
return 'browser';
}

// 方法 4:通过 Launch Queue API(更精确)
if ('launchQueue' in window) {
(window as any).launchQueue.setConsumer((params: any) => {
// PWA 启动时触发
console.log('PWA launched with:', params);
});
}

Q15: PWA 的存储配额是多少?如何管理?

答案

// 查询存储配额
async function checkStorageQuota(): Promise<void> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
const used = estimate.usage || 0;
const quota = estimate.quota || 0;
console.log(`已用: ${(used / 1024 / 1024).toFixed(2)} MB`);
console.log(`配额: ${(quota / 1024 / 1024).toFixed(2)} MB`);
console.log(`使用率: ${((used / quota) * 100).toFixed(1)}%`);
}
}

// 请求持久化存储(防止被浏览器自动清理)
async function requestPersistentStorage(): Promise<boolean> {
if ('storage' in navigator && 'persist' in navigator.storage) {
const granted = await navigator.storage.persist();
console.log(`持久化存储: ${granted ? '已授权' : '未授权'}`);
return granted;
}
return false;
}

// 主动清理过期缓存
async function cleanupCache(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<void> {
const cacheNames = await caches.keys();
for (const name of cacheNames) {
const cache = await caches.open(name);
const keys = await cache.keys();
for (const request of keys) {
const response = await cache.match(request);
if (response) {
const dateHeader = response.headers.get('date');
if (dateHeader) {
const age = Date.now() - new Date(dateHeader).getTime();
if (age > maxAge) {
await cache.delete(request);
}
}
}
}
}
}
浏览器存储配额持久化存储自动清理
Chrome磁盘空间的 60%支持LRU 淘汰
Firefox磁盘空间的 50%支持LRU 淘汰
Safari~1GB(移动端更少)不支持7 天未使用清理
Edge同 Chrome支持LRU 淘汰

相关链接