个人导航页的 PWA 缓存为什么总“更不上去”:版本化文档 + 分级片段缓存的一次实践
摘要
我的个人导航页是一个 PWA:装到手机主屏上,打开就是首页,离线也能看骨架。听起来很美好,但它在缓存上连摔了三个跟头:iOS 上打开先白一段时间、改了配置手机上还是旧版、Service Worker 更新时直接装不上。这篇文章记录我为它做的一套机制——把“配置版本”写进地址里,让文档和片段都变成不可变快照,再按数据新鲜度给片段分级缓存;同时把同步交给客户端每 10 秒一次的轻量轮询,而不是依赖任何 CDN 的 purge 接口。生产环境目前跑的是 v0.1.10,版本化片段这套设计在 develop 分支里已经实现并通过代码评审,正在等合入。文章按真实时间线写,哪些已上线、哪些还在分支里,会如实区分。
一、三个跟头:白屏、旧版粘住、更新装不上
先说我摔的三个跟头,因为机制的每一块都是为其中一个坑而设的。
第一个是 PWA 白屏。用户反馈——其实就是我自己每天在用——从主屏打开导航页,先白一段时间才出内容。我查了线上资产,原因有两段。iOS 这一段是先天的:manifest 里的 background_color 和 theme_color 我设的是 #191c20,但 iOS 根本不拿 manifest 的背景色当启动图,而且我当时没配 apple-touch-startup-image,iPhone 上打开就先天白一段。更主要的一段在 Service Worker:sw.js 对导航请求(/、/page/…)是纯 network-only,只有失败时才回退 offline.html,从来不先吐缓存。而 / 是 no-store,每次打开都必须回源站等网络,等多久就白多久。CSS 和 JS 是缓存优先,反而不是问题。iOS 杀后台又很积极,几乎每次打开都是冷启动,白屏就成了常态。
第二个是“改了配置,手机上还是旧版”。导航页的首页由很多片段拼成:服务状态、订阅源、天气、搜索框。早期的做法是片段地址固定,靠缓存头控制新鲜度。问题在于,配置一改,地址没变,CDN 和 Service Worker 手里的旧片段还在有效期内,用户看到的就是旧版。想让它立即新,唯一的办法是调 CDN 的 purge——而我的前门后来从 Cloudflare 切到了腾讯 EdgeOne,purge 接口、生效时间、权限全都不一样,等于把“配置生效”这件事绑死在某一家 CDN 身上。
第三个是 Service Worker 更新装不上。v0.1.10 之前的版本,预缓存清单和离线清单里有重复的 URL,Service Worker 更新时 Cache.addAll() 遇到重复直接失败,整个新 worker 装不上。用户手机上就一直跑着旧 worker,旧缓存也清不掉。这是最恼人的一种:不是缓存策略错了,是更新机制本身坏了。
这三个坑指向同一个结论:缓存更新不能靠“过期时间 + 手动清理”这种组合,必须让“新版本”在地址层面就和旧版本区分开,让旧东西自然失效。
二、核心设计:把版本写进地址里
这套机制的核心只有一句话:入口文档和片段的地址里都带上版本号,版本一变,地址就变,旧缓存再也不会被命中。
入口文档有一个版本化规范地址:
/page/<构建哈希>/<32 位配置修订号>
<构建哈希> 是代码构建的哈希,<32 位配置修订号> 是配置的修订号。修订号是一个随机令牌,只有当序列化后的配置真的变了才换一个新的——不是每次保存都换,更不是每次请求都换。这个细节很重要:修订号变的频率,等于配置真正变化的频率,缓存命中率才不会被无意义的版本抖动打掉。
生成修订号的逻辑在 src/config/page-version.js 里,有一条硬性要求:修订号本身不能暴露配置摘要。配置里可能有我自己的服务地址、订阅源地址,摘要一旦可逆推,就等于把配置内容泄漏到了 URL 里。所以修订号只是一个随机令牌,服务端拿它当键去查对应的配置快照,令牌本身不携带任何配置信息。
围绕这个地址,有三条配套规则:
第一,/ 标 no-store,永远不缓存。根路径只做一件事:告诉浏览器当前最新的版本化地址在哪。浏览器每次打开 /,都拿到最新的 /page/<构建>/<修订>,再去取那份文档。
第二,旧的 /page/ 地址 302 跳转到当前版本。用户收藏夹里、PWA 启动时存的老地址不会 404,而是被带到新版。
第三,片段地址同样版本化:
/fragments/<构建哈希>/<32 位配置修订号>/…
片段是首页的组成部分,每个片段的地址都带构建哈希和配置修订号。请求里带的修订号如果已经是旧的,源站直接返回 409,并带一个 reload 标记,告诉客户端:你手里的版本过期了,重新拿最新版。409 在这里不是错误,而是一个明确的信号——比“默默返回旧片段”诚实得多。
配置保存时还有两个配套动作:自动保留最近 10 份配置历史,管理端提供配置对比工具。版本化地址让“回到上一版”变成一件很具体的事:上一版的地址本身就是上一版的快照,改回去不需要恢复数据,只需要把当前修订号指回上一版。
这套设计还有一个好处:它不依赖任何 CDN 的 purge。配置生效不靠“我去通知 CDN 删缓存”,而靠“新地址天然不在任何缓存里”。前门从 Cloudflare 切到 EdgeOne 的时候,这套机制一个字都不用改——因为它根本不调用 CDN 的任何接口。
三、片段缓存要分级:实时数据不能冻住
文档版本化解决的是“新旧版本”的问题,片段缓存解决的是“多新鲜”的问题。首页的片段新鲜度要求天差地别,必须分级,不能一刀切。
我的分级是这样的:
片段类型
缓存策略
理由
服务状态
s-maxage=15
状态变化快,但 15 秒的滞后可以接受
订阅源、磁贴
s-maxage=60
内容分钟级变化,60 秒是合理折中
天气、搜索、错误、未就绪
no-store
实时数据或异常状态,冻住就是错的
最容易犯的错,是把“错误”和“未就绪”也缓存了。某个上游源站一时超时,片段返回错误态,如果这个错误响应被缓存 60 秒,源站恢复后用户还要再看一分钟的错误。未就绪也一样:首屏还在等数据时吐出的占位片段,一旦进了缓存,数据到了也刷不出来。所以这两类一律 no-store,宁可每次都重新算,也不要把一个瞬时状态冻成一分钟的事实。
共享缓存要“有界”也是同一条线上的要求。片段种类和版本组合是有限的,但如果不设上限,缓存里会越积越多。分级 TTL 本身就是一种边界:每类片段的最长存活时间是确定的,缓存不会被无限期的条目撑大。
这套分级还有一个隐含的前提:片段的缓存策略是跟着片段类型走的,而不是跟着用户走的。不同用户看到的订阅源内容可能不同,但缓存键里已经有版本和构建信息,类型分级不会把 A 用户的实时数据错发给 B 用户——这是版本化地址顺带解决的另一个问题。
四、客户端同步:10 秒轮询 + 先给旧版
地址版本化之后,还剩一个问题:已经打开的页面,怎么知道配置变了?
答案是客户端轮询。可见页面大约每 10 秒请求一次 /page-version,带 ETag,服务端没变化时返回 304。10 秒这个间隔是我权衡过的:导航页是常驻页面,10 秒一次的轻量请求成本很低;间隔再短没有意义,配置本来就不是秒级变化的东西;间隔再长,用户改完配置要等很久才看到新版。ETag/304 的配合让“没变化”的轮询几乎不花流量,服务端也只是比对一下修订号。
轮询发现版本变了之后,客户端不会立刻粗暴地整页刷新,而是按 reload 标记处理:当前页的片段修订号已经过期,重新请求新版本化地址下的片段。这就是前面说的 409 + reload 标记在客户端的落点。
导航请求的处理也配合改了。白屏诊断之后,导航不再是纯 network-only,而是 cache-first:先吐出上一版的版本化文档——它本身就是一份不可变快照,内容是自洽的——再由 /page-version 轮询去校准是不是最新版。这样冷启动时用户立刻看到上一版的完整页面,而不是一片白;新版到了,再平滑地换过去。配合 v0.1.5 里“按保存布局生成 PWA 启动骨架”的改动,以及补上的 apple-touch-startup-image 和文档 head 内联背景色,iOS 上那段先天白屏也被收掉了。
这套组合拳的思路可以总结成一句:先给用户看点东西,再悄悄校准到最新。 白屏的反面不是“立刻最新”,而是“立刻有内容”。
五、生产落地的两个教训:ETag 与 Cache.addAll
设计归设计,生产环境还给了我两个独立的教训,都和“缓存更新”这件事直接相关。
第一个教训在 v0.1.1。配置保存时不时误报 409——注意,是误报,不是前面说的那种“旧修订号”的正常 409。查下来是反向代理的 gzip 弱化了 ETag:源站返回强 ETag,代理层 gzip 之后把它降级成弱 ETag,客户端拿弱 ETag 去做条件保存,服务端比对不上,就报了 409。修法是让配置保存的 ETag 比对不再被代理层的 gzip 干扰。这个坑的教训是:ETag 是缓存机制的地基,任何中间层对它的改写都会向上传导。 反代、CDN、压缩,任何一个环节动了 ETag,条件请求就可能全线误报。
第二个教训在 v0.1.10,也就是生产目前跑的版本。它的 release 说明只有一句话:“修复 PWA 缓存更新:预缓存/离线清单按最终 URL 去重,修 Service Worker 更新时 Cache.addAll() 失败装不上”。清单里混进了重复 URL——同一个资源经过不同路径解析出了两个条目,Cache.addAll() 遇到重复就整个失败,新 worker 装不上,旧 worker 和旧缓存就一直留在用户手机上。修法很朴素:清单按最终 URL 去重。但这个朴素的修法背后是一个值得记下来的教训:Service Worker 的更新是原子性的,装不上就是全盘旧版。 预缓存清单是更新链路上的第一环,它的质量直接决定了用户能不能拿到新版。
部署流程本身也为“缓存更新”留了安全边际。每次更新生产环境,我的固定流程是:先备份生产配置和环境文件,再拉代码、钉死版本号、拉镜像、单独执行 up -d,最后验证容器健康、首页 200、管理端 401、生产配置的 md5 更新前后一致。配置 md5 一致是关键验证项:版本升级不应该动用户配置,动了就说明升级过程写坏了什么。v0.1.10 这次的验证就是全绿通过,公网上的 sw.js 也已经是新生成的 worker。
六、取舍与边界
这套机制不是没有代价的,写出来供参考。
第一,轮询是有成本的。10 秒一次的 /page-version,对常驻页面来说可以接受,但如果页面只是偶尔打开,这个轮询就是纯开销。我的取舍是:只在页面可见时轮询,后台标签页不参与。
第二,地址变长了。版本化地址比固定地址长,日志、收藏夹、分享链接里都会带上构建哈希和修订号。这是为“不可变快照”付的代价,我认为值得——可读性差一点,但语义明确得多。
第三,它假设配置变化是低频的。修订号只在配置真变时才换,如果你的应用配置每分钟都在变,这套机制的缓存命中率会很难看,版本化地址的意义也会被稀释。导航页的配置是小时甚至天级变化,这个假设成立。
最后如实说明状态:版本化片段、409 + reload、10 秒轮询这套完整设计,在我项目的 develop 分支里已经实现(核心提交里有 260 行的回归测试),并通过了代码评审,但截至写稿时还没有合入主线、发到生产。生产 v0.1.10 跑着的是白屏修复(v0.1.4)、启动骨架(v0.1.5)和 Cache.addAll 去重(v0.1.10)这几块已经落地的部分。写这篇文章不是为了宣称“全都上线了”,而是把“为什么这样设计”和“生产上已经验证了其中哪几块”分开讲清楚——设计先行、落地分步,本身也是这套机制的一部分。
总结
导航页 PWA 的缓存问题,最后收敛成三条可以复用的经验:
让版本进地址。 文档和片段的地址里带上构建哈希和配置修订号,版本一变地址就变,旧缓存自然失效,不依赖任何 CDN 的 purge 接口。
让新鲜度分级。 状态 15 秒、订阅源 60 秒、天气和错误不缓存。实时数据和异常状态一旦被冻住,错的就是确定的。
先给内容,再校准。 导航 cache-first 先吐上一版快照,10 秒轮询再校准最新版。白屏的反面是“立刻有内容”,不是“立刻最新”。
以及两个生产教训:ETag 会被反代的 gzip 弱化,条件请求的地基要先保住;Service Worker 的更新是原子性的,预缓存清单的一个重复 URL 就能让整个新版装不上。
缓存更新这件事,难的从来不是某一个缓存头怎么写,而是让“新版本”在系统里有一个明确、无歧义、可验证的落点。地址里的那个修订号,就是这个落点。
- 点赞
- 收藏
- 关注作者
评论(0)