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

PWA オフラインアプリ実践:Service Worker + IndexedDB + Background Sync

Choyeon
Choyeon
2026年9月28日 1 分で読了 334 閲覧
906 字 5,319 文字 更新日 2026年11月8日
PWA オフラインアプリ実践:Service Worker + IndexedDB + Background Sync
目次

PWA Offline App in Practice: Service Worker + IndexedDB + Background Sync

PWA is natively supported in Chrome/Edge/Safari/iOS 16.4+. Installs to user home screen, works offline, accepts writes offline, replays on network return. Experience approaches native without App Store review walls.

Service Worker Caching Strategies

App Shell (HTML + first-paint assets) uses NetworkFirst with 3s timeout fallback to cache for instant weak-network render. Static images/fonts/scripts use Cache First with capacity caps. API GET list endpoints use Stale-While-Revalidate — return cache instantly while background silently fetches fresh copy — always-new data with zero perceived latency.

// ====== 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 Structured Store + Background Sync

localStorage is 5MB sync-blocking and unsuitable for structured data. IndexedDB stores drafts/chat/orders with queries, indexes, batches, multi-GB capacity. All writes (POST/PUT/DELETE) that fail go into Background Sync queue; sync event fires when user reconnects and SW replays them in sequence, guaranteeing zero loss for offline actions.

Storage Quota Async Structured/Indexes Persistence Use Case
localStorage ~5MB No No Partial manual purge Tokens, prefs
sessionStorage ~5MB No No Per session only Temp state
Cache API Unbounded (policy) Yes URL key / Response Per cache strategy Static files + API GET responses
IndexedDB Tens of GB Yes Full schema/indexes/transactions Persistent Structured business data
OPFS (File System) Hundreds GB Yes File stream API Persistent Large files, media editing

Best Practices

Use Vite + vite-plugin-pwa to skip manual Manifest/SW boilerplate. Three iOS gotchas: SW resets every 7 days, push requires add-to-home-screen, video autoplay needs mute. Always give app-layer UI feedback for writes: "Saved as draft. Will sync when you're back online."

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 は、Vue コンポーネントの設計手法を根本的に変えました。複雑な EC サイトのフィルターコンポーネントの構築を通じて、ref/reactive の使い分け、computed/watch の適切な使用方法、カスタム Composable の抽出原則、ライフサイクルフックのタイミング、provide/inject による階層を超えた状態受け渡しの正しい方法を解説します。

1 分で読了
Tailwind CSS v4 新機能:Zero-config + CSS ネイティブ変数

Tailwind CSS v4 新機能:Zero-config + CSS ネイティブ変数

Tailwind CSS v4はアーキテクチャ級アップデート:tailwind.config.js を廃止し、ネイティブCSS内の@themeブロックでデザイントークン宣言。@import "tailwindcss" 単一エントリでpostcss設定不要。CSS変数内蔵、Container Queriesネイティブ対応、oxcエンジンで10-40倍高速化、カラー関数簡略化。

1 分で読了
フロントエンド性能:Core Web Vitals 実践

フロントエンド性能:Core Web Vitals 実践

Google検索順位のCore Web Vitals完全実践:LCP最適化(事前接続・画像優先度・CDN)、CLS除去(画像寸法・フォント・プレースホルダ)、INP改善(長タスク分割・デバウンス)。

1 分で読了
React 18 Hooks ベストプラクティス

React 18 Hooks ベストプラクティス

React 18 は、コンカレントレンダリング、自動バッチング、useTransition、useDeferredValue などの革新的な機能を導入しました。この記事では、useState/useEffect の正しい使い方、useMemo/useCallback の最適化境界、カスタム Hook の設計パターン、コンカレントモードでの古いクロージャや競合状態を回避するための完全なガイドを提供します。

1 分で読了
CSS Container Queries 完全ガイド

CSS Container Queries 完全ガイド

モダンCSSの2大レスポンシブ機能:親コンテナ寸法に応じるContainer Queries、子孫要素の状態で親をスタイルする:has()セレクタ。

1 分で読了