PWA 落地到内容站:Service Worker 缓存的四类分法与上线灰度

一、先分清「PWA 到底能带来什么」

很多站长第一次接触 PWA(Progressive Web App)时,是被「像 App 一样装到桌面」这个效果吸引的。但真做起来会发现,把它当成一个「网站换个外壳」的项目,十有八九会做到一半放弃。原因很简单:PWA 并不是一个功能,而是三个互相独立的收益叠在一起——离线可访问、再次访问加速、可安装到桌面。这三件事在技术上是分开的,各自的成本和风险完全不同,只有想清楚要哪几个,才知道该写多少代码。

对个人站长来说,真正值得投入的通常是第二项:再次访问加速。第一次访问的用户感知不到 Service Worker 的存在,第二次、第三次访问才会明显变快——静态资源直接从本地缓存读取,不用再走一次网络往返。而「可安装」这一项,在中文用户场景里的实际转化率非常低,绝大多数用户不会把一个资讯站装到桌面。离线访问听起来很美,但一个内容型网站隔了三天再打开还是离线状态,只会在用户心里留下「这站打不开」的印象。

所以下面这篇文章的写法,是把 PWA 当成一个「缓存代理」来用:Service Worker 负责把静态资源管好,让第二次访问几乎零等待;可安装和离线页做成可选项,做好了是加分,不做也不影响主流程。这个取向决定了后面所有技术选择的取舍——比如缓存策略会偏向「让用户拿到较新的资源」,而不是「保证离线完整可用」。

二、上线前提:三件没做好就别谈 PWA

Service Worker 有一个硬性前提:页面必须运行在 HTTPS 下,而且是在「安全上下文」里。http 页面下 navigator.serviceWorker 直接是 undefined,代码一行都不会执行,也不会有明显报错——这是最常见的「写了没反应」原因。上线前先确认三件事,缺一件就别往下走。

第一是证书覆盖范围。Service Worker 和后面要说的 manifest、推送服务都要求页面与所有被缓存的资源同属安全上下文。如果页面挂了 https,但图片还在 http 域名上,那这些图片既不能进缓存,还会触发混合内容拦截。第二是域名一致性。Service Worker 的作用域是按 URL 路径算的,www 与裸域在浏览器眼里是两个完全不同的源,缓存完全不共享;如果站点同时能通过两个域名访问,等于要做两套,得不偿失。第三是 CDN 与压缩层。如果前面挂了 CDN,要确认它不会改写或删除 Service Worker 脚本本身,也不要给它加长缓存——后面会详细讲这个坑。

三、Service Worker 的生命周期:四个状态与两次等待

写 Service Worker 之前,必须把它的生命周期刻在脑子里,因为「改了代码没生效」的所有问题都出在这里。一个 Service Worker 脚本从注册开始,会经历 installing、installed(waiting)、activating、activated 四个状态。真正麻烦的是 waiting 这一段:如果页面上还开着一个由旧 Service Worker 控制的标签页,新版本就会一直停在 waiting 状态,直到那个标签页全部关闭。

更让人意外的是第二次等待:skipWaiting() 能让新 Service Worker 立刻激活,但如果页面没有跟着刷新,新版本控制不了当前页面,用户看到的还是旧 HTML 加新缓存,很容易出现样式错乱。同样的道理,clients.claim() 能让新 Service Worker 接管已打开的页面,但如果接管的同时缓存是新的、页面是旧的,就会出现「页面结构是旧的,静态资源是新的」这种最难排查的组合。

实务上的做法是:有版本变更时,同时下发一个新版本标识(在构建时把一段版本号写进脚本),页面端监听 controllerchange 事件,在用户切回标签页时提示「有新版本,点击刷新」,而不是自动刷新——自动刷新会把用户正在填的表单冲掉,这在评论区尤其致命。这一段多花二十分钟,能省掉后面几个月「用户说页面错乱但我复现不了」的麻烦。

四、缓存策略的三类分法与对应写法

缓存策略不要按「文件类型」分,要按「资源变化频率 × 拿旧版本的代价」这个二维来分。分完之后只有三类,每一类对应一种写法,记住这三类就不需要再去看各种教程里的七八种组合。

第一类是永不变化或极低频变化的资源:字体文件、第三方库、图标。这类用 Cache First,命中就直接返回,不访问网络。前提是文件名带内容哈希,否则一旦内容变了,用户会永远拿到旧版本。第二类是本站的样式和脚本:用 Stale-While-Revalidate,先返回缓存里的旧版本保证速度,同时在后台发请求更新缓存。这类资源用旧版本最多影响一次显示效果,不会导致功能完全不可用。第三类是HTML 页面:必须用 Network First,网络失败才回落到缓存,超时时间给三秒左右。页面用旧版本的风险最大——用户可能看到一个已经删除的文章、一个过期的活动页。

// sw.js 里的三类策略骨架
const VERSION = 'v20261113a';
const STATIC_CACHE = 'static-' + VERSION;
const HTML_CACHE = 'html-' + VERSION;

self.addEventListener('fetch', (event) => {
  const req = event.request;
  if (req.method !== 'GET') return;
  const url = new URL(req.url);
  if (url.origin !== self.location.origin) return;

  if (/\.(css|js|woff2?|png|jpe?g|webp|svg)$/.test(url.pathname)) {
    event.respondWith(staleWhileRevalidate(req, STATIC_CACHE));
  } else if (req.mode === 'navigate') {
    event.respondWith(networkFirst(req, HTML_CACHE, 3000));
  }
});

注意上面第一行就把跨域请求排除了。这一点很关键:很多教程的示例是无条件拦截所有 fetch,结果把统计脚本、广告脚本、第三方 API 全部塞进自己的缓存,不仅缓存体积失控,还会出现「广告位显示的是上周的内容」这种无法解释的现象。跨域资源由浏览器自己的 HTTP 缓存管就够了,Service Worker 不要越权。

五、缓存版本与清理:别让缓存无限膨胀

Storage 是有配额的,而且不同浏览器给的额度差别很大——桌面 Chrome 通常能到可用磁盘的六成,但 Safari 对单个源的配额要保守得多,移动端 Safari 尤其紧。个人站长最容易犯的错是每次构建都换一个新的缓存名,旧缓存从不删除,几个月后用户的缓存里躺着十几个版本的静态资源。

标准做法是在 activate 事件里遍历所有缓存名,把不是当前版本前缀的全部删掉。这里有个细节:只在 activate 里删是不够的,因为一个用户在 waiting 状态停留很久时,activate 迟迟不触发。可以在页面端注册成功后主动查一次 caches.keys(),发现过期缓存就发消息让 Service Worker 清理,双保险。

另外要定期检查缓存的实际占用。移动端用户被系统清理存储的概率很高,尤其是 iOS——如果缓存被系统删掉,Service Worker 也可能一起被移除,下次访问等于全新用户。所以缓存只能当「加速层」,不能当「数据层」:任何用户必须保留的数据都不能只放在 Cache Storage 里,要么进后端,要么进 IndexedDB 加导出。

六、最坑的一条:sw.js 自己的缓存头

我见过最典型的 PWA 事故是这个:全站静态资源统一加了 Cache-Control: max-age=31536000, immutable,Service Worker 脚本放在根目录,也被这条规则覆盖了。于是浏览器按一年缓存了 sw.js,从此以后不管服务端怎么改,用户那边拿到的永远是第一次那份脚本,缓存策略、版本号、清理逻辑全部失效。站长在本地测试一切正常,用户端却「怎么改都没变化」。

正确做法是给 sw.js 单独开一个 location,强制 Cache-Control: no-cache(注意是 no-cache,不是 no-store,允许存但每次都要协商),并且不要给它加长缓存或 immutable。同时在 CDN 上把这个路径单独设成「不缓存」或「跟随源站」。这一段配置通常只有四行,但它是整个方案能不能迭代的前提。

location = /sw.js {
    add_header Cache-Control "no-cache, max-age=0" always;
    add_header Service-Worker-Allowed "/" always;
    try_files $uri =404;
}

location = /manifest.webmanifest {
    types { } default_type application/manifest+json;
    add_header Cache-Control "no-cache" always;
    try_files $uri =404;
}

第二个 location 里的 types { } 是清空继承的 MIME 类型表,再单独指定 manifest 的类型。有些服务器默认不给 .webmanifest 后缀正确的 MIME,结果是浏览器控制台报「Manifest 的 MIME 类型不被支持」,安装提示永远不会出现。这个报错和缓存策略无关,但常常同时出现,容易让人误判。

七、可安装性与 manifest:三个必须填的字段

可安装性的门槛在不同浏览器上不完全一样,但核心就三个字段:name(或 short_name)、start_url、display。icons 至少要有 192 和 512 两个尺寸,否则 Chrome 会认为这是「不完整的 manifest」。这三项填全了,用户访问几次后才会出现安装提示——注意是有条件的,不是访问一次就弹。

start_url 有一个隐蔽的坑:它决定了独立窗口打开时的落地页。如果写成了带追踪参数的地址,或者写成需要登录的后台地址,用户装完之后点开就是一片空白。另外 start_url 必须与 manifest 同源,跨源会被忽略,这个错误在控制台里提示得很含糊,经常被当成「浏览器不支持」。

还有一个更现实的判断:内容站要不要做可安装?我的建议是先不做明显的安装引导。因为一旦引导用户安装,就等于暗示「这是一个会长期使用的应用」,而用户装完之后如果发现内容更新并不频繁,反而会卸载并降低对站点的印象。相比之下,把 Service Worker 当作纯加速层、完全不提示安装,用户完全无感地获得速度提升,这是性价比最高的用法。

八、推送、后台同步这些「高级功能」先别碰

Web Push 在技术上是可行的,但对个人站来说,投入产出比很差。第一,推送需要额外的推送服务或自己维护 VAPID 密钥与服务端订阅表,服务器端复杂度立刻上一个量级。第二,各浏览器厂商的推送服务通道不同,国内部分浏览器的推送支持并不完整,最终覆盖率可能只有一半用户。第三,也是最关键的:推送的到达率和用户的容忍度完全成正比——推得多,用户关通知甚至卸载;推得少,前面的基础设施白建。

Background Sync 看起来也很美:网络恢复后自动补发一个操作。但它在 Safari 上支持有限,而且对于内容站几乎没有场景——用户的动作无非是浏览和评论,评论失败时给一个明确的错误提示并保留输入内容,比后台同步可靠得多。所以这两块我建议明确写进「暂不做」的清单里,把精力留给缓存策略的打磨。

九、上线姿势:先在子路径里灰度

Service Worker 的作用域受脚本路径限制。如果脚本放在 /sw.js,它能控制整个站点;如果放在 /app/sw.js,就只能控制 /app/ 以下。这既是限制也是机会:要灰度测试,就先在某个子目录里放一份 Service Worker,让它只接管那个目录,观察两三周没有异常再挪到根目录。

灰度期间要盯的指标只有三个:一是错误率,Service Worker 抛错会导致该请求直接失败,用户看到的是资源加载不出来;二是缓存命中情况,用 caches.keys() 与 DevTools 的 Application 面板看实际缓存了多少、命中多少;三是用户投诉里「页面样式不对」「内容过期」两类关键词的出现频率。第三个指标虽然原始,但最灵敏。

另外,一定要写一个「紧急下线」开关。做法是把根目录的 sw.js 替换成一个只做 self.registration.unregister() 并清空所有 caches 的版本,然后把它设成 no-cache。这个文件要提前准备好,放在仓库里,出事时一条命令就能推上去。没有这个开关,一旦缓存策略算出问题,用户端可能几天都退不回来。

十、验证清单:五个可执行的检查

做完之后不要凭感觉判断,按下面五步实测一遍,每一步都有明确的期望结果。

# 1. 确认 sw.js 的缓存头是 no-cache
curl -sI https://example.com/sw.js | grep -i cache-control

# 2. 确认 manifest 的 MIME 与内容都可取
curl -sI https://example.com/manifest.webmanifest | grep -i content-type
curl -s https://example.com/manifest.webmanifest | head -20

# 3. 确认 Service Worker 脚本不被 CDN 改写(对比源站与 CDN 的哈希)
curl -s https://example.com/sw.js | md5sum

# 4. 确认被缓存的资源都是同源、且带指纹
curl -s https://example.com/ | grep -oE '(src|href)='\x22'[^\x22]+\.(js|css)'\x22 | head -20

第四步的输出要人工看一遍:如果出现不带哈希的文件名,说明 Cache First 那类策略不能用在它身上,否则用户永远拿不到新版本。这个检查两分钟就能做完,但能挡掉最常见的「上线后用户看到旧样式」问题。

第五步在浏览器里做:DevTools 打开 Application 面板,勾上 Offline 后刷新页面,看是否还能正常显示;然后清空 Cache Storage 再刷新,看是否正常回落到网络。这两个状态都正常,说明缓存分层写对了。反过来说,如果离线时页面能打开但样式全丢,说明 HTML 进了缓存而 CSS 没进,或者缓存名对不上——这类问题在代码里看不出来,只能实测。

最后提醒一句:PWA 不是一次性工程。每次改缓存策略都要跟着改版本号,每次增删静态资源目录都要回头看一眼正则是否还匹配。把它当成一个小型的、会持续演进的子系统来对待,而不是一个「加上去就完事」的插件。这也是我从几次缓存事故里学到的最实在的一条经验——真正让这类方案变可靠的,不是第一次写得有多漂亮,而是后面每一次改动都留了退路。

Last modification:November 13th, 2026 at 10:24 pm

Leave a Comment