Service Worker + stale-while-revalidate:让页面"假装"秒开的正经方案

20 阅读1分钟

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-cacheno-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 个以上慢接口、用户会频繁重复访问同一个页面、数据时效性要求不是特别高——满足这三条,可以考虑。