最近在优化一个 Vue 3 社区项目时遇到一个很容易误判的问题头像已经显示过返回首页时几乎瞬间出现但 Network 面板里仍然会再次看到头像请求。直觉上会怀疑组件被重新挂载、浏览器缓存失效或者 CDN 没有命中。最后排查发现资源其实已经进入 Service Worker 缓存重复请求恰恰是缓存策略主动发起的。一、先分清三层缓存一个 PWA 页面里的静态资源至少可能经过三层浏览器 HTTP 缓存由Cache-Control、ETag等响应头控制Service Worker Cache Storage由 Workbox 或手写 SW 的策略控制CDN边缘缓存例如 Cloudflare决定请求是否回到源站。看到from ServiceWorker只说明页面拿到了 SW 返回的响应不等于 SW 没有在后台联网。二、根因StaleWhileRevalidate 本来就会再请求一次StaleWhileRevalidate的工作方式是请求资源 ├─ 缓存中有立即返回缓存 └─ 同时发起网络请求更新缓存供下一次使用它很适合需要“先快后新”的接口或经常变化的图片。但对于头像、帖子图片等已经具备稳定 URL 或版本参数的资源它会产生一个副作用用户每次回到页面内容显示得很快却仍然会多一次真实网络请求。旧配置可以抽象成这样{urlPattern:({url})url.pathname.startsWith(/uploads/)||url.hostnamemedia.example.com,handler:StaleWhileRevalidate,options:{cacheName:media-cache}}如果目标是“资源一旦缓存后续尽量不联网”更合适的是CacheFirst。三、媒体资源改为 CacheFirst在vite-plugin-pwa的 Workbox 配置中可以按资源类型划分运行时缓存import{defineConfig}fromviteimport{VitePWA}fromvite-plugin-pwaexportdefaultdefineConfig({plugins:[VitePWA({registerType:autoUpdate,workbox:{runtimeCaching:[{urlPattern:({url})url.pathname.startsWith(/uploads/)||url.pathname.startsWith(/static/)||url.pathname.startsWith(/official/)||url.hostnamemedia.example.com,handler:CacheFirst,options:{cacheName:app-media,cacheableResponse:{statuses:[0,200]},expiration:{maxEntries:300,maxAgeSeconds:30*24*60*60,purgeOnQuotaError:true}}}]}})]})这里有三个细节statuses: [0, 200]兼容同源响应和部分跨域 opaque responsemaxEntries防止大量图片无限占用本地存储purgeOnQuotaError允许存储空间不足时优先清理运行时缓存。需要注意CacheFirst不能单独解决资源更新问题。URL 永远不变时用户可能长期看到旧图。因此必须配合版本化。四、稳定资源要带内容版本带哈希的构建资源天然适合长期缓存/assets/index-CYN-L7my.js /assets/PostDetail-D1abc234.css文件内容变化文件名也变化所以可以放心设置location ^~ /assets/ { expires 1y; add_header Cache-Control public, max-age31536000, immutable always; }对于文件名固定的官方头像也可以增加内容版本constOFFICIAL_AVATAR_VERSIONf2d13162c146exportfunctionofficialAvatarUrl(path:string){return${path}?v${OFFICIAL_AVATAR_VERSION}}更新头像时同步更新版本值旧缓存自然失效不需要让每次访问都重新校验。五、入口文档和 Service Worker 不能长期缓存不是所有资源都应该immutable。下面两个文件如果被长期缓存可能导致用户一直停留在旧版本location /index.html { expires -1; add_header Cache-Control no-store, no-cache, must-revalidate always; } location /sw.js { expires -1; add_header Cache-Control no-store, no-cache, must-revalidate always; }推荐的整体分层是资源策略原因带哈希 JS/CSS一年 immutable内容变化即换 URL头像、帖子图片SW CacheFirst 有界缓存回访时减少网络请求官方固定资源URL 版本参数 immutable兼顾长期缓存与主动更新index.htmlno-store及时拿到新构建入口sw.jsno-store及时发现新 SW用户态 API不做公共缓存防止数据串用和过期六、别把“重复 API 请求”误认为静态资源问题这次排查还发现另一个独立问题顶部栏在watch(..., { immediate: true })中初始化了一次通知、私信和 WebSocket随后又在onMounted中执行同一套逻辑。典型反例watch(currentUser,initRealtime,{immediate:true})onMounted((){initRealtime()// 重复初始化})可以保留单一入口并让连接函数本身具备幂等性letsocketStartedfalseasyncfunctioninitRealtime(user?:User){if(!user||socketStarted)returnsocketStartedtrueawaitPromise.all([loadUnreadNotifications(),loadUnreadMessages()])connectWebSocket()}watch(currentUser,initRealtime,{immediate:true})另外首页启动后无条件import()热榜、精华页也会下载用户可能根本不会访问的 JS/CSS。预加载不是越多越好它应该服务于高概率的下一步而不是把所有路由提前请求一遍。七、如何验证优化真的生效建议按下面的顺序验证清理旧 Service Worker 与 Cache Storage首次加载新版本访问包含头像或媒体图片的页面返回首页后再次进入在 Network 中确认资源来自 Service Worker确认没有由StaleWhileRevalidate触发的后台媒体请求直接请求 CDN 资源两次检查第二次是否边缘命中更新资源版本确认新 URL 能绕过旧缓存。命令行也可以辅助检查响应头curl-Ihttps://www.example.com/assets/index-HASH.jscurl-Ihttps://www.example.com/sw.js前者应适合长期缓存后者则应允许浏览器及时重新验证或获取新版本。总结“页面显示很快但 Network 仍有请求”并不矛盾。StaleWhileRevalidate的设计目标就是先返回旧缓存、再后台更新。这次优化最终遵循四条规则媒体资源使用CacheFirst并限制容量和有效期长期缓存资源必须有哈希文件名或内容版本参数index.html与sw.js保持可更新同时检查组件生命周期、路由预加载和 WebSocket 初始化避免应用层重复请求。缓存治理不是简单地把max-age调大而是让“资源身份、更新方式和缓存策略”彼此匹配。我正在把这类真实的开发与运营记录整理到 Zens。它是一个友善、真实、鼓励长期表达的开放社区如果你愿意分享完整教程、独立作品或排障过程可以来看看https://www.allinsong.top/?utm_sourcecsdnutm_mediumtechnical_articleutm_campaignzens_first_wave_202607utm_contentpwa_cache参考文档Workbox Strategieshttps://developer.chrome.com/docs/workbox/modules/workbox-strategiesMDN Cache-Controlhttps://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Cache-ControlVite PWAhttps://vite-pwa-org.netlify.app/