Service Worker 缓存策略——离线优先、后台同步与版本治理的工程闭环

Service Worker 缓存策略——离线优先、后台同步与版本治理的工程闭环
Service Worker 缓存策略——离线优先、后台同步与版本治理的工程闭环一、从离线看板到「永远看到旧页面」Service Worker 缓存的陷阱某工业 SaaS 后台上线 PWA 后运营反馈「上周改的按钮文案客户那边今天还没更新」。排查半天发现是 Service Worker 把旧 HTML 死死钉在 Cache Storage 里新版上线后走的是 CacheFirst用户刷新再多遍也是老界面。这事我见过太多团队栽进去——把 Service Worker 当成「加了就快」的银弹却没设计缓存策略与版本治理。Service WorkerSW本质是跑在浏览器后台的代理脚本拦截页面发出的网络请求。它让 PWA 具备离线能力也让 Web 应用能做后台同步与推送。但这份拦截能力是双刃剑策略对了弱网用户秒开策略错了用户被锁在旧版本里出不来。缓存策略错配的后果分三类。其一HTML 走 CacheFirst 导致版本不更新用户永远看到旧页面。其二接口响应被缓存后返回脏数据工单系统显示两小时前的状态。其三旧版 SW 注册的 Cache 永不清理存储配额被无声吃满最终抛 QuotaExceededError。要把 SW 用对必须把三件事一起设计缓存策略请求怎么走、后台同步离线写操作怎么补传、版本治理旧缓存怎么清。三者缺一PWA 在生产环境必然出事。二、请求分流与缓存生命周期SW 的底层拦截机制SW 的拦截发生在 fetch 事件阶段。页面发出请求后SW 先于网络拿到它决定走缓存还是走网络。这套决策由开发者自行实现没有默认策略。Cache Storage 是一个独立的键值存储键是 Request 对象值是 Response 对象按缓存名分桶管理。三大经典策略各自适配不同资源。CacheFirst 优先读缓存命中即返回适合长期不变的静态资源字体、图标、带 hash 的 JS。NetworkFirst 优先走网络失败回退缓存适合 HTML 与关键接口保证用户拿到最新版本。StaleWhileRevalidate 立即返回缓存的同时后台拉取新版本更新缓存适合可容忍短暂时延的资源图片、非关键配置。缓存的生命周期与 SW 一致。SW 安装时触发 install激活时触发 activate。activate 阶段是清理旧版本缓存的唯一可靠时机——此时旧 SW 已退出新 SW 接管可以安全删除上一版本的 Cache 桶。后台同步Background Sync则依赖 sync 事件在网络恢复时由浏览器触发把离线期间暂存的写操作批量补传。综上SW 的分流让每类请求各走各路静态资源走 CacheFirst 极速命中HTML 走 NetworkFirst 保新鲜写操作靠 Background Sync 兜底补传过期缓存在 activate 阶段统一清理。四类策略组合离线可用与实时新鲜才能兼顾。三、生产级 SW 缓存管理器实现下面给出一个可配置的 SW 缓存管理器。它按路由分发策略带网络超时、写操作离线入队以及版本化缓存清理。// sw.ts —— 在 Service Worker 上下文中运行 const SW_VERSION v3; // 每次发布递增用于版本治理 const CACHE_PREFIX app-shell-; // 路由策略表每条规则匹配一类请求并指定策略 interface RouteRule { pattern: RegExp; strategy: cache-first | network-first | stale-while-revalidate; cacheName: string; networkTimeoutMs?: number; // NetworkFirst 超时回退缓存避免弱网卡死 } const ROUTES: RouteRule[] [ // 带 hash 的静态资源长期不变走 CacheFirst { pattern: /\.(?:js|css|woff2|png|svg)$/, strategy: cache-first, cacheName: static }, // HTML 文档必须新鲜走 NetworkFirst 超时回退 { pattern: /\/(?:index\.html)?$/, strategy: network-first, cacheName: doc, networkTimeoutMs: 3000 }, // 非关键配置走 SWR容忍短暂时延 { pattern: /\/api\/config/, strategy: stale-while-revalidate, cacheName: config }, ]; // install预缓存关键壳资源失败不阻断安装后续按需补拉 self.addEventListener(install, (event) { event.waitUntil( caches.open(${CACHE_PREFIX}${SW_VERSION}).then((cache) cache.addAll([/, /index.html]).catch(() { /* 预缓存失败容错不阻塞 */ }) ) ); self.skipWaiting(); // 新 SW 立即接管配合 activate 清理 }); // activate清理旧版本 Cache 桶释放存储配额 self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((keys) Promise.all( keys .filter((k) !k.endsWith(SW_VERSION)) // 仅保留当前版本 .map((k) caches.delete(k)) ) ) ); self.clients.claim(); // 立即接管已打开页面 }); // fetch按路由分发策略 self.addEventListener(fetch, (event) { const req event.request; // 写操作不走缓存失败则入队 Background Sync if ([POST, PUT, DELETE].includes(req.method)) { event.respondWith(handleWrite(req)); return; } const rule ROUTES.find((r) r.pattern.test(req.url)); if (!rule) return; // 无规则放行走默认网络 event.respondWith( (async () { try { const cache await caches.open(${CACHE_PREFIX}-${rule.cacheName}); switch (rule.strategy) { case cache-first: return await cacheFirst(req, cache); case network-first: return await networkFirst(req, cache, rule.networkTimeoutMs ?? 3000); case stale-while-revalidate: return await swr(req, cache); } } catch { // 策略异常兜底直连网络避免请求整体失败 return fetch(req); } })() ); }); async function cacheFirst(req: Request, cache: Cache): PromiseResponse { const hit await cache.match(req); if (hit) return hit; const fresh await fetch(req); cache.put(req, fresh.clone()); // clone 后写入原响应返回页面 return fresh; } async function networkFirst(req: Request, cache: Cache, timeoutMs: number): PromiseResponse { // 用 AbortController 控制超时弱网下回退缓存避免卡死 const controller new AbortController(); const timer setTimeout(() controller.abort(), timeoutMs); try { const fresh await fetch(req, { signal: controller.signal }); cache.put(req, fresh.clone()); return fresh; } catch { const hit await cache.match(req); if (hit) return hit; throw new Error(网络与缓存均不可用); } finally { clearTimeout(timer); // 无论成功失败都清理定时器避免句柄泄漏 } } async function swr(req: Request, cache: Cache): PromiseResponse { const hit await cache.match(req); // 后台拉新不阻塞当前请求失败静默保留旧缓存 fetch(req).then((fresh) cache.put(req, fresh.clone())).catch(() {}); if (hit) return hit; return fetch(req); // 首次无缓存直连网络 } // 写操作离线入队网络恢复后由 Background Sync 重放 async function handleWrite(req: Request): PromiseResponse { try { return await fetch(req); } catch { const queue await caches.open(${CACHE_PREFIX}-write-queue); // 请求体只能消费一次clone 后存入队列等待重放 const body await req.clone().text(); const staged new Request(req.url, { method: req.method, headers: req.headers, body }); await queue.put(staged, new Response(body)); await (self as any).registration.sync.register(retry-writes); return new Response(离线已暂存待网络恢复后重试, { status: 202 }); } } self.addEventListener(sync, (event) { if (event.tag retry-writes) { event.waitUntil(replayWrites()); } }); async function replayWrites() { const queue await caches.open(${CACHE_PREFIX}-write-queue); const keys await queue.keys(); for (const key of keys) { try { const body (await (await queue.match(key))?.text()) ?? ; await fetch(key.url, { method: key.method, headers: key.headers, body }); await queue.delete(key); // 成功才出队失败留待下次 sync } catch { // 单条失败不阻断后续等待下一次 sync 重试 } } }关键点在于四处。其一路由表驱动策略分发每类资源各走各路HTML 保新鲜、静态走缓存。其二NetworkFirst 用 AbortController 控制超时弱网下回退缓存不卡死。其三写操作失败入队 Background Sync网络恢复后自动重放。其四activate 阶段按版本号清理旧 Cache 桶存储配额不被无声吃满。四、缓存策略的代价容量、一致性与调试复杂度Service Worker 不是免费午餐。Cache Storage 有容量上限。浏览器约为磁盘可用空间的某个比例单 origin 受 OpQuota 约束超限抛 QuotaExceededError。无差别缓存大图与视频会迅速吃满配额。缓存策略必须按资源价值筛选低频资源不该入缓存。某图床站曾把全量原图缓存进 SW两周后配额告警新用户首次访问直接报错。一致性是更隐蔽的代价。CacheFirst 与 SWR 都会让用户在一段时间内看到旧数据。对价格、库存这类强一致场景缓存策略必须配合接口短 TTL 或彻底绕过缓存。某票务系统曾因 SWR 返回旧票价用户下单时才发现已售罄引发投诉。调试复杂度不容忽视。SW 生命周期有 install、waiting、activating、activated 多个状态更新不及时时新代码不生效。开发期需配合 Update on Reload 与 skipWaiting否则容易陷入「改了代码没反应」的困局。生产期则要避免 skipWaiting 滥用防止页面加载到一半被新 SW 接管导致资源错配。HTTPS 是硬性前置。SW 拥有拦截网络请求的权限浏览器强制仅 HTTPS 环境下注册localhost 例外。混合内容HTTPS 页面加载 HTTP 资源会被 SW 拦截后失败需确保全站 HTTPS 化。适用边界PWA 离线应用、弱网优先的内容站、需要后台补传写操作的工单系统收益最高。强一致交易系统、SSR 频繁变更的运营后台、首屏依赖实时数据的看板应谨慎使用或仅缓存静态壳。五、总结Service Worker 缓存策略的核心是「分流」与「治理」两套机制。落地建议第一按资源类型分发策略静态走 CacheFirst、HTML 走 NetworkFirst、可容忍延迟的走 SWR。第二NetworkFirst 必须带超时弱网下回退缓存避免卡死。第三写操作失败入队 Background Sync网络恢复后重放保证离线数据不丢。第四activate 阶段按版本号清理旧 Cache避免配额被无声吃满。最终在离线可用、数据新鲜与存储可控之间取得平衡。这条路在 PWA 与弱网优先场景下能跑通回报是值得的。资料说明本文中的协议、版本、性能、成本和行业趋势应以可核验的一手资料为准。未标注统计口径的比例、时间表和预测仅作工程讨论不应视为行业事实。可参考 0731 资料来源索引并在发布前将具体来源贴到对应断言之后。