PWA(Progressive Web App)在 Chrome/Edge/Safari/iOS 16.4+ 都已原生支持,能把 Web 应用安装到用户桌面,离线可用、离线提交、网络恢复自动同步,体验已接近原生 App,且无需过审上架商店。
Service Worker 缓存策略
App Shell(HTML壳+首屏资源)用 NetworkFirst,3 秒未响应回退缓存,保障弱网秒开;静态图片/字体/脚本用 Cache First + 容量上限;API GET 列表页用 Stale-While-Revalidate(立返缓存 + 后台静默更新),让数据总是新的且加载无延迟。
// ====== sw.ts Service Worker - Vite 推荐用 vite-plugin-pwa ======
import { PrecacheEntry } from 'workbox-precaching/_types';
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate, NetworkFirst } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
import { backgroundSyncQueue } from './lib/bg-sync';
declare const self: ServiceWorkerGlobalScope;
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST as unknown as PrecacheEntry[]);
// 1. SPA 导航请求:NetworkFirst,失败 fallback 到 /index.html
registerRoute(
new NavigationRoute(new NetworkFirst({
networkTimeoutSeconds: 3,
cacheName: 'app-shell',
plugins: [new CacheableResponsePlugin({ statuses: [0, 200] })]
}))
);
// 2. 静态资源:Cache First 且限制数量
registerRoute(
({ request, sameOrigin }) => sameOrigin &&
['style', 'script', 'worker', 'font', 'image'].includes(request.destination),
new CacheFirst({
cacheName: 'static-assets-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 })
]
})
);
// 3. API GET:Stale-While-Revalidate — 立返缓存,后台静默更新
registerRoute(
({ url, request }) => request.method === 'GET' && url.pathname.startsWith('/api/v1/'),
new StaleWhileRevalidate({
cacheName: 'api-read-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 60 * 5 })
]
})
);
// 4. API POST/PUT/DELETE:失败时进入 Background Sync 队列
self.addEventListener('fetch', (event: FetchEvent) => {
const req = event.request;
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return;
if (!req.url.includes('/api/v1/')) return;
event.respondWith((async () => {
try {
const cloned = req.clone();
return await fetch(req);
} catch (err) {
await backgroundSyncQueue.enqueue(cloned);
await self.registration.sync.register('outbox-sync');
return new Response(JSON.stringify({
ok: false,
queued: true,
message: '已进入离线队列,网络恢复后自动发送'
}), { status: 202, headers: { 'Content-Type': 'application/json' } });
}
})());
});
// ====== lib/idb.ts — IndexedDB 封装(用 idb 库)======
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface AppDB extends DBSchema {
drafts: {
key: string;
value: { id: string; title: string; body: string; createdAt: number; pending: boolean };
indexes: { 'by-created': number; 'by-pending': boolean };
};
settings: { key: string; value: any };
}
const DB_NAME = 'pwa-app-db';
const DB_VERSION = 2;
export async function getDB(): Promise<IDBPDatabase<AppDB>> {
return openDB<AppDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVer, newVer, tx) {
if (!db.objectStoreNames.contains('drafts')) {
const s = db.createObjectStore('drafts', { keyPath: 'id' });
s.createIndex('by-created', 'createdAt');
s.createIndex('by-pending', 'pending');
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings');
}
}
});
}
export const DraftRepo = {
async list(pending?: boolean) {
const db = await getDB();
if (pending !== undefined) return db.getAllFromIndex('drafts', 'by-pending', pending);
return db.getAll('drafts');
},
async save(draft: AppDB['drafts']['value']) {
const db = await getDB();
await db.put('drafts', draft);
},
async delete(id: string) {
const db = await getDB();
await db.delete('drafts', id);
}
};
// ====== 应用入口 main.ts 注册 SW + 离线状态监听 ======
import { registerSW } from 'virtual:pwa-register';
const updateSW = registerSW({
immediate: true,
onOfflineReady() { console.log('[PWA] 缓存就绪,可离线使用'); },
onNeedRefresh() {
if (confirm('检测到新版本,是否立即刷新?')) updateSW(true);
},
});
window.addEventListener('online', async () => {
if ('SyncManager' in window) return;
// 浏览器不支持 SyncManager 时兜底:上线立即重放队列
const pending = await DraftRepo.list(true);
for (const d of pending) {
try {
await fetch('/api/v1/drafts', { method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(d) });
d.pending = false; await DraftRepo.save(d);
} catch {}
}
});
export function useApp() {
const isOnline = ref(navigator.onLine);
onMounted(() => {
window.addEventListener('online', () => (isOnline.value = true));
window.addEventListener('offline', () => (isOnline.value = false));
});
return { isOnline, DraftRepo };
}
IndexedDB 结构化存储 + Background Sync
localStorage 只有 5MB 同步阻塞,不适合结构化数据。用 IndexedDB 存草稿/聊天记录/订单,可查询、可索引、可批量,容量可达数 GB。所有写操作(POST/PUT/DELETE)失败时推入 Background Sync 队列,注册 sync 事件,用户重新联网后 Service Worker 自动按顺序重放,保证离线操作 100% 不丢。
| 存储技术 | 容量 | 异步 | 结构化/索引 | 持久化 | 适用场景 |
|---|---|---|---|---|---|
| localStorage | ~5MB | 否 | 否 | 部分手动清 | Token/偏好设置 |
| sessionStorage | ~5MB | 否 | 否 | 会话内消失 | 临时状态 |
| Cache API | 不限策略制 | 是 | URL键/Response | 按缓存策略 | 静态文件+API GET响应 |
| IndexedDB | 数十GB | 是 | 完整Schema/索引/事务 | 持久保存 | 结构化业务数据 |
| OPFS (File System) | 数百GB | 是 | 文件流API | 持久保存 | 大文件/媒体编辑 |
最佳实践
用 Vite + vite-plugin-pwa 避免手写 Manifest/SW。iOS 有 3 个大坑:SW 每 7 天重置、推送通知需添加到主屏后才可用、视频 autoplay 需静音。写操作一定要在应用层给 UI 反馈:"已保存草稿,稍后联网自动同步"。