PWA 渐进式 Web 应用
问题
PWA(Progressive Web App)的核心技术是什么?如何实现离线访问、推送通知和类原生体验?
答案
1. PWA 是什么
PWA(Progressive Web App) 是一种利用现代 Web 技术构建的应用形态,能在浏览器中提供类原生的用户体验。它不是某个具体的 API 或框架,而是一组技术方案和设计理念的集合。
PWA 的核心特征:
| 特征 | 说明 |
|---|---|
| 渐进增强 | 在任何浏览器中可用,功能逐步增强 |
| 响应式 | 适配桌面、移动端、平板等各种设备 |
| 离线可用 | Service Worker 实现离线访问能力 |
| 类原生体验 | 可添加到主屏幕,全屏运行 |
| 可安装 | 无需应用商店,通过浏览器直接安装 |
| 可推送 | 支持 Push API 推送通知 |
| 安全 | 必须通过 HTTPS 提供服务 |
| 可链接 | 通过 URL 分享,无需安装 |
- Service Worker —— 离线缓存与网络代理
- Web App Manifest —— 安装与外观配置
- HTTPS —— 安全保障(Service Worker 强制要求)
2. Service Worker 详解
Service Worker 是 PWA 的核心。它是一个在浏览器后台运行的独立线程,充当 Web 应用、浏览器和网络之间的代理服务器。
2.1 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);
}
});
}
/// <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);
})
);
});
- 必须 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 First | API、实时数据 | 优先展示最新数据 | 网络慢时体验差 |
| Stale While Revalidate | 频繁更新但非实时 | 快速响应 + 后台更新 | 第一次访问略慢 |
| Network Only | 需要实时的请求(支付) | 数据始终最新 | 离线不可用 |
| Cache Only | 预缓存的静态资源 | 极快响应 | 无法更新 |
2.3 Workbox 工具库
Workbox 是 Google 开发的 Service Worker 工具库,大幅简化缓存管理:
- npm
- Yarn
- pnpm
- Bun
npm install workbox-webpack-plugin workbox-recipes workbox-routing workbox-strategies workbox-precaching
yarn add workbox-webpack-plugin workbox-recipes workbox-routing workbox-strategies workbox-precaching
pnpm add workbox-webpack-plugin workbox-recipes workbox-routing workbox-strategies workbox-precaching
bun add workbox-webpack-plugin workbox-recipes workbox-routing workbox-strategies workbox-precaching
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 年
}),
],
})
);
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$/],
}),
],
};
- GenerateSW:自动生成 SW 文件,适合简单场景
- InjectManifest:注入预缓存清单到自定义 SW,适合复杂需求(推荐)
3. Web App Manifest
Web App Manifest 是一个 JSON 文件,定义 PWA 的安装信息和外观:
{
"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/*"] }]
}
}
}
<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)
// 拦截浏览器默认安装提示
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 更新策略
// 主线程:检测并提示更新
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();
}
});
self.addEventListener('message', (event: ExtendableMessageEvent) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
5. Push Notifications 推送通知
// 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));
}
// 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);
})
);
});
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 允许在网络恢复时自动执行待处理的操作:
// 主线程:注册后台同步
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');
}
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)与动态内容分离:
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
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
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] },
},
},
],
},
}),
],
});
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
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/Edge | Firefox | Safari/iOS | Samsung Internet |
|---|---|---|---|---|
| Service Worker | 完整支持 | 完整支持 | 支持(限制) | 完整支持 |
| Web App Manifest | 完整支持 | 部分支持 | 部分支持 | 完整支持 |
| Push Notifications | 支持 | 支持 | iOS 16.4+ | 支持 |
| Background Sync | 支持 | 不支持 | 不支持 | 支持 |
| Periodic Sync | 支持 | 不支持 | 不支持 | 不支持 |
| 安装提示 | 自动 | 地址栏图标 | 手动添加 | 自动 |
| badging API | 支持 | 不支持 | 支持 | 支持 |
- 无推送通知(iOS 16.4 之前),16.4+ 支持但需要用户手动添加到主屏幕
- 存储限制:每个域名 50MB,7 天不使用可能被清理
- 无后台同步:不支持 Background Sync API
- 无
beforeinstallprompt:需引导用户手动"添加到主屏幕" - 独立会话:PWA 模式下 Cookie/Storage 与 Safari 不共享
- WKWebView 限制:部分 Web API 表现与 Safari 不同
// 检测 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 的浏览器
- Google Play(TWA):通过 Bubblewrap 打包为 Android 应用
- Microsoft Store:Windows 上可将 PWA 上架到 Microsoft Store
- App Store:iOS 不支持直接上架 PWA,需包装为 WKWebView 应用
12. 性能优化
// 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' },
});
})()
);
}
});
- 使用 App Shell 模式分离静态框架和动态内容
- 启用 Navigation Preload 减少 SW 启动延迟
- 合理选择缓存策略:静态资源 Cache First,API Network First
- 使用 Workbox 的预缓存和运行时缓存
- 懒加载非关键资源(图片、字体、非首屏 JS)
- 压缩资源:Brotli/Gzip,WebP/AVIF 图片
- 使用 Lighthouse 定期审计 PWA 得分
13. PWA vs 其他跨端方案
| 维度 | PWA | React Native | Flutter | 小程序 | Electron |
|---|---|---|---|---|---|
| 技术栈 | Web 标准 | React + JS | Dart | JS(受限) | Web + Node.js |
| 安装 | 浏览器/应用商店 | 应用商店 | 应用商店 | 宿主 App | 安装包 |
| 离线能力 | Service Worker | 原生存储 | 原生存储 | 有限缓存 | 完整文件系统 |
| 推送通知 | Push API(受限) | 原生推送 | 原生推送 | 模板消息 | 系统通知 |
| 性能 | 接近原生 | 接近原生 | 接近原生 | 中等 | 较重 |
| 包体积 | 0(Web) | 较大 | 较大 | 受限 | 很大(100MB+) |
| 开发成本 | 低 | 中 | 中 | 低 | 中 |
| 发布更新 | 即时 | 需审核 | 需审核 | 需审核 | 需下载更新 |
- 内容型网站(新闻、博客、文档)—— 离线阅读、快速加载
- 电商网站 —— 添加到主屏幕、推送通知提高留存
- 内部工具 —— 免安装、免上架、快速迭代
- 轻量级应用 —— 不需要深度原生能力的场景
- 新兴市场 —— 低端设备、网络不稳定、流量昂贵
常见面试问题
Q1: Service Worker 和 Web Worker 有什么区别?
答案:
| 对比维度 | Service Worker | Web 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 的离线方案如何设计?
答案:
分三层设计:
- App Shell 预缓存:在 install 事件中缓存 HTML 骨架、核心 CSS/JS
- 运行时缓存策略:
- 静态资源(图片、字体)→ Cache First
- API 数据 → Network First + 离线回退
- CDN 资源 → Stale While Revalidate
- 离线数据同步:
- 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 状态码的页面 |
| 启动 URL | start_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 的安装条件(其他浏览器类似):
- Web App Manifest 包含:name/short_name、icons(至少 192px 和 512px)、start_url、display(standalone/fullscreen/minimal-ui)
- 注册了 Service Worker,且有 fetch 事件监听
- HTTPS 提供服务
- 用户参与度达到阈值(用户与页面有一定交互)
- 未被安装
// 监听 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?
答案:
-
Chrome DevTools:
Application > Service Workers:查看 SW 状态、手动更新/取消注册Application > Cache Storage:查看缓存内容Application > Manifest:检查 manifest 配置- 勾选 "Update on reload" 方便开发调试
-
调试技巧:
// 开发环境跳过 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);
// ...
});
- Lighthouse PWA 审计:
F12 > Lighthouse > Progressive Web App - chrome://serviceworker-internals/:查看所有已注册的 SW
- 远程调试:
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 安全性需要注意什么?
答案:
-
HTTPS 强制要求:Service Worker 只在 HTTPS 环境下工作(localhost 除外)
-
CSP(Content Security Policy):
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';">
-
SW 安全考虑:
- SW 可以拦截所有网络请求,恶意 SW 可能窃取数据
- 浏览器会验证 SW 文件的完整性(字节比较)
- SW 不能跨域注册
-
缓存安全:
// 不要缓存敏感数据
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;
}
});
- 推送安全:使用 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;
});
})
);
});
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 淘汰 |