提示:本网站现在为维护调试状态

PWA 離線應用實戰:Service Worker + IndexedDB + Background Sync

Choyeon
Choyeon
2026年9月28日 1 分鐘閱讀 327 閱讀
1,104 字 4,609 字元 更新於 2026年11月8日
PWA 離線應用實戰:Service Worker + IndexedDB + Background Sync
目錄

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

X微博Telegram

本文作者

投票

你最希望 Rosetta 优先加入哪项功能?

社区投票(演示数据,可后台删除)

留言 (4)

0 / 3000
独立开发者阿明
独立开发者阿明aming.dev2026年9月30日

看到博主推荐的这个 UI 组件库了,之前一直在纠结选哪个,试了一下确实不错,文档也很友好!

奶茶味程序媛
奶茶味程序媛2026年9月29日

建议可以加一下关于无障碍访问(a11y)的部分,现在很多项目都不重视这块,但真的很重要。

熬夜攻城狮
熬夜攻城狮nightowl.tech2026年9月29日

有个小小的建议:代码示例如果能加上语法高亮和行号就更好啦,阅读体验会更上一层楼~

安全小白白
安全小白白seclab.digital2026年9月28日

作为一个后端转全栈的,看你的前端文章从来不会觉得难懂,讲得特别接地气,谢谢!

相關文章

Astro 靜態站點深度:群島架構 & 內容集合

Astro 靜態站點深度:群島架構 & 內容集合

深入理解 Astro 核心創新:群島架構實現最小化 JS 注入,僅交互元件按需水合;內容集合提供類型安全的 Markdown/MDX 管理,構建零 JS 的高性能靜態站點。

1 分鐘閱讀
Vue 3 組合式 API 實戰

Vue 3 組合式 API 實戰

Vue 3 的組合式 API (Composition API) 徹底改變了 Vue 組件的組織方式。本文通過構建一個複雜的電商篩選器組件,演示 ref/reactive 的選擇、computed/watch 的合理使用、自定義組合函數 (Composable) 的抽離原則、生命週期鉤子的時機以及 provide/inject 進行跨層級狀態傳遞的正確姿勢。

1 分鐘閱讀
Tailwind CSS v4 新特性:Zero-config + CSS-native 變數

Tailwind CSS v4 新特性:Zero-config + CSS-native 變數

Tailwind CSS v4 是一次架構級升級:徹底拋棄 tailwind.config.js,用 @theme 塊在原生 CSS 文件聲明設計令牌;新增 @import "tailwindcss" 單入口代替 postcss.config;內置 CSS 變量、容器查詢原生支持、oxc 引擎提速 10-40 倍、顏色函數簡化。

1 分鐘閱讀
前端性能優化:Core Web Vitals 實戰

前端性能優化:Core Web Vitals 實戰

Google 搜索排名核心指標完全實戰:LCP 最大內容繪製(預連接/圖像優先級/CDN緩存)、CLS 累積佈局偏移(圖像尺寸/字體FOIT/動態佔位)、INP 交互響應(長任務拆分/防抖節流)全面落地。

1 分鐘閱讀
React 18 Hooks 最佳實踐

React 18 Hooks 最佳實踐

React 18 引入了並發渲染、自動批處理、useTransition、useDeferredValue 等革命性特性。本文系統梳理 useState/useEffect 的正確用法、useMemo/useCallback 的優化邊界、自定義 Hook 的設計模式,以及並發模式下避免陳舊閉包、競態條件等陷阱的完整指南,幫助你寫出更健壯的 React 18 代碼。

1 分鐘閱讀
CSS Container Queries 完全指南

CSS Container Queries 完全指南

徹底掌握現代響應式 CSS:Container Queries 讓元件基於父容器尺寸自適應(而非視口),配合 :has() 父選擇器實現依賴後代狀態的樣式變化,徹底改變元件化響應式設計範式。

1 分鐘閱讀