SPA 缓存策略的工程实践:Service Worker 三种模式与发布链路设计

浏览器缓存的基础排查规则——强缓存看响应头灰色 200、协商缓存看 304、静态资源靠 contenthash 换 URL——团队内部已经算是共识,新人入职培训材料里就有一节专门讲这个。但这一年接手的几个需求让我意识到,光靠这套基础规则,解决不了两类真实问题:一是引入 Service Worker 之后,HTTP 缓存的那套心智模型直接失效,SW 拦在最前面,Network 面板里看到的"缓存命中"和实际发生的事情对不上;二是发布新版本时,就算 Nginx 配置和文件名 hash 全都做对了,用户依然可能在几层缓存的夹缝里看到一个"一半新一半旧"的页面,排查起来比单纯的强缓存问题麻烦得多。

Service Worker 插进来之后,缓存的决策权变了

Service Worker(下文简称 SW)这几年逐渐从"实验性技术"变成团队愿意在生产环境小范围落地的东西,主要驱动力是离线可用和二次访问秒开。但它一旦注册成功,就会拦截页面发出的所有网络请求,缓存策略完全由 fetch 事件里的代码决定,HTTP 响应头里的 Cache-Control 反而成了次要参与者——因为请求可能压根没有真正发到网络层。

理解这件事最直接的方式是先看 SW 的生命周期。注册之后有 installactivatefetch 三个阶段,install 阶段负责把首批资源塞进 Cache Storage:

1// sw.js
2var CACHE_NAME = 'app-shell-v3'
3var PRECACHE_URLS = [
4  '/',
5  '/static/css/main.a1b2c3.css',
6  '/static/js/vendor.d4e5f6.js',
7]
8
9self.addEventListener('install', function (event) {
10  event.waitUntil(
11    caches.open(CACHE_NAME).then(function (cache) {
12      return cache.addAll(PRECACHE_URLS)
13    })
14  )
15})

这里的 CACHE_NAME 带版本号是有意为之——不带版本号的话,activate 阶段没有依据判断哪些旧缓存该清理,SW 更新了但老缓存条目会一直堆在 Cache Storage 里,占用户的磁盘配额。activate 阶段照例要清理不属于当前版本的缓存:

1self.addEventListener('activate', function (event) {
2  event.waitUntil(
3    caches.keys().then(function (keys) {
4      return Promise.all(
5        keys
6          .filter(function (key) { return key !== CACHE_NAME })
7          .map(function (key) { return caches.delete(key) })
8      )
9    })
10  )
11})

真正决定缓存行为的是 fetch 事件里怎么写。业界总结出来的几种模式里,团队实际会用到的主要是三种,取舍点完全不一样。

cache-first(缓存优先):命中缓存直接返回,缓存没有才走网络,网络结果再补进缓存。这个策略速度最快,但代价是"用户可能永远看不到更新",因为只要缓存没被主动清理,SW 就不会去问网络。适合内容变化极少、且已经用 hash 做了版本区分的静态资源——字体文件、图标、第三方库这类。

1self.addEventListener('fetch', function (event) {
2  event.respondWith(
3    caches.match(event.request).then(function (cached) {
4      if (cached) return cached
5      return fetch(event.request).then(function (response) {
6        var copy = response.clone()
7        caches.open(CACHE_NAME).then(function (cache) {
8          cache.put(event.request, copy)
9        })
10        return response
11      })
12    })
13  )
14})

network-first(网络优先):先尝试网络请求,成功就用最新结果并更新缓存,网络失败(离线、超时)才退回缓存。牺牲了一点速度(每次都要等网络往返一轮),换来的是"只要有网就一定拿最新的"。这个策略必须用在 HTML 入口和会频繁变化的接口数据上:

1self.addEventListener('fetch', function (event) {
2  if (event.request.mode === 'navigate') {
3    event.respondWith(
4      fetch(event.request)
5        .then(function (response) {
6          var copy = response.clone()
7          caches.open(CACHE_NAME).then(function (cache) {
8            cache.put(event.request, copy)
9          })
10          return response
11        })
12        .catch(function () {
13          return caches.match(event.request)
14        })
15    )
16  }
17})

stale-while-revalidate(先给旧的,后台顺手换新):立刻返回缓存里的内容(哪怕是旧的),与此同时在后台发一次网络请求更新缓存,这次更新对当前页面不生效,下次访问才能吃到。它是前两种策略的折中,用户永远不用等网络,但看到的可能是上一次访问时的版本:

1self.addEventListener('fetch', function (event) {
2  event.respondWith(
3    caches.open(CACHE_NAME).then(function (cache) {
4      return cache.match(event.request).then(function (cached) {
5        var fetchPromise = fetch(event.request).then(function (response) {
6          cache.put(event.request, response.clone())
7          return response
8        })
9        return cached || fetchPromise
10      })
11    })
12  )
13})

三种策略摆在一起看,其实是在"速度"和"新鲜度"之间选一个点。之前团队里犯过一个具体的错误:把 cache-first 用到了 index.html 上,本意是想让首屏更快,结果发布新版本之后,用户即使清掉了浏览器的 HTTP 缓存、走硬刷新,看到的还是旧页面——因为 SW 的 Cache Storage 是独立于 HTTP 缓存的另一层存储,Ctrl+Shift+R 只清 HTTP 缓存,清不掉 Cache Storage。唯一能让用户脱困的办法是等 SW 自己完成一次更新检测,或者用户手动在 DevTools 的 Application 面板里点 Unregister。这类问题不查 Application 面板,光看 Network 面板是永远定位不到的,因为请求在 SW 层面就被截胡了,根本没有走到真正的网络层。

排查 SW 相关的缓存问题,Application 面板下的 Service Workers 和 Cache Storage 两栏是必看项:前者能看到当前注册的 SW 版本、是否有等待中的新版本(waiting 状态),后者能直接展开每个 Cache 里存了哪些请求、对应的响应内容是什么。SW 更新还有一个反直觉的地方——新的 sw.js 文件即使已经被浏览器下载并进入 installed 状态,默认也不会立刻接管页面,要等所有已打开的、由旧 SW 控制的标签页全部关闭,新 SW 才会激活。想让新版本立刻生效,需要在新 SW 的 install 里显式调用 self.skipWaiting(),并且页面里监听到新 SW 激活后调用 self.clients.claim(),两者配合才能让"发布即生效"这件事对 SW 场景也成立。

新版本迟迟不生效:SW 的更新检测机制要主动触发

前面提到新 SW 装好了要等所有旧标签页关闭才会激活,这条规则在实际项目里造成过一个具体的困扰:用户开着一个电商后台页面挂机一整天不关,团队这期间发布了两次版本,用户始终感知不到,因为浏览器默认只在页面导航(刷新、重新进入)时才会去检查 sw.js 有没有更新,而这个用户全程没有刷新过。

浏览器检查 SW 更新的默认时机是页面加载时——拿新拉到的 sw.js 和已注册的版本做字节级比对,不一样才会触发新版本的 install。这个检查本身还受 HTTP 缓存影响,如果 sw.js 文件被 Nginx 配了较长的 max-age,浏览器可能连这次比对都拿的是旧文件,永远比对不出差异。所以 sw.js 本身的响应头必须是 no-cache,这条规则比 index.html 的要求还硬——很多团队做了 HTML 的协商缓存却漏掉了 sw.js,结果 HTML 更新了,SW 却因为浏览器缓存住了旧的 sw.js 文件而迟迟没有更新检测的机会。

1# sw.js 本身绝不能强缓存,否则更新检测永远拿到旧文件
2location = /sw.js {
3  add_header Cache-Control "no-cache";
4}

除了被动等待页面导航触发检查,还可以在代码里主动调用 registration.update() 定期轮询,把检测频率握在自己手里,这对长时间挂机不刷新的后台类页面尤其重要:

1navigator.serviceWorker.register('/sw.js').then(function (registration) {
2  setInterval(function () {
3    registration.update()
4  }, 60 * 60 * 1000) // 每小时主动检查一次
5
6  registration.addEventListener('updatefound', function () {
7    var newWorker = registration.installing
8    newWorker.addEventListener('statechange', function () {
9      if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
10        // 新版本已经装好,但还在等旧标签页关闭
11        // 这里通常会弹一条提示条,让用户主动点击刷新
12        showUpdateToast(function () {
13          newWorker.postMessage({ type: 'SKIP_WAITING' })
14        })
15      }
16    })
17  })
18})
19
20navigator.serviceWorker.addEventListener('controllerchange', function () {
21  window.location.reload()
22})

配合 SW 内部监听 message 事件调用 skipWaiting(),就能把"新版本装好但被动等待"变成"新版本装好、提示用户、点击后立刻生效",这是比强行 skipWaiting() 更稳妥的做法——强行跳过等待意味着正在使用旧版本页面的用户可能在毫无提示的情况下被切换到新版本,如果新旧版本的接口协议有不兼容变更,页面可能直接报错。给用户一个"有新版本,点击刷新"的提示条,是工程上更负责任的选择。

1// sw.js 内监听主动跳过等待的消息
2self.addEventListener('message', function (event) {
3  if (event.data && event.data.type === 'SKIP_WAITING') {
4    self.skipWaiting()
5  }
6})

灰度发布下的一个经典坑:chunk 404

带 hash 的资源文件名解决了"缓存要不要更新"的问题,但引入了另一个在灰度发布场景下才会暴露的问题:用户当前页面里的 HTML 是版本 A 加载的,其中记录着一批 chunk 的文件名;如果这时候发生了一次新的发布(版本 B),旧版本 A 对应的这批 chunk 文件被构建产物目录清理掉了,用户在版本 A 页面里进行一次触发按需加载的操作(比如切换路由,触发 import() 动态加载一个还没加载过的 chunk),请求的还是版本 A 的文件名,但源站已经没有这个文件了,请求直接 404。

这个问题在小流量发布、多个版本短暂并存的场景下出现得更频繁,因为版本切换的时间窗口拉长了。常见的规避方式有两种。一种是保留策略:发布新版本时,不立即删除上一个版本(甚至上上个版本)的构建产物目录,服务端同时对外提供多份历史产物,直到确认没有活跃用户还在使用旧版本再清理,这需要构建产物目录按版本号或者发布时间归档,而不是每次发布直接覆盖同一个目录。

1# 部署目录按发布版本归档,而不是覆盖同一个目录
2/var/www/app/releases/2021-03-01-1024/
3/var/www/app/releases/2021-03-04-0930/
4/var/www/app/current -> /var/www/app/releases/2021-03-04-0930/

current 是一个软链接,指向最新版本目录,Nginx 配置里 root 指向 current,发布时只需要切换软链接指向,旧版本目录暂时保留几天再清理,用户手里的旧 HTML 引用的 chunk 依然能在旧目录里找到。

另一种是应用层退路:在路由跳转或者动态 import() 失败时捕获错误,判断是不是因为 chunk 加载失败,如果是,直接触发一次整页刷新,让用户拿到最新的 HTML 和与之匹配的资源列表,避免用户卡在一个加载不出内容的半死页面:

1// 路由懒加载失败时的补救处理
2function loadRouteComponent(importFn) {
3  return importFn().catch(function (error) {
4    var isChunkLoadError =
5      error.name === 'ChunkLoadError' ||
6      /Loading chunk [\d]+ failed/.test(error.message)
7    if (isChunkLoadError) {
8      window.location.reload()
9      return new Promise(function () {}) // 阻塞住,等刷新发生
10    }
11    throw error
12  })
13}

两种方案不是互斥的,团队目前的组合是:部署时保留最近两个版本的产物目录降低 404 概率,应用层再加一道退路挡住极端情况(比如运维手滑提前清理了旧目录)。这个问题本质上和 SW 的版本切换是同一类矛盾——只要允许新旧版本并存的时间窗口存在,就必须假设用户随时可能拿着旧版本的引用信息来访问,系统要么保证旧引用依然有效,要么在失效时有补救动作,两者选一个,但不能什么都不做。

部署脚本里落实"保留最近几个版本"这条规则,通常要在发布流程的最后一步加一段清理逻辑,而不是完全不清理——构建产物会随着版本迭代持续占用磁盘空间,团队实际用的清理脚本类似这样:

1#!/bin/bash
2# 每次发布成功后执行,只保留最近 3 个版本目录
3RELEASES_DIR=/var/www/app/releases
4KEEP_COUNT=3
5
6cd "$RELEASES_DIR" || exit 1
7ls -1t | tail -n +$((KEEP_COUNT + 1)) | while read -r old_release; do
8  echo "cleaning up old release: $old_release"
9  rm -rf "${RELEASES_DIR:?}/${old_release}"
10done

KEEP_COUNT 设成多少要结合两个数字来定:一是 SW 或浏览器 HTTP 缓存里 HTML 最长可能存活多久(取决于 no-cache 场景下用户离线时长,或者 SW 场景下用户挂机不刷新的时长),二是两次发布之间的实际间隔。如果团队一天能发布好几次,保留 3 个版本可能都不够覆盖某个几天没刷新页面的用户;如果发布频率是每周一两次,保留 3 个版本通常绰绰有余。这个数字不是拍脑袋定的,而是要对着实际的发布节奏和用户访问模式核算一遍。

SPA 组合拳的设计原理:为什么必须是"HTML 短、资源长"这套搭配

前几年的排查经验总结成了一句话:入口 HTML 走协商缓存或不缓存,带 hash 的静态资源走强缓存。这一年想把这套组合拳背后的设计原理再想透一层——它本质上解决的是"如何在不知道未来会不会发布新版本的前提下,让缓存策略保持自洽"。

单页应用的资源依赖是一棵树:index.html 是根,它里面写死了入口 JS/CSS 的文件名;入口 JS 又通过 import() 动态引用其他 chunk,这些 chunk 的文件名同样是构建时生成的 hash。整棵树上,只有根节点的 URL 是固定不变的(用户书签、直接输入的域名都指向它),其余所有节点的"更新"都表现为"URL 变了"。这意味着:只要根节点每次都能拿到最新内容,整棵树就能顺着新的引用关系连锁更新到最新;反过来,只要根节点被缓存住不刷新,无论下面的资源发布得多勤,用户看到的永远是旧树。

这就是为什么"HTML 不能强缓存"不是一条孤立的最佳实践,而是从依赖树结构直接推导出的必然结论。团队里新人经常问的一个问题是"HTML 不缓存会不会导致每次访问都很慢",答案是不会——因为 HTML 走的是 no-cache(协商缓存)而不是完全不缓存,只要内容没变就是 304,传输的字节数极小,真正占带宽的是 JS/CSS/图片这些体积大的资源,而它们恰好可以被长期强缓存。协商缓存和强缓存不是二选一,是分别用在依赖树的不同层级上。

webpack 5 在这套体系里带来一个值得记录的改进:contenthash 的计算方式变得更稳定了。webpack 4 时代,contenthash 虽然名义上按内容算,但实际实现里会把一些和内容无关的东西(比如模块 id、chunk 的内部标识符)掺进哈希输入里,导致改动一个不相关的模块,另一个模块的 chunk hash 也可能跟着变——这在上一版本里已经是个已知问题。webpack 5 默认启用了更彻底的确定性算法,配合 optimization.moduleIds: 'deterministic'(不再用递增的数字 id,而是用模块路径算出的短 hash 作为 id),同一份源码不管构建多少次、不管其他模块怎么改,没有实际变化的 chunk 产出的文件名可以做到完全不变:

1// webpack.config.js(webpack 5)
2module.exports = {
3  output: {
4    filename: 'js/[name].[contenthash:8].js',
5    chunkFilename: 'js/[name].[contenthash:8].chunk.js',
6  },
7  optimization: {
8    moduleIds: 'deterministic',
9    chunkIds: 'deterministic',
10    runtimeChunk: 'single',
11  },
12}

runtimeChunk: 'single' 这一条同样重要,原理和 moduleIds 是一回事:webpack 的运行时代码(负责模块加载、chunk 拼接的那部分胶水代码)如果和业务代码打在一起,每次改动业务代码,运行时也会跟着重新生成,连带影响到不该变的 vendor chunk 的引用关系。把运行时单独抽成一个体积很小的 chunk 之后,它自己变不变是它的事,不会牵连别的文件——这个小 chunk 本身缓存时间可以设得短一些,反正体积不大,重新下载的成本可以忽略。webpack 5 另外一个和缓存相关的重大变化是持久化缓存(cache: { type: 'filesystem' }),它缓存的是构建过程本身的中间产物,用来加速二次构建速度,和这里讨论的"浏览器怎么缓存产物"是两回事,但团队里评估 webpack 5 升级时经常把两者搞混,值得单独说明区分开。

CDN 和源站是两层缓存,排查时要分层验证

静态资源过 CDN 分发之后,实际上多了一层缓存主体:CDN 边缘节点自己也会缓存资源,而且它遵守的规则和浏览器不完全一样。浏览器只认 Cache-Control 里对客户端可见的部分,CDN 更关心 s-maxage(专门给共享缓存用的字段,优先级高于 max-age)以及 CDN 控制台里单独配置的缓存规则——很多 CDN 服务商允许针对文件后缀名设置缓存时间,这个配置往往和源站响应头是两套独立的东西,甚至可能互相覆盖。

这两层缓存分别对应两种失效场景。第一种是"源站缓存过期了但 CDN 节点还没回源":CDN 为了减少回源压力,即使源站响应头写着 max-age=3600,CDN 自己的规则可能设置的是缓存 24 小时,用户从这个 CDN 节点拿到的资源,实际比源站声明的"新鲜期"要长得多。第二种更麻烦——同一份资源在不同的 CDN 边缘节点之间不同步。用户第一次访问由就近的节点 A 提供服务并缓存下来,发布新版本之后源站已经更新,但节点 A 还没有触发重新回源(可能是因为节点 A 自己的缓存还没过期,也可能是刷新指令没有覆盖到所有节点),这个用户如果恰好又路由到节点 A,看到的还是旧内容;而路由到刚被刷新过的节点 B 的用户,看到的已经是新内容。同一时刻,不同用户看到不一样的版本,是 CDN 场景特有的现象,单纯查看自己的浏览器完全定位不到。

排查这类问题,需要绕开自己本地的网络环境去多个地理位置分别探测,思路和查 DNS 传播延迟很像:

1# 分别指定不同的 CDN 节点 IP(或用第三方多地 ping 工具),
2# 直接对比同一 URL 在不同边缘节点上的响应内容和响应头
3curl -I --resolve static.example.com:443:203.0.113.10 https://static.example.com/app.a1b2c3.js
4curl -I --resolve static.example.com:443:203.0.113.20 https://static.example.com/app.a1b2c3.js

如果两次请求的 ETagLast-Modified 不一致,就说明确实存在节点间不同步的情况,这时候唯一的解法是主动触发 CDN 的刷新(purge)接口,把发布流程和 CDN 刷新绑在一起,而不是等缓存自然过期。带 hash 的资源理论上不需要手动 purge——因为文件名变了就是新 URL,CDN 对"新 URL"永远是第一次回源,天然不存在"旧节点还在吐旧内容"的问题。真正需要手动 purge 的,只有 index.html 这类文件名不带 hash、内容却必须实时更新的入口资源。这也是为什么"给入口做协商缓存 + 主动 purge CDN"这两件事要一起做:协商缓存解决浏览器这一层的及时性,purge 解决 CDN 这一层的及时性,缺一个都可能让"发布已经完成但用户看不到新版本"的时间窗口被拉长到不可控。

发布新版本后用户停在旧页面,完整的排查顺序

这一年把这类问题的排查顺序沉淀成了一个相对固定的流程,核心思路是按"离用户最近到离用户最远"的顺序逐层排除,避免一上来就怀疑 CDN 或者服务端,浪费时间。

先确认现象本身:用无痕窗口重新打开页面。如果无痕窗口里就是新页面,说明问题出在用户本地的持久化状态——可能是 HTTP 缓存,也可能是前面说到的 Service Worker 的 Cache Storage,两者互不影响、需要分别清。如果无痕窗口里依然是旧页面,问题不在客户端,要往后端和 CDN 方向查。

接着检查 SW 的注册状态。打开 DevTools 的 Application 面板,看 Service Workers 一栏是否存在注册记录、状态是 activated 还是卡在 waiting。如果有 SW 而且用的是 cache-first 策略缓存了 HTML,那问题基本可以定性——需要检查 SW 里是否正确实现了版本切换逻辑(skipWaiting / clients.claim),或者干脆把 HTML 排除在 SW 的预缓存列表之外,改成 network-first。

然后看 HTML 响应头本身命中的是哪层缓存,用 curl -I 直接绕开浏览器确认:

1curl -I https://example.com/

重点看 cache-control 是不是被意外配成了较长的 max-age,以及响应头里有没有额外的 age 字段——这个字段如果存在且数值较大,说明请求是被某一层缓存(通常是 CDN 或反向代理)直接命中返回的,并没有真正回源,这一层也需要纳入排查范围。

再对比 HTML 里实际引用的资源文件名和最新一次构建产物的文件名是否一致。如果 HTML 已经是最新的、但里面写的资源文件名还是老版本,说明发布流程本身有问题——多半是构建产物没有正确覆盖到线上目录,或者部署脚本里资源上传和 HTML 上传的先后顺序反了(资源应该先于 HTML 上传完成,否则会出现短暂的窗口期:新 HTML 已经生效,但它引用的新资源文件还没有真正就位,返回 404)。

最后如果前面几步都没问题,才轮到怀疑 CDN 节点不同步,用前面提到的多地探测方式确认,确认之后触发一次全量 purge。

这个顺序把最常见、最容易自证的原因放在最前面,把需要跨团队协调(CDN 侧操作往往需要运维权限)的原因放在最后,实际排查效率比想到哪查到哪要高得多。

实际排查中最费时间的往往不是单一原因,而是几个原因叠加出现——比如同时存在 SW 缓存了旧 HTML、又叠加 CDN 某个节点没刷新这两个问题,只解决其中一个,用户依然反馈"还是旧的",容易把排查者的注意力引偏,误以为刚才的修复没生效,实际上是还剩下另一层没处理。这种复合场景下,前面提到的排查顺序更需要走完整,而不是任何一步看着"顺眼"就提前收工——每一步都要拿到明确的证据(浏览器无痕验证过、Application 面板确认过 SW 状态、curl 确认过响应头、多地探测确认过 CDN 节点内容一致),而不是凭经验猜一个原因就去修,改完发现没解决又倒回去猜下一个。把每一层的验证方法固定下来、每次都走一遍,比依赖经验判断"这次大概率是哪一层的问题"要可靠,尤其是在几层问题同时存在的场景下,凭经验很容易只发现了其中一层就提前结案。

手写 SW 还是用 Workbox:取舍在哪

前面几段的 fetch 事件处理代码都是手写的,团队内部评估过要不要换成 Workbox(Google 维护的一套 SW 工具库)。手写的好处是逻辑完全透明,出问题排查起来直接看代码就知道走了哪条分支;代价是随着缓存路径变多(HTML 一套策略、静态资源一套、接口数据又一套),fetch 事件里的条件判断会越堆越长,容易在某个分支漏掉边界情况——比如忘记处理 event.request.method !== 'GET' 的情况,把一个 POST 请求也塞进了缓存匹配逻辑里,导致奇怪的行为。

Workbox 把 cache-first、network-first、stale-while-revalidate 这几种策略封装成了现成的类,注册时按路由匹配规则声明用哪种策略,不需要在 fetch 事件里手写分支:

1// 使用 Workbox 的声明式写法
2importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.0.0/workbox-sw.js')
3
4workbox.routing.registerRoute(
5  function (ctx) { return ctx.request.mode === 'navigate' },
6  new workbox.strategies.NetworkFirst({ cacheName: 'html-cache' })
7)
8
9workbox.routing.registerRoute(
10  /\.(?:js|css)$/,
11  new workbox.strategies.CacheFirst({
12    cacheName: 'static-resources',
13    plugins: [
14      new workbox.expiration.ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 }),
15    ],
16  })
17)
18
19workbox.routing.registerRoute(
20  /\/api\/dict\//,
21  new workbox.strategies.StaleWhileRevalidate({ cacheName: 'api-dict' })
22)

ExpirationPlugin 这类插件解决了手写方案里容易被忽略的一个问题:Cache Storage 不会自动清理,如果只顾着往里面塞资源不做数量或时间上限,缓存会无限膨胀,占满用户设备的存储配额,浏览器达到配额上限后会开始按某种策略(通常是最近最少使用)自动清理,行为不完全可控。Workbox 内置的过期插件能显式限定"最多存多少条""超过多久自动清理",比手写时容易漏掉这层收尾工作要稳妥。

团队目前的结论是:如果 SW 只是简单缓存几个静态资源,手写完全够用,代码量也不大;如果缓存策略要覆盖到多种资源类型、还要处理过期淘汰这类细节,引入 Workbox 能省下不少容易踩坑的边界处理,换来的代价是多引入一个依赖、调试时多一层封装需要理解。这不是非此即彼的选择,实际项目里可以先用手写方案跑通核心链路,等策略复杂度真的上来了再迁移到 Workbox,不需要一开始就假设复杂度。

不同资源类型该配多长的缓存时间,判断依据是什么

前面的讨论集中在"要不要缓存""缓存多久"背后的机制,具体到某一类资源该配多长的 max-age,团队里的判断标准逐渐从"抄一个业界推荐值"变成了"看这类资源的变更频率和变更后的影响范围"。

带 hash 的 JS、CSS 属于"内容变了 URL 一定变"的资源,缓存时间可以设到理论最大值(max-age=31536000 也就是一年,浏览器和大多数 CDN 都认这个作为长缓存的上限,配更大的数字没有实际意义),加 immutable 免掉刷新时的协商请求。图片和字体这类资源需要分两种情况看:如果文件名也带了内容 hash(构建流程里跑过 file-loader 或者类似工具处理),可以享受和 JS/CSS 一样的待遇;如果是运营手动上传到 CDN、文件名固定不变的图片(比如首页 banner),就不能配太长的强缓存,因为运营随时可能替换同一个 URL 下的图片内容,这种场景下要么在图片管理后台的上传流程里强制生成带 hash 的新 URL,要么退而求其次配一个折中的 max-age(比如一天到一周)配合 ETag,让运营换图之后能在可接受的时间窗口内触达用户。

字体文件有一个容易被忽视的特殊性:Cache-Control 对跨域字体请求要配合 CORS 头一起生效,如果字体是从单独的静态资源域名(和主站不同源)加载的,响应头里缺少 Access-Control-Allow-Origin,某些浏览器会拒绝使用这个字体,表现为页面掉回了备用字体、样式看起来不对,排查时容易先怀疑成缓存问题,实际是跨域配置缺失,这个坑在把静态资源迁移到独立 CDN 域名时出现过一次。

接口返回的、允许缓存的数据(字典、地区列表这类)如果是通过 HTTP 层面的 Cache-Control 缓存而不是前面提到的 SW 或前端内存缓存,要格外小心 Vary 头的配置。同一个接口 URL,如果按 Accept-Language 返回不同语言的内容,又没有配 Vary: Accept-Language,缓存层(不管是浏览器还是 CDN)可能会把某个语言版本的响应缓存下来,之后不管请求方要哪种语言,都拿到了同一份缓存内容——这是国际化场景下一个真实会发生的缓存污染问题,本质上和这条链路里反复出现的原则是一回事:缓存的判断依据必须覆盖所有会导致响应内容不同的请求维度,遗漏任何一个维度,缓存就可能把不该复用的内容错误地复用。

接口数据缓存:SW 场景下的补充考虑

之前团队内部的接口缓存方案是前端自己维护一个带 TTL 的内存字典,这个方案在普通页面里工作良好,但引入 SW 之后,它和 SW 的缓存层是两套独立的机制,互相不知道对方的存在,可能出现"内存缓存已经过期该发请求了,但请求又被 SW 用 cache-first 策略拦下来给了更旧的数据"这种叠加的陈旧问题。

这一年团队对这类接口的处理原则调整成:GET 类型的配置接口如果确实要在 SW 层缓存,统一使用 stale-while-revalidate——先给用户已有的数据展示,不阻塞界面,后台顺手换新,下次进来就是最新的;同时把 URL 里对版本敏感的参数(比如接口的业务版本号)作为查询字符串带上,version 变了自然就是新的 Cache 条目,不需要额外的失效逻辑:

1self.addEventListener('fetch', function (event) {
2  var url = new URL(event.request.url)
3  if (url.pathname.indexOf('/api/dict/') === 0) {
4    event.respondWith(
5      caches.open('api-dict-v1').then(function (cache) {
6        return cache.match(event.request).then(function (cached) {
7          var fetchPromise = fetch(event.request).then(function (response) {
8            if (response.ok) cache.put(event.request, response.clone())
9            return response
10          })
11          return cached || fetchPromise
12        })
13      })
14    )
15  }
16})

订单状态、用户权限这类强一致性要求的接口,依然明确排除在任何形式的持久化缓存之外,包括 SW 层——fetch 事件里对这类路径直接透传给网络,不做任何拦截判断,这一条在团队内部的 SW 代码规范里写成了硬性要求,评审时专门会检查。

离线降级:缓存策略之外还要设计一个兜底页面

引入 SW 的初衷之一是离线可用,但"离线可用"这四个字如果只理解成"缓存住了资源所以能显示",实际做起来会漏掉一个环节——网络请求失败时给用户看什么。前面 network-first 的写法里,catch 分支退回 caches.match(event.request),这句话有一个隐含前提:这个请求之前必须被缓存过,如果用户第一次访问就恰好断网,请求既没有网络结果、Cache Storage 里也没有对应的条目,caches.match 返回 undefined,页面会呈现一个彻底空白的结果,用户体验上比什么都没做还糟糕——至少浏览器原生的离线提示页还会告诉用户"这是网络问题"。

正确的做法是在 install 阶段就预缓存一个专门的离线兜底页面,fetchcatch 分支里,如果连缓存都没有命中,最后退回这个页面,而不是让 undefined 直接传导到页面上:

1var OFFLINE_URL = '/offline.html'
2
3self.addEventListener('install', function (event) {
4  event.waitUntil(
5    caches.open(CACHE_NAME).then(function (cache) {
6      return cache.addAll(PRECACHE_URLS.concat([OFFLINE_URL]))
7    })
8  )
9})
10
11self.addEventListener('fetch', function (event) {
12  if (event.request.mode === 'navigate') {
13    event.respondWith(
14      fetch(event.request).catch(function () {
15        return caches.match(event.request).then(function (cached) {
16          return cached || caches.match(OFFLINE_URL)
17        })
18      })
19    )
20  }
21})

offline.html 本身要写得足够简单、自包含——不能再依赖任何需要网络才能加载的外部资源(包括某些走 CDN 加载的图标字体),否则在真正离线的场景下,这个页面自己也加载不完整,等于白设计。团队里这个页面最后做成了纯内联样式的一屏文字提示,加一个"重新检测网络"的按钮,点击后重新发起一次 fetch,成功就整页跳转回原来的地址。

这一层设计容易被忽略,是因为开发和测试阶段几乎不会真的断网去验证,只有等用户在电梯、地铁隧道这类弱网或无网环境里实际访问时才会暴露。Chrome DevTools 的 Network 面板里有一个 Offline 选项,勾上之后可以直接模拟断网状态测试这条路径,比真的关闭电脑网络方便得多,这也是排查、验证离线降级逻辑时最常用的手段。

小结这条链路上真正值钱的判断

缓存这件事到这一年为止,团队内部已经不再把它当成"配几个响应头"的孤立操作,而是当成一条从浏览器本地存储、Service Worker、CDN 边缘节点,一路延伸到源站和构建产物命名策略的完整链路来对待。每一层缓存都有自己的失效规则和排查方式,出问题时不能想当然地只查距离自己最近的那一层。真正能省排查时间的做法,是提前想清楚每种资源属于依赖树的哪个位置——是像 index.html 这样必须保持新鲜的根节点,还是像带 hash 的 JS/CSS 这样可以放心长期缓存的叶子节点,抑或是像接口数据这样需要按业务新鲜度单独判断的动态内容——再针对每一类分别选择对应的缓存策略和更新机制,而不是套用同一套配置应付所有场景。

这条链路上每一层引入的新变量,都是在原有基础上叠加复杂度,而不是替换原有规则。强缓存和协商缓存的判断依据没有变,contenthash 换 URL 的原理也没有变,Service Worker 只是在这些规则之上多插入了一层"由前端代码显式接管"的决策点,CDN 只是多插入了一层"由地理位置决定命中哪个缓存副本"的变量。这类问题排查起来费时间,大多数时候不是某一层单独出了错,是几层的失效时机凑巧没对上——SW 版本切换的时间窗口、CDN 节点回源的时间窗口、构建产物保留的时间窗口,三者只要有一个和另外两个对不齐,就会出现用户侧感知到的"新旧混杂"。把这三个时间窗口放在同一张时间线上去核对,是排查这类问题时比单独盯着某一层响应头更有效的思路,也是这一年从几次真实的发布问题里沉淀出来、值得写进团队工程规范的一条判断标准。