Service Worker + stale-while-revalidate:让页面"假装"秒开的那些事

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()
    }
  }
})

整个流程串起来就是:

  1. 用户打开页面,请求 /api/product/list
  2. Service Worker 拦截,发现有缓存 → 立刻返回缓存(页面瞬间有内容)
  3. 同时后台发起真实请求
  4. 新数据回来 → 更新缓存 → postMessage 通知页面
  5. 页面收到通知 → 静默刷新商品列表(用户基本无感)

缓存过期怎么控制

上面的实现有个问题:缓存永远不过期。哪怕数据是一周前的,也照样返回。

得加个过期机制。但 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.jsactivate 事件里清掉旧版本缓存:

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 这个模式的核心思路就一句话:别让用户等那些大概率没变的数据。 先给旧的看着,新的到了再换。关键是要想清楚哪些数据适合这么干,哪些不适合。拿捏不好这个度,要么白屏慢,要么数据不一致------两头都是坑。

相关推荐
秋水无痕4 小时前
从零搭建个人博客系统:Spring Boot 多模块实践详解
前端·javascript·后端
进击的尘埃4 小时前
基于 Claude Streaming API 的多轮对话组件设计:状态机与流式渲染那些事
javascript
juejin_cn4 小时前
[转][译] 从零开始构建 OpenClaw — 第六部分(持久化记忆)
javascript
juejin_cn5 小时前
[转][译] 从零开始构建 OpenClaw — 第七部分(子智能体系统)
javascript
an317426 小时前
解决 VSCode 中 ESLint 格式化不生效问题:新手也能看懂的配置指南
前端·javascript·vue.js
Lee川8 小时前
🚀《JavaScript 灵魂深处:从 V8 引擎的“双轨并行”看执行上下文的演进之路》
javascript·面试
比特鹰8 小时前
手把手带你用Flutter手搓人生K线
前端·javascript·flutter
大雨还洅下8 小时前
前端JS: 数组扁平化
javascript
奔跑路上的Me8 小时前
前端导出 Word/Excel/PDF 文件
前端·javascript