
前端离线优先架构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-FirstTTL 后台更新50 MB媒体层图片/字体/音视频Stale-While-RevalidateLRU 淘汰200 MB动态层用户上传/表单提交Network-Only (离线队列)持久化5 MB/** * Service Worker 多层级缓存策略实现 */ /// reference libwebworker / 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): PromiseResponse { 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; charsetutf-8 }, }); } } /** * Network-First 策略 * 优先请求网络网络失败时使用缓存 */ async function networkFirstStrategy( request: Request, timeout: number 3000 ): PromiseResponse { 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): PromiseResponse { 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 ): PromiseResponse { 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/CSSCache-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 ): Promisevoid { 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(): Promisevoid { 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 通过skipWaitingclients.claim机制更新后所有页面立即可用新版本这使得回滚变得困难。版本管理策略包括1. 版本号注入在构建时将 commit hash 注入 SW 文件确保新旧 SW 的 URL 不同浏览器按字节对比检测更新。2. 优雅降级检查新 SW 激活后先发送postMessage通知页面应用版本变更。页面在收到消息后展示发现新版本提示而非强制刷新。3. 紧急回滚在服务端配置 SW 路由指向旧版本文件触发浏览器检测到 SW 文件内容变更后重新安装旧版本。五、总结前端离线优先架构的工程方案以 Service Worker 为请求拦截层以分层缓存策略Cache-First / Network-First / Stale-While-Revalidate为数据分发基础以离线操作队列为写操作暂存机制构建了一个从展示到交互再到数据同步的完整离线支持框架。离线优先不是所有应用都需要的架构选择。决策前需要评估收益可用性提升、加载速度优化与成本缓存策略维护、同步冲突处理、SW 更新复杂度。对于内容消费型应用仅启用 Shell 层和媒体层缓存即可获得大部分收益对于表单密集型工具应用离线操作队列是必要组件。