Service Worker + stale-while-revalidate:让页面"假装"秒开的那些事
上周有个同事跑过来问我:"咱这个 H5 页面,二次打开还要白屏一两秒,能不能搞成秒开?"
我看了一眼------接口不慢,资源也上了 CDN,但每次打开还是要等。问题出在哪?HTTP 缓存策略太保守了。所有 API 请求都走的 no-cache,每次都要等服务端返回才渲染。
改成强缓存?那数据不实时。不缓存?那就是现在这个慢样子。
这种"既要又要"的场景,stale-while-revalidate 是个非常合适的解法。而要把它玩到极致,得靠 Service Worker。
stale-while-revalidate 到底在干嘛
这个名字又长又绕,但逻辑很简单:先把过期的缓存给你用着,背后悄悄去请求新的。
用大白话说就是:你去食堂打饭,阿姨先把上一锅剩的给你盛上,同时后厨在炒新的。你不用等,先吃着。下一个人来的时候,新的就好了。
HTTP 层面,这个策略通过 Cache-Control 头来声明:
arduino
Cache-Control: max-age=0, stale-while-revalidate=86400
ts
// 浏览器拿到这个响应后的行为:
// 1. max-age=0 → 响应立刻"过期"
// 2. stale-while-revalidate=86400 → 但 24 小时内,过期的缓存仍然可以先用
// 3. 用过期缓存的同时,后台发起 revalidate 请求
// 4. 新响应回来后,更新缓存,下次请求就是新数据了
浏览器原生支持这个头,Chrome 75+ 就有了。但有个问题------浏览器的实现你控制不了细节。什么时候用缓存、什么时候 revalidate、revalidate 失败了怎么办,这些行为都是黑盒。
这就是 Service Worker 上场的理由。
为什么非得用 Service Worker
直接用 HTTP 头不行吗?大部分场景确实够用。但碰到下面这几种情况就不够了:
1. 需要对不同接口用不同策略
比如用户信息接口可以容忍 5 分钟的旧数据,但支付状态接口必须实时。纯靠服务端 Cache-Control 头来区分,意味着后端要改每个接口的响应头。实际操作中,大部分后端同事的反应是:"加需求走排期。"
2. 需要在 revalidate 完成后通知页面更新
原生 stale-while-revalidate 拿到新数据后,缓存是更新了,但当前页面不知道。用户看到的还是旧数据,得刷新才能看到新的。Service Worker 里可以主动通知页面。
3. 需要 fallback 逻辑
网络挂了,revalidate 失败了,怎么办?原生行为是沉默的。Service Worker 里可以自己兜底------继续用旧缓存、展示离线提示、或者走降级接口。
核心实现
先注册 Service Worker,这步没什么花样:
js
// main.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered, scope:', reg.scope))
.catch(err => console.error('SW registration failed:', err))
}
重头戏在 sw.js 里。stale-while-revalidate 的完整实现:
js
// sw.js
const CACHE_NAME = 'api-cache-v1'
// 哪些请求需要走 stale-while-revalidate
const SWR_PATTERNS = [
/\/api\/user\/profile/,
/\/api\/product\/list/,
/\/api\/config\//,
]
// 哪些请求必须走网络(绝不用缓存)
const NETWORK_ONLY = [
/\/api\/payment\//,
/\/api\/auth\//,
]
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
// 只处理 GET 请求的 API 调用
if (request.method !== 'GET') return
if (!url.pathname.startsWith('/api/')) return
// 支付、鉴权这类接口,老老实实走网络
if (NETWORK_ONLY.some(p => p.test(url.pathname))) return
// 命中 SWR 模式的接口
if (SWR_PATTERNS.some(p => p.test(url.pathname))) {
event.respondWith(staleWhileRevalidate(request))
}
})
核心函数 staleWhileRevalidate:
js
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME)
const cached = await cache.match(request)
// 不管有没有缓存,都发起网络请求
const fetchPromise = fetch(request)
.then(async (response) => {
if (response.ok) {
// 拿到新数据,更新缓存
await cache.put(request, response.clone())
// 通知页面:这个接口有新数据了
notifyClients(request.url)
}
return response
})
.catch((err) => {
console.warn('Revalidate failed:', request.url, err)
// 网络挂了,不处理,反正缓存已经返回了
return null
})
if (cached) {
// ✅ 有缓存 → 立刻返回旧数据,网络请求在后台跑
return cached
}
// ❌ 没缓存(首次请求)→ 只能等网络
const response = await fetchPromise
if (response) return response
// 网络也挂了,缓存也没有,没辙了
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
})
}
通知页面更新的部分:
js
// sw.js
async function notifyClients(url) {
const clients = await self.clients.matchAll()
clients.forEach(client => {
client.postMessage({
type: 'CACHE_UPDATED',
url,
})
})
}
页面侧监听消息,拿到新数据后静默刷新对应模块:
js
// main.js
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'CACHE_UPDATED') {
const url = event.data.url
// 根据更新的接口,刷新对应的 UI 模块
if (/\/api\/user\/profile/.test(url)) {
refreshUserProfile() // 重新请求并渲染用户信息
}
if (/\/api\/product\/list/.test(url)) {
refreshProductList()
}
}
})
整个流程串起来就是:
- 用户打开页面,请求
/api/product/list - Service Worker 拦截,发现有缓存 → 立刻返回缓存(页面瞬间有内容)
- 同时后台发起真实请求
- 新数据回来 → 更新缓存 →
postMessage通知页面 - 页面收到通知 → 静默刷新商品列表(用户基本无感)
缓存过期怎么控制
上面的实现有个问题:缓存永远不过期。哪怕数据是一周前的,也照样返回。
得加个过期机制。但 Cache API 本身不支持 TTL,所以要自己搞:
js
// 存缓存时,把时间戳也塞进去
async function putWithTimestamp(cache, request, response) {
const headers = new Headers(response.headers)
headers.set('X-SW-Cached-At', Date.now().toString())
const timestampedResponse = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
await cache.put(request, timestampedResponse)
}
// 判断缓存是否还在 SWR 窗口期内
function isWithinSWRWindow(response, maxAgeMs = 24 * 60 * 60 * 1000) {
const cachedAt = response.headers.get('X-SW-Cached-At')
if (!cachedAt) return false
return Date.now() - parseInt(cachedAt) < maxAgeMs
}
然后改造主逻辑:
js
async function staleWhileRevalidate(request, options = {}) {
const { maxAge = 24 * 60 * 60 * 1000 } = options
const cache = await caches.open(CACHE_NAME)
const cached = await cache.match(request)
const fetchPromise = fetch(request)
.then(async (response) => {
if (response.ok) {
await putWithTimestamp(cache, request, response.clone())
notifyClients(request.url)
}
return response
})
.catch(() => null)
if (cached && isWithinSWRWindow(cached, maxAge)) {
return cached // 窗口期内,放心用
}
if (cached) {
// 超出窗口期了,缓存太老,等网络
const response = await fetchPromise
return response || cached // 网络也挂了就用旧的凑合
}
const response = await fetchPromise
return response || new Response('{"error":"offline"}', {
status: 503,
headers: { 'Content-Type': 'application/json' },
})
}
不同接口可以设不同的 maxAge。用户信息给 5 分钟,配置接口给 24 小时,商品列表给 30 分钟。这个灵活度是纯 HTTP 头做不到的。
和 HTTP 缓存怎么配合
这里有个容易搞混的点:Service Worker 缓存和浏览器 HTTP 缓存是两层。请求的链路是这样的:
页面发请求 → Service Worker → HTTP 缓存(disk cache / memory cache)→ 网络
如果服务端返回了 Cache-Control: max-age=300,那 Service Worker 里 fetch(request) 拿到的可能是浏览器 HTTP 缓存的内容,根本没到服务端。
这就尴尬了------你以为在 revalidate,其实 HTTP 缓存拦了一道,拿到的还是旧的。
解决办法:revalidate 的 fetch 要绕过 HTTP 缓存。
js
const fetchPromise = fetch(request, {
// 告诉浏览器:这次别用 HTTP 缓存,直接问服务端
cache: 'no-cache',
})
或者更精细一点,用条件请求:
js
async function revalidateFetch(request, cachedResponse) {
const headers = new Headers(request.headers)
// 如果缓存里有 ETag,带上 If-None-Match
const etag = cachedResponse?.headers.get('ETag')
if (etag) headers.set('If-None-Match', etag)
// 如果有 Last-Modified,带上 If-Modified-Since
const lastModified = cachedResponse?.headers.get('Last-Modified')
if (lastModified) headers.set('If-Modified-Since', lastModified)
const conditionalRequest = new Request(request, { headers })
const response = await fetch(conditionalRequest, { cache: 'no-cache' })
// 304 → 数据没变,不用更新缓存
if (response.status === 304) return null
// 200 → 有新数据
if (response.ok) return response
return null
}
这样 Service Worker 的 stale-while-revalidate 和 HTTP 的协商缓存(ETag / Last-Modified)就联动起来了。304 的时候不用传输 body,省带宽;有新数据再整个更新。
之前做项目没注意这个,revalidate 请求每次都传完整 body 回来。后来加上条件请求,接口流量直接少了 60% 多。当时还挺意外,没想到大部分请求其实数据根本没变。
几个实际踩过的坑
Service Worker 更新的时机问题
sw.js 本身也有缓存。浏览器默认会缓存 Service Worker 文件 24 小时(虽然新版浏览器已经改了,但有些旧版还是这样)。如果改了 SW 逻辑但用户没拿到新版本,缓存策略还是旧的。
我的做法是在 sw.js 的 activate 事件里清掉旧版本缓存:
js
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(key => key !== CACHE_NAME) // 只保留当前版本
.map(key => caches.delete(key))
)
)
)
self.clients.claim() // 立刻接管所有页面
})
POST 请求别拦
有一次手抖把 request.method !== 'GET' 这个判断漏了,导致提交订单的 POST 请求也被缓存了。用户点了两次提交,第二次直接返回了第一次的缓存结果,后端根本没收到请求。排查了半天才发现是 SW 的锅。
缓存存储容量
Cache API 的存储空间不是无限的。Chrome 大概给每个域名分配可用空间的 80%(通常几百 MB 到几 GB),但在存储压力大的时候浏览器会清理。别把关键数据只存在 SW 缓存里,它不可靠。
什么时候不该用
stale-while-revalidate 不是万能的。这几种场景别用:
数据一致性要求高的。比如账户余额、库存数量、支付状态。给用户显示旧余额,哪怕只有几秒,都可能引发客诉。
写操作之后的读。用户刚改了昵称,马上回到个人主页,结果显示的还是旧昵称(因为命中了缓存)。这种场景要么在写操作后主动清缓存,要么在写操作后短暂绕过 SW。
js
// 写操作后通知 SW 清除相关缓存
async function invalidateCache(pattern) {
const reg = await navigator.serviceWorker.ready
reg.active.postMessage({
type: 'INVALIDATE',
pattern,
})
}
// 改了用户信息后
await updateProfile(newData)
await invalidateCache('/api/user/profile') // 清掉旧缓存
await refreshUserProfile() // 这次请求会走网络
SEO 相关的页面。搜索引擎爬虫不一定跑 Service Worker,SSR 场景下这套东西基本没用。
实际效果
在之前那个 H5 项目上线后,二次打开的白屏时间从 1.5s 左右降到了 200ms 以内。主要是因为列表数据和用户信息这两个最慢的接口都走了 SW 缓存,页面拿到数据后直接渲染,不用等网络。
用户基本无感知的是:渲染完 200ms 后,后台 revalidate 完成,如果数据有变化,列表会静默更新一下。大部分情况下数据没变(304),所以页面连闪都不闪。
回过头看,stale-while-revalidate 这个模式的核心思路就一句话:别让用户等那些大概率没变的数据。 先给旧的看着,新的到了再换。关键是要想清楚哪些数据适合这么干,哪些不适合。拿捏不好这个度,要么白屏慢,要么数据不一致------两头都是坑。