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

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

Choyeon
Choyeon
2026年9月28日 1 分钟阅读 333 阅读
1,104 字 4,609 字符 更新于 2026年11月8日
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 分钟阅读