Service Worker 缓存策略:从预缓存到运行时缓存的精细控制

发布时间:2026/7/23 7:59:12
Service Worker 缓存策略:从预缓存到运行时缓存的精细控制 Service Worker 缓存策略从预缓存到运行时缓存的精细控制一、你的 PWA 离线了但用户打开的是一个 3 天前的旧页面Service Worker 缓存是 PWA 的核心能力也是最容易被错误配置导致永远显示旧内容的地方。最常见的问题开发阶段设置了Cache-First策略上线后忘了改用户永远看到的是首次缓存的旧版本。等发现的时候已经有三天的用户反馈页面没更新。Service Worker 缓存策略有五类每类有不同的适用场景。没有一种策略是适用于所有资源的——静态资源JS/CSS/字体和 API 数据JSON的缓存策略应该完全不同。静态资源适合 Cache-First内容通过文件名哈希区分版本API 数据适合 Network-First优先获取最新数据网络失败时降级到缓存。策略之外的另一个关键问题缓存更新。当新版本部署时旧的 Service Worker 还在缓存旧资源——必须有版本管理和缓存清理机制。二、底层机制与原理剖析五种缓存策略的应用场景Cache-First缓存优先优先使用缓存缓存未命中才走网络。适用静态资源JS/CSS/图片/字体——这些资源通过文件名哈希来管理版本app.a3f2.js不会出现内容变了但缓存没更新的问题。不适用HTML 入口文件——入口文件如果走 Cache-First新版本永远加载不了。Network-First网络优先优先使用网络网络失败才使用缓存。适用API 数据——用户需要最新数据只在离线时降级到缓存。网络超时设置要对齐用户的容忍度通常 2-3 秒。Stale-While-Revalidate缓存即返回 后台更新先返回缓存零延迟同时后台发起网络请求更新缓存。适用HTML 外壳App Shell——用户立即看到页面后台静默更新。下一次访问时看到的是更新过的版本。Network-Only仅网络不缓存。适用需要实时性的数据支付状态、股票价格、不应被缓存的敏感数据。Cache-Only仅缓存不访问网络。适用完全离线的应用场景——所有资源在首次安装时预缓存完毕。三、生产级代码实现// service-worker.js /** * Service Worker 缓存策略实现 * * 设计要点 * 1. 分资源类型使用不同的缓存策略 * 2. 基于 BUILD_VERSION 做缓存版本管理 * 3. 新 SW 激活时自动清理旧版本缓存 * 4. 请求路由使用正则匹配性能优于逐个比较 */ // 构建时由构建系统注入如 Webpack DefinePlugin const BUILD_VERSION 20260723.1; const CACHE_PREFIX app-cache; const CURRENT_CACHE ${CACHE_PREFIX}-${BUILD_VERSION}; // --------------------------------------------------------------------------- // 路由规则根据请求 URL 匹配缓存策略 // --------------------------------------------------------------------------- const ROUTE_RULES [ { // 静态资源JS/CSS/字体/图片 → Cache-First pattern: /\.(js|css|woff2?|png|jpg|svg|ico)$/, strategy: cache-first, cacheName: ${CACHE_PREFIX}-static-${BUILD_VERSION}, // maxAge: 30 天静态资源通过文件名哈希控制版本 maxAge: 30 * 24 * 60 * 60, }, { // API 请求 → Network-First2 秒超时 pattern: /\/api\//, strategy: network-first, cacheName: ${CACHE_PREFIX}-api, networkTimeout: 2000, maxAge: 5 * 60, // API 缓存 5 分钟 }, { // HTML 页面导航 → Stale-While-Revalidate pattern: ({ request }) request.mode navigate, strategy: stale-while-revalidate, cacheName: ${CACHE_PREFIX}-pages-${BUILD_VERSION}, }, { // 图片资源 → Cache-First独立缓存池 pattern: /\.(png|jpg|jpeg|gif|webp|avif)$/, strategy: cache-first, cacheName: ${CACHE_PREFIX}-images, maxAge: 7 * 24 * 60 * 60, // 图片缓存 7 天 // 最多缓存 200 张图片超出时 LRU 淘汰 maxEntries: 200, }, ]; // --------------------------------------------------------------------------- // Install: 预缓存 App Shell // --------------------------------------------------------------------------- self.addEventListener(install, (event) { console.log([SW] Installing version ${BUILD_VERSION}); event.waitUntil( caches.open(CURRENT_CACHE).then((cache) { // 预缓存关键资源App Shell return cache.addAll([ /, /app-shell.html, /offline.html, ]); }).then(() { // 立即激活新 SW不等待旧 SW 释放 return self.skipWaiting(); }) ); }); // --------------------------------------------------------------------------- // Activate: 清理旧版本缓存 // --------------------------------------------------------------------------- self.addEventListener(activate, (event) { console.log([SW] Activating version ${BUILD_VERSION}); event.waitUntil( caches.keys().then((cacheNames) { return Promise.all( cacheNames .filter((name) { // 匹配 app-cache- 开头的缓存且不是当前版本 return name.startsWith(CACHE_PREFIX) name ! CURRENT_CACHE; }) .map((name) { console.log([SW] Deleting old cache: ${name}); return caches.delete(name); }) ); }).then(() { // 接管所有页面的控制权 return self.clients.claim(); }) ); }); // --------------------------------------------------------------------------- // Fetch: 根据路由规则选择缓存策略 // --------------------------------------------------------------------------- self.addEventListener(fetch, (event) { const { request } event; // 跳过非 GET 请求——POST/PUT/DELETE 永远不缓存 if (request.method ! GET) { return; } // 跳过 chrome-extension:// 等非 HTTP 请求 if (!request.url.startsWith(http)) { return; } // 匹配路由规则 for (const rule of ROUTE_RULES) { const matches typeof rule.pattern function ? rule.pattern({ request }) : rule.pattern.test(request.url); if (!matches) continue; event.respondWith(handleStrategy(request, rule)); return; } // 未匹配任何规则 → 默认 Network-First event.respondWith(networkFirstStrategy(request, default, 3000)); }); // --------------------------------------------------------------------------- // 缓存策略实现 // --------------------------------------------------------------------------- /** * Cache-First缓存优先 */ async function cacheFirstStrategy(request, cacheName, maxAge) { const cache await caches.open(cacheName); // 1. 查缓存 const cached await cache.match(request); if (cached) { // 检查缓存年龄 if (maxAge) { const cacheDate cached.headers.get(sw-cached-date); if (cacheDate) { const age (Date.now() - new Date(cacheDate).getTime()) / 1000; if (age maxAge) { return cached; } // 缓存过期 → 后台更新但仍返回旧缓存 updateCacheInBackground(request, cacheName); return cached; } } return cached; } // 2. 缓存未命中 → 网络请求 try { const response await fetch(request); // 克隆响应——response body 只能读一次 const toCache response.clone(); // 添加缓存时间戳用于缓存过期判断 const headers new Headers(toCache.headers); headers.set(sw-cached-date, new Date().toISOString()); cache.put(request, new Response(toCache.body, { status: toCache.status, statusText: toCache.statusText, headers: headers, })); return response; } catch (error) { // 网络失败 无缓存 → 返回离线页面对 navigation 请求 if (request.mode navigate) { return caches.match(/offline.html); } return new Response(Network error, { status: 408 }); } } /** * Network-First网络优先带超时 */ async function networkFirstStrategy(request, cacheName, timeoutMs) { // 创建超时 Promise const timeoutPromise new Promise((resolve) { setTimeout(() resolve(timeout), timeoutMs); }); try { // 竞速网络请求 vs 超时 const response await Promise.race([ fetch(request), timeoutPromise, ]); if (response timeout) { // 超时 → 降级到缓存 return fallbackToCache(request, cacheName); } // 网络成功 → 更新缓存 const cache await caches.open(cacheName); cache.put(request, response.clone()); return response; } catch (error) { // 网络失败 → 降级到缓存 return fallbackToCache(request, cacheName); } } /** * Stale-While-Revalidate缓存即返回 后台更新 */ async function staleWhileRevalidateStrategy(request, cacheName) { const cache await caches.open(cacheName); const cached await cache.match(request); // 后台更新 Promise不阻塞主返回 const fetchPromise fetch(request).then((response) { cache.put(request, response.clone()); return response; }).catch(() { // 后台更新失败——静默用户已经看到缓存版本 console.warn([SW] Background update failed for:, request.url); }); if (cached) { // 有缓存立即返回 后台更新 // 注意fetchPromise 不等待让它在后台执行 return cached; } // 无缓存等待网络请求 return fetchPromise; } /** * 降级到缓存的辅助函数 */ async function fallbackToCache(request, cacheName) { const cache await caches.open(cacheName); const cached await cache.match(request); if (cached) { return cached; } // 完全无缓存 → 返回离线响应 if (request.mode navigate) { return caches.match(/offline.html); } return new Response( JSON.stringify({ error: Network unavailable, cached: false }), { status: 503, headers: { Content-Type: application/json }, } ); } /** * 后台静默更新缓存 */ function updateCacheInBackground(request, cacheName) { caches.open(cacheName).then((cache) { fetch(request).then((response) { cache.put(request, response); }).catch(() { // 静默失败 }); }); } // --------------------------------------------------------------------------- // 推送缓存版本更新通知给客户端 // --------------------------------------------------------------------------- self.addEventListener(message, (event) { if (event.data SKIP_WAITING) { self.skipWaiting(); } });四、边界分析与架构权衡Service Worker 的生命周期陷阱新 SW 安装后会进入waiting状态——直到当前页面关闭旧的 SW 才释放。如果你希望用户立即看到新版本需要skipWaiting()clients.claim()但这意味着正在加载的页面会被新 SW 接管——可能导致正在使用的旧 API 与新 SW 的缓存策略冲突折中方案在 SW 中推送更新通知让用户手动刷新缓存空间限制浏览器为每个 origin 的缓存空间有限Chrome ~ 总磁盘的 60% 按比例分配缓存过多大文件如音频、视频可能触发浏览器自动清理导致关键资源被意外删除建议对图片/视频缓存设置maxEntries上限结合 LRU 淘汰策略API 缓存的时效性问题Network-First 策略下只有在网络失败时才会使用缓存 API 数据——如果网络成功但返回了旧数据策略也认为是成功的需要在 API 响应的 HTTP 头中设置Cache-Control来辅助 SW 判断数据新鲜度五、总结Service Worker 缓存策略不能一刀切。静态资源用 Cache-First通过文件名哈希保证版本正确性API 数据用 Network-First优先获取最新数据HTML 页面用 Stale-While-Revalidate立即展示 后台更新。关键是缓存版本管理——新 SW 激活时必须清理旧缓存否则版本号再新用户拿到的还是旧内容。五种策略是工具不是答案——每一类资源都有最适合的缓存策略需要根据资源的特性和用户对新鲜度的要求来选择。