Service Worker + stale-while-revalidate:让页面"假装"秒开的正经方案
你肯定遇到过这种场景:用户打开一个列表页,接口响应要 800ms,白屏晃一下,数据才出来。产品跑过来说"能不能秒开"。
秒开?服务端又不是你家的,CDN 也不归你管。但有一件事你能控制——上次请求的数据还躺在缓存里,为什么不先拿出来顶上?
这就是 stale-while-revalidate 的大致思路。先给用户看"旧的",后台悄悄拿"新的",拿到了再换上去。HTTP 协议本身支持这个策略,但浏览器实现得比较保守,真正好用的版本得靠 Service Worker 自己搞。
先搞清楚 HTTP 层的 stale-while-revalidate
Cache-Control 有个不太常用的指令:
Cache-Control: max-age=60, stale-while-revalidate=300
意思是:60 秒内直接用缓存,过期后的 300 秒内,先返回旧缓存,同时后台去 revalidate。超过 360 秒才真正过期。
听着挺完美,但实际用起来有几个问题:
- 浏览器支持参差不齐,Safari 到 2024 年才补上
- 只对 GET 请求生效,POST 的接口没戏
- 你控制不了"拿到新数据后做什么"——浏览器默默更新缓存,但当前页面的 UI 不会变
- 服务端不一定愿意配这个 header,后端同事可能觉得你在搞事
所以 HTTP 层的 SWR 更像个"被动优化"。你想精确控制缓存策略、想在新数据到了之后更新页面、想针对特定接口做差异化处理——得上 Service Worker。
Service Worker 里手搓 stale-while-revalidate
核心逻辑其实不复杂,伪代码就这么几行:
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
// 不管有没有缓存,都发一次真实请求
const fetching = fetch(event.request).then((response) => {
// 拿到新响应,更新缓存
const clone = response.clone()
caches.open('api-cache').then((cache) => {
cache.put(event.request, clone)
})
return response
})
// 有缓存?先返回缓存。没有?等网络请求
return cached || fetching
})
)
})
看着简单,但这段代码有个很大的问题:网络请求的结果拿到了,怎么通知页面?
缓存返回 + 增量通知:真正能用的版本
解决办法是把"通知页面更新"这件事,从 response 层面提到消息通信层面。
// sw.js
const SWR_URLS = ['/api/feed', '/api/user/profile', '/api/config']
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// 只对特定接口做 SWR,别全局开,会出事
if (!SWR_URLS.some((p) => url.pathname.startsWith(p))) return
event.respondWith(handleSWR(event.request))
})
async function handleSWR(request) {
const cache = await caches.open('api-swr')
const cached = await cache.match(request)
// 后台发请求,不阻塞返回
const networkPromise = fetch(request)
.then(async (response) => {
if (response.ok) {
await cache.put(request, response.clone())
// 拿到新数据了,通知所有页面
const data = await response.clone().json()
const clients = await self.clients.matchAll()
clients.forEach((client) => {
client.postMessage({
type: 'SWR_UPDATE',
url: request.url,
data,
})
})
}
return response
})
.catch(() => cached) // 网络挂了,还是用缓存兜底
// 有缓存就先返回,没有就等网络
return cached || networkPromise
}
页面那边监听消息:
// main.js
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'SWR_UPDATE') {
const { url, data } = event.data
// 根据 url 判断要更新哪块 UI
if (url.includes('/api/feed')) {
store.commit('updateFeed', data) // Vue 的写法
// 或者 dispatch 一个 action,看你项目怎么组织
}
}
})
哪些接口适合做 SWR,哪些别碰
不是所有接口都该走这套逻辑。分两类:
适合的:
- 列表型数据(文章列表、商品列表)——旧数据和新数据差异通常不大
- 配置型接口(用户设置、功能开关)——变更频率低
- 个人信息(头像、昵称)——就算展示了旧的,几百毫秒后更新也没人在意
说到别碰的:
- 余额、库存、价格,展示旧数据可能导致用户决策错误
- 验证码、token 相关——用缓存数据直接出问题
- 实时性要求高的(聊天消息、通知数)——stale 数据体验更差
之前在项目里犯过一次错,把订单状态接口也加了 SWR,用户付完款回来,看到的还是"待支付",慌了,又点了一次支付。虽然后端做了幂等,但客诉还是来了,后来老老实实把这类接口从 SWR 列表里摘出去了。
缓存版本控制:不处理迟早翻车
纯粹的 cache.put 有个隐患:接口返回的数据结构变了怎么办?
比如 v1 返回 { list: [...] },v2 改成了 { items: [...], total: 100 }。用户本地缓存的还是 v1 的结构,页面代码已经按 v2 写了,直接报错。
解决思路是给缓存加版本:
const CACHE_VERSION = 'api-swr-v3'
// 激活时清理旧版本缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith('api-swr-') && key !== CACHE_VERSION)
.map((key) => caches.delete(key))
)
)
)
})
每次前端发版,如果接口结构有 breaking change,把 CACHE_VERSION 改一下就行。SW 更新后会触发 activate,旧缓存自动清掉。
不过这里有个时间差的问题——SW 更新不是即时的。用户可能在旧 SW 还在跑的时候就加载了新页面代码。这种情况下要么在页面侧做防御性判断,要么用 skipWaiting() 强制接管(但 skipWaiting 也有自己的坑,后面说)。
skipWaiting 的取舍
self.addEventListener('install', () => {
self.skipWaiting() // 装完直接激活,不等旧 SW 退出
})
好处很明显:新 SW 立刻生效,缓存版本立刻切换。
坏处:如果用户当前页面正在用旧 SW 处理请求,突然 SW 换了,可能出现一半请求走旧逻辑、一半走新逻辑的情况。
我的做法是:SWR 缓存场景下用 skipWaiting,但在页面侧监听 controllerchange 事件,检测到 SW 切换后自动刷新一次。
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return
refreshing = true
window.location.reload()
})
和 HTTP 缓存的关系:别打架
这块容易搞混。Service Worker 里的 fetch() 也是会走浏览器 HTTP 缓存的。如果服务端给接口加了 Cache-Control: max-age=300,那 SW 里 fetch(request) 拿到的可能也是 HTTP 缓存里的旧响应,根本不是最新的。
两层缓存叠在一起,你以为在 revalidate,其实在自己跟自己玩。
解法:SW 里发请求时强制跳过 HTTP 缓存。
const networkPromise = fetch(request, {
cache: 'no-cache', // 跳过 HTTP 缓存,但仍然会写入缓存
// 或者用 'reload',完全不读也不写 HTTP 缓存
})
no-cache 和 no-store 的区别:
// no-cache:发请求时不用 HTTP 缓存,但响应可以被缓存
// → 适合 SWR 场景,你想让 SW 层管缓存,HTTP 层别插手
// no-store:完全不缓存
// → 太激进了,连浏览器的 back/forward cache 都会受影响
监控:怎么知道 SWR 在正常工作
上线之后你怎么知道 SWR 真的在生效?不能全凭体感。
加几个埋点:
async function handleSWR(request) {
const cache = await caches.open(CACHE_VERSION)
const cached = await cache.match(request)
const startTime = performance.now()
const networkPromise = fetch(request, { cache: 'no-cache' })
.then(async (response) => {
const networkTime = performance.now() - startTime
// 上报:网络请求耗时 + 是否命中了缓存
reportMetrics({
url: request.url,
cacheHit: !!cached,
networkTime,
// 如果命中缓存,用户实际看到的耗时约等于 0
perceivedTime: cached ? 0 : networkTime,
})
if (response.ok) {
await cache.put(request, response.clone())
await notifyClients(request.url, response.clone())
}
return response
})
return cached || networkPromise
}
重点看两个指标:
- 缓存命中率:低于 60% 说明你的缓存策略有问题,可能是缓存被清得太频繁
- perceived time(感知耗时):命中缓存时应该趋近于 0,这个才是用户体感
之前在一个项目里上了 SWR,缓存命中率能到 85% 左右。首屏的接口数据展示时间从平均 600ms 降到了接近 0(缓存命中的情况下),整体 P90 也从 1.2s 降到了 400ms。效果还是挺明显的。
容易忽略的边界情况
几个上线后才会遇到的问题:
1. Cache Storage 空间有限
浏览器对 Cache Storage 有配额限制(通常是可用磁盘的一定比例)。如果你缓存的接口太多,旧的缓存可能被浏览器自动清理。可以主动做 LRU:
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName)
const keys = await cache.keys()
if (keys.length > maxEntries) {
// 删掉最早的几条
await Promise.all(
keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key))
)
}
}
2. 用户长时间不打开页面
缓存的数据可能已经非常旧了。可以在缓存时写入时间戳,读取时判断是否超过了一个"最大容忍过期时间"。
async function putWithTimestamp(cache, request, response) {
const headers = new Headers(response.headers)
headers.set('X-SW-Cached-At', Date.now().toString())
const timestamped = new Response(response.body, {
status: response.status,
headers,
})
await cache.put(request, timestamped)
}
async function getCachedIfFresh(cache, request, maxAge = 86400000) {
const cached = await cache.match(request)
if (!cached) return null
const cachedAt = Number(cached.headers.get('X-SW-Cached-At') || 0)
if (Date.now() - cachedAt > maxAge) {
await cache.delete(request) // 过期太久,直接丢掉
return null
}
return cached
}
24 小时没打开过的页面,就别拿旧缓存糊弄用户了,老老实实等网络请求。
3. 多 tab 场景
self.clients.matchAll() 会拿到所有 tab。如果用户开了同一个页面的多个 tab,每个 tab 都会收到 SWR_UPDATE 消息。这其实是个好事——所有 tab 数据保持同步。但要注意消息处理的幂等性,别重复触发副作用。
聊到这
stale-while-revalidate 不是什么新概念,HTTP 规范里早就有了。
这套方案的本质是一种乐观 UI 策略:先假设数据没怎么变,给用户看旧的,后台验证。跟 React 的 useOptimistic 和 SWR 库(对,swr 这个 npm 包名就是从这来的)的思路一脉相承。
但也别滥用。不是每个接口都值得缓存,不是每个场景都能容忍 stale data。用户感知不到延迟的地方,别加这套复杂度。 Service Worker 本身就是个不太好调试的东西,再叠一层缓存策略,出了问题排查起来会比较痛苦。
值不值得上,看你的场景。首屏有 3 个以上慢接口、用户会频繁重复访问同一个页面、数据时效性要求不是特别高——满足这三条,可以考虑。