前端离线优先架构:Service Worker 缓存策略与数据同步的工程方案
一、离线优先的架构边界与收益评估
离线优先(Offline-First)不等同于"完全离线使用",而是在网络不可靠(高铁隧道、电梯、弱信号区)的场景下,保障用户不看到空白页面或加载失败提示。架构设计的核心原则是:以本地为真理源(Local-First),网络为同步通道。
架构的适用条件:
- 应用的核心交互不依赖实时数据(内容类、工具类、表单类)。
- 用户有一段时间的使用会话(非纯查询型应用)。
- 可接受秒级的数据一致性延迟(非强一致性场景)。
典型收益数据:
- 二次加载时间从 2.5s(网络请求)降到 200ms(本地缓存)。
- 弱网环境下的可用性从 23% 提升到 89%(PWA 实测数据)。
- 用户留存率提升约 15%(数据来源:Google IO 2024 PWA Case Study)。
flowchart TD A[用户访问] --> B{Service Worker 激活?} B -->|否| C[常规网络请求] B -->|是| D{请求类型判断} D -->|静态资源| E[Cache-First: 缓存命中直接返回] D -->|API 数据| F{网络可用?} F -->|是| G[Network-First: 获取最新数据 + 更新缓存] F -->|否| H[返回缓存数据,标记为离线状态] G --> I[渲染页面] E --> I H --> I C --> I style E fill:#6f6,stroke:#333 style H fill:#fc6,stroke:#333离线优先的工程成本主要在两方面:请求拦截层的缓存策略实现,以及数据同步层的冲突解决。两者都依赖 Service Worker 的生命周期管理。
二、Service Worker 的分层缓存策略
缓存策略分为四个层级,按请求类型分配不同的策略:
| 缓存层 | 适用资源 | 策略 | 过期策略 | 最大容量 |
|---|---|---|---|---|
| Shell 层 | HTML/JS/CSS 核心资源 | Cache-First | 版本号控制 | 10 MB |
| 数据层 | API GET 响应 | Network-First | TTL + 后台更新 | 50 MB |
| 媒体层 | 图片/字体/音视频 | Stale-While-Revalidate | LRU 淘汰 | 200 MB |
| 动态层 | 用户上传/表单提交 | Network-Only (离线队列) | 持久化 | 5 MB |
/** * Service Worker 多层级缓存策略实现 */ /// <reference lib="webworker" /> declare const self: ServiceWorkerGlobalScope; /** 缓存配置 */ const CACHE_CONFIG = { /** Shell 层缓存名(版本化,用于自动清理旧版本) */ shell: `shell-v${process.env.APP_VERSION || '1'}`, /** 数据层缓存名 */ data: 'api-data-v1', /** 媒体层缓存名 */ media: 'media-v1', /** 各层最大条目数 */ maxItems: { data: 500, media: 1000, }, /** API 响应 TTL(毫秒) */ apiTTL: 5 * 60 * 1000, // 5 分钟 } as const; /** * Service Worker 安装事件 * 预缓存 Shell 层关键资源 */ self.addEventListener('install', (event) => { event.waitUntil( (async () => { const cache = await caches.open(CACHE_CONFIG.shell); // 预缓存应用骨架和关键资源 const shellAssets = ['/', '/index.html', '/assets/app.js', '/assets/app.css']; try { await cache.addAll(shellAssets); console.log('[SW] Shell 层预缓存完成'); } catch (error) { console.error('[SW] Shell 层预缓存失败:', error); // 预缓存失败不应阻塞 SW 激活 } })() ); // 跳过等待,使新 SW 立即激活 self.skipWaiting(); }); /** * Service Worker 激活事件 * 清理旧版本缓存 */ self.addEventListener('activate', (event) => { event.waitUntil( (async () => { const cacheNames = await caches.keys(); // 保留当前版本的缓存(以 'shell-v' 开头的仅保留当前版本) const currentVersion = CACHE_CONFIG.shell; const deletePromises = cacheNames .filter((name) => name.startsWith('shell-v') && name !== currentVersion) .map((name) => caches.delete(name)); await Promise.all(deletePromises); // 立即接管所有页面 await self.clients.claim(); console.log('[SW] 新版本激活,旧缓存已清理'); })() ); }); /** * Service Worker 请求拦截 * 根据请求类型应用不同的缓存策略 */ self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // 仅处理 GET 请求,非 GET 请求透传给网络 if (request.method !== 'GET') return; // 跳过 chrome-extension 和非 HTTP 协议 if (!url.protocol.startsWith('http')) return; // 根据请求类型选择缓存策略 if (isStaticAsset(url)) { // 静态资源:Cache-First event.respondWith(cacheFirstStrategy(request)); } else if (isAPIRequest(url)) { // API 请求:Network-First event.respondWith(networkFirstStrategy(request)); } else if (isMediaAsset(url)) { // 媒体资源:Stale-While-Revalidate event.respondWith(staleWhileRevalidateStrategy(request)); } }); /** * Cache-First 策略 * 优先使用缓存,缓存未命中时请求网络 */ async function cacheFirstStrategy(request: Request): Promise<Response> { const cache = await caches.open(CACHE_CONFIG.shell); try { const cachedResponse = await cache.match(request); if (cachedResponse) { return cachedResponse; } } catch { // 缓存读取失败,降级为网络请求 } // 缓存未命中或读取失败,请求网络 try { const networkResponse = await fetch(request); // 缓存成功的网络响应(仅缓存 200 状态码) if (networkResponse.ok) { const clonedResponse = networkResponse.clone(); cache.put(request, clonedResponse).catch(() => { // 缓存写入失败:可能超出配额,静默处理 }); } return networkResponse; } catch (error) { console.error('[SW] 网络请求失败,无缓存回退:', request.url, error); // 返回离线提示页面 return new Response('当前处于离线状态,无法加载此页面', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } } /** * Network-First 策略 * 优先请求网络,网络失败时使用缓存 */ async function networkFirstStrategy( request: Request, timeout: number = 3000 ): Promise<Response> { const cache = await caches.open(CACHE_CONFIG.data); try { // 网络请求,带超时控制 const networkResponse = await fetchWithTimeout(request, timeout); // 缓存 200 响应 if (networkResponse.ok) { const clonedResponse = networkResponse.clone(); cache.put(request, clonedResponse); } return networkResponse; } catch { // 网络失败或超时,降级为缓存 const cachedResponse = await cache.match(request); if (cachedResponse) { // 在响应头中标记为离线数据 return addOfflineHeader(cachedResponse); } return new Response( JSON.stringify({ error: '网络不可用,无可用缓存数据' }), { status: 503, headers: { 'Content-Type': 'application/json' }, } ); } } /** * Stale-While-Revalidate 策略 * 立即返回缓存,同时在后台更新缓存 */ async function staleWhileRevalidateStrategy(request: Request): Promise<Response> { const cache = await caches.open(CACHE_CONFIG.media); // 立即返回缓存版本(不阻塞) const cachedResponse = await cache.match(request); // 后台发起网络请求更新缓存 const updatePromise = fetch(request) .then((networkResponse) => { if (networkResponse.ok) { cache.put(request, networkResponse.clone()); } }) .catch(() => { // 后台更新失败,静默处理(下次访问时重试) }); // 在合适的时机等待更新完成(不阻塞本次响应) event.waitUntil?.(updatePromise); return cachedResponse || updatePromise; } /** * 带超时的 fetch 封装 */ async function fetchWithTimeout( request: Request, timeout: number ): Promise<Response> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(request, { signal: controller.signal, }); return response; } finally { clearTimeout(timeoutId); } } /** * 在响应头添加离线标记 */ function addOfflineHeader(response: Response): Response { const headers = new Headers(response.headers); headers.set('X-Offline-Data', 'true'); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } /** 判断是否为静态资源 */ function isStaticAsset(url: URL): boolean { return /\.(js|css|html|json|woff2?)$/.test(url.pathname); } /** 判断是否为 API 请求 */ function isAPIRequest(url: URL): boolean { return url.pathname.startsWith('/api/'); } /** 判断是否为媒体资源 */ function isMediaAsset(url: URL): boolean { return /\.(png|jpg|jpeg|gif|svg|webp|mp4|mp3)$/.test(url.pathname); }缓存策略的选择需要根据资源特性调整。对于版本化资源(带 hash 的 JS/CSS),Cache-First 策略最合适,因为文件名变更是版本更新的信号,不存在缓存过期问题。对于 API 响应,Network-First 配合短 TTL 保证数据的时效性。
三、离线数据同步的冲突解决策略
离线优先应用中,用户在离线时产生的写操作(表单提交、内容编辑)需要暂存并在网络恢复后同步到服务端。核心问题在于冲突解决:当多个设备或同一设备离线期间其他用户修改了相同数据时,如何合并变更。
常用的同步方案对比:
| 方案 | 适用场景 | 冲突策略 | 复杂度 |
|---|---|---|---|
| Last-Write-Wins (LWW) | 单用户编辑、配置类数据 | 时间戳比较 | 低 |
| CRDT (Conflict-free Replicated Data Type) | 协作编辑、多用户在线 | 自动合并 | 高 |
| 三路合并 | 结构化文档编辑 | Diff 合并 | 中 |
对于大多数前端表单类应用,LWW + 操作日志的方案足够实用:
/** * 离线操作队列管理器 * 缓存离线期间的写操作,网络恢复后批量同步 */ interface OfflineOperation { /** 操作唯一 ID */ id: string; /** 操作类型 */ type: 'CREATE' | 'UPDATE' | 'DELETE'; /** 目标资源路径 */ endpoint: string; /** 操作数据 */ payload: unknown; /** 创建时间 */ timestamp: number; /** 重试次数 */ retryCount: number; /** 状态 */ status: 'pending' | 'syncing' | 'failed'; } class OfflineSyncQueue { private readonly STORAGE_KEY = 'offline-sync-queue'; private queue: OfflineOperation[] = []; private maxRetries = 3; private syncInProgress = false; constructor() { // 从 localStorage 恢复操作队列 this.loadFromStorage(); // 监听网络恢复事件 this.registerNetworkListener(); } /** * 入队一个离线操作 */ async enqueue( type: OfflineOperation['type'], endpoint: string, payload: unknown ): Promise<void> { const operation: OfflineOperation = { id: crypto.randomUUID(), type, endpoint, payload, timestamp: Date.now(), retryCount: 0, status: 'pending', }; this.queue.push(operation); this.persistToStorage(); // 如果当前网络可用,立即尝试同步 if (navigator.onLine) { this.sync(); } } /** * 同步所有待处理的离线操作 */ async sync(): Promise<void> { if (this.syncInProgress || this.queue.length === 0) return; this.syncInProgress = true; const pendingOps = this.queue.filter( (op) => op.status === 'pending' ); for (const op of pendingOps) { try { op.status = 'syncing'; const response = await fetch(op.endpoint, { method: this.mapHttpMethod(op.type), headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.payload), }); if (response.ok || response.status === 409) { // 成功或冲突已由服务端处理(409) this.removeOperation(op.id); } else if (response.status === 412) { // 前置条件失败(版本冲突),需用户手动解决 op.status = 'failed'; this.notifyConflict(op); } else { op.retryCount++; if (op.retryCount >= this.maxRetries) { op.status = 'failed'; this.notifyFailedOperation(op); } else { op.status = 'pending'; // 置回 pending 等待下次同步 } } } catch (error) { // 网络错误,不递增重试计数(下次网络恢复时重试) op.status = 'pending'; console.warn(`离线操作同步失败 (${op.id}):`, error); break; // 网络错误时停止后续操作,下次一起重试 } } this.persistToStorage(); this.syncInProgress = false; } /** * 从队列中移除已成功的操作 */ private removeOperation(id: string): void { this.queue = this.queue.filter((op) => op.id !== id); } /** * 持久化队列到 localStorage */ private persistToStorage(): void { try { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.queue)); } catch (error) { console.error('离线操作队列持久化失败:', error); } } private loadFromStorage(): void { try { const stored = localStorage.getItem(this.STORAGE_KEY); if (stored) { this.queue = JSON.parse(stored); } } catch { this.queue = []; } } private registerNetworkListener(): void { window.addEventListener('online', () => { console.log('[OfflineSync] 网络恢复,开始同步'); this.sync(); }); } private mapHttpMethod(type: OfflineOperation['type']): string { switch (type) { case 'CREATE': return 'POST'; case 'UPDATE': return 'PUT'; case 'DELETE': return 'DELETE'; } } private notifyConflict(op: OfflineOperation): void { // 通过 postMessage 或事件通知应用层处理冲突 window.dispatchEvent( new CustomEvent('sync-conflict', { detail: op }) ); } private notifyFailedOperation(op: OfflineOperation): void { window.dispatchEvent( new CustomEvent('sync-failed', { detail: op }) ); } }四、Service Worker 的版本管理与灰度发布
由于 Service Worker 通过skipWaiting+clients.claim机制更新后,所有页面立即可用新版本,这使得回滚变得困难。版本管理策略包括:
1. 版本号注入:在构建时将 commit hash 注入 SW 文件,确保新旧 SW 的 URL 不同,浏览器按字节对比检测更新。
2. 优雅降级检查:新 SW 激活后,先发送postMessage通知页面应用版本变更。页面在收到消息后展示"发现新版本"提示,而非强制刷新。
3. 紧急回滚:在服务端配置 SW 路由指向旧版本文件,触发浏览器检测到 SW 文件内容变更后重新安装旧版本。
五、总结
前端离线优先架构的工程方案,以 Service Worker 为请求拦截层,以分层缓存策略(Cache-First / Network-First / Stale-While-Revalidate)为数据分发基础,以离线操作队列为写操作暂存机制,构建了一个从展示到交互再到数据同步的完整离线支持框架。
离线优先不是所有应用都需要的架构选择。决策前需要评估收益(可用性提升、加载速度优化)与成本(缓存策略维护、同步冲突处理、SW 更新复杂度)。对于内容消费型应用,仅启用 Shell 层和媒体层缓存即可获得大部分收益;对于表单密集型工具应用,离线操作队列是必要组件。