PWA 离线应用实战:Service Worker + IndexedDB + Background Sync

Choyeon· 2026年9月28日· 4 分钟阅读· 167 阅读· 1,104 字· 4,609 字符· 更新于 2026年10月1日
PWA 离线应用实战:Service Worker + IndexedDB + Background Sync

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 反馈:"已保存草稿,稍后联网自动同步"。

本文作者

评论 (0)

暂无评论,来抢沙发吧。