Service Worker 离线缓存这事,没你想的那么简单

Service Worker 离线缓存这事,没你想的那么简单

上个月接了个需求:把公司的 B 端管理系统做成"弱网可用"。产品说得轻巧------"加个离线缓存就行了嘛"。

我当时心想,行,上 Workbox,配几个路由策略,半天搞定。

结果呢?搞了整整一周。

问题不在"能不能缓存",而在"缓存了之后怎么更新"。用户打开页面用的是旧版本、新版本发上去了但 SW 还抱着老文件不放、偶尔还会出现半新半旧的"弗兰肯斯坦"状态------页面一半是新的一半是旧的,直接白屏。

这篇聊聊我最后是怎么用 Workbox 把这套离线缓存做到"能用、能更新、不炸"的。

先搞清楚 SW 的更新机制,不然后面全是坑

很多人对 Service Worker 的生命周期理解停留在 install → activate → fetch,觉得新文件上去了浏览器自动就换了。

没那么简单。

ts 复制代码
// SW 更新的真实流程:
// 1. 浏览器发现 sw.js 文件内容变了(逐字节比对)
// 2. 下载新 SW,触发 install 事件
// 3. 新 SW 进入 waiting 状态 ------ 注意,不是直接激活
// 4. 等所有标签页都关了,新 SW 才 activate
// 5. 下次打开页面,才用新的缓存

// 问题来了:用户不关标签页怎么办?
// 答:新 SW 就一直 waiting,用户一直用旧缓存

这就是经典的"我明明发了新版本,用户看到的还是旧的"。

很多文章教你在 install 里加 skipWaiting(),activate 里加 clients.claim(),一步到位。

ts 复制代码
self.addEventListener('install', () => {
  self.skipWaiting() // 跳过 waiting,直接激活
})

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim()) // 立刻接管所有页面
})

能用,但粗暴。想象一下:用户正在填一个复杂表单,填了半天,SW 突然切了,页面资源全换成新版本,某个接口的响应格式变了------表单直接废了。B 端系统这么搞,会被投诉的。

用 Workbox 搭一套分层缓存策略

Workbox 提供了五种缓存策略,但不是选一种就完事了。不同资源该用不同策略,这事得想清楚。

我最后的分层方案长这样:

ts 复制代码
import { precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import {
  CacheFirst,
  StaleWhileRevalidate,
  NetworkFirst,
} from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'

// 第一层:构建产物 → precache(预缓存)
// hash 文件名的 JS/CSS,内容变了 hash 就变,天然版本控制
precacheAndRoute(self.__WB_MANIFEST)

// 第二层:图片/字体等静态资源 → CacheFirst
// 这些东西基本不变,命中缓存直接用,省带宽
registerRoute(
  ({ request }) =>
    request.destination === 'image' ||
    request.destination === 'font',
  new CacheFirst({
    cacheName: 'static-assets-v1',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 100,       // 最多缓存 100 个
        maxAgeSeconds: 30 * 24 * 3600, // 30 天过期
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200],    // 0 是 opaque response,跨域资源
      }),
    ],
  })
)

// 第三层:API 请求 → NetworkFirst
// 优先拿新数据,网络挂了才用缓存兜底
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 3, // 3 秒没响应就用缓存
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // API 缓存只留 5 分钟
      }),
    ],
  })
)

// 第四层:HTML 页面 → StaleWhileRevalidate
// 先给旧的用着,后台偷偷更新
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new StaleWhileRevalidate({
    cacheName: 'pages-cache',
  })
)

三个关键决策说一下:

API 用 NetworkFirst 而不是 StaleWhileRevalidate。 B 端系统数据一致性很重要,审批状态、订单数据这些,给用户看过期的可能出事。宁可慢一点,也要优先拿最新的。

HTML 用 StaleWhileRevalidate。 这里比较纠结,我一开始用的 NetworkFirst,但弱网下页面加载体验太差。后来改成先给旧页面、后台更新,配合后面说的版本控制机制,体验好了不少。

静态资源设了 maxEntries 上限。 之前没设,缓存越积越多,有个用户的 Cache Storage 膨胀到 800MB,手机直接卡死。

版本控制:怎么让更新不翻车

分层缓存解决了"缓存什么"的问题,但核心难题还没解决:怎么让新版本平滑上去,不出现半新半旧的状态?

Workbox 的 precache 机制本身带版本控制。构建时会生成一个 manifest:

ts 复制代码
// 构建产物大概长这样:
self.__WB_MANIFEST = [
  { url: '/js/app.3a7b2c.js', revision: null },  // 文件名带 hash,revision 不需要
  { url: '/js/vendor.9f8e1d.js', revision: null },
  { url: '/index.html', revision: 'v28' },         // 没 hash 的文件需要 revision
  { url: '/manifest.json', revision: 'v3' },
]

文件名带 hash 的,内容一变 hash 就变,precache 自动处理增量更新------只下载变了的文件,没变的直接跳过。这部分 Workbox 做得挺好,不用操心。

麻烦的是 index.html 这类没有 hash 的文件。revision 字段本质上是内容的 hash,靠构建工具生成。但问题在于,index.html 是入口,它引用了哪些 JS/CSS 文件决定了用户加载哪个版本。

如果 SW 更新了 JS 但还在用旧的 index.html,旧 HTML 里引用的是旧 JS hash,新 JS 缓存了但压根不会被加载------经典的版本不一致。

我的处理方式是,在主线程加一层更新检测:

ts 复制代码
// main.ts ------ 应用入口
if ('serviceWorker' in navigator) {
  const registration = await navigator.serviceWorker.register('/sw.js')

  // 检测到新 SW 在 waiting
  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing
    if (!newWorker) return

    newWorker.addEventListener('statechange', () => {
      if (
        newWorker.state === 'installed' &&
        navigator.serviceWorker.controller // 说明不是首次安装
      ) {
        // 新版本就绪,通知用户
        showUpdateNotification({
          onConfirm: () => {
            newWorker.postMessage({ type: 'SKIP_WAITING' })
          },
        })
      }
    })
  })

  // SW 控制权切换后刷新页面
  let refreshing = false
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (refreshing) return
    refreshing = true
    window.location.reload() // 刷新拿新资源
  })
}

SW 那边对应地处理消息:

ts 复制代码
// sw.js
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting() // 用户确认后才跳过 waiting
  }
})

核心思路:不自动 skipWaiting,让用户决定什么时候更新。

弹个不起眼的提示条------"有新版本可用,点击刷新",用户手头事忙完了自己点,不打断操作流。这比强制刷新友好太多了。

增量更新:别让用户每次都全量下载

Precache 的增量更新是文件级别的:100 个文件只改了 3 个,就只下载那 3 个。但有个前提------你的构建配置得配合

踩过一个坑:项目用 Vite 打包,每次构建所有 chunk 的 hash 都变了。明明只改了一行代码,用户得重新下载全部 JS。

原因是 Vite 默认的 manualChunks 配置没做好,所有代码打成几个大 chunk,任何改动都会导致 chunk 内容变化。

ts 复制代码
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // 细粒度拆包,让改动的影响范围最小化
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 把大的第三方库单独拆出来
            if (id.includes('echarts')) return 'vendor-echarts'
            if (id.includes('lodash')) return 'vendor-lodash'
            if (id.includes('antd') || id.includes('ant-design'))
              return 'vendor-antd'
            return 'vendor' // 其余第三方统一放
          }
          // 业务代码按路由拆
        },
      },
    },
  },
})

拆完之后效果明显:改一个页面组件,只有对应的路由 chunk 的 hash 变了,其他 chunk 不受影响。增量更新从"几乎全量"变成了"真的增量"。

还有一个容易忽略的点:运行时缓存(Runtime Cache)没有增量更新的概念。CacheFirst 策略下,一个 2MB 的图片只要 URL 没变就永远用缓存。这大部分时候是对的,但如果你的图片 URL 不带版本号,改了图片但 URL 一样,用户永远看到旧图。

解法也简单,要么 URL 带 hash/版本号,要么把这类资源的策略从 CacheFirst 改成 StaleWhileRevalidate。

缓存清理:没人提但迟早会炸的事

缓存只进不出,Storage 迟早满。浏览器对 Cache Storage 有配额限制(Chrome 大概是磁盘空间的 60%,但不保证),超了会整个 origin 的数据被清------包括 IndexedDB、localStorage,全没。

ExpirationPlugin 能解决一部分问题,但老版本的 precache 缓存不会自动清理。

Workbox 的 precache 在 activate 阶段会清理旧版本的缓存条目,这部分是自动的。但如果你手动管理了一些缓存,或者 cacheName 改了(比如从 static-assets-v1 升到 v2),旧的 cache 不会自己消失。

ts 复制代码
// sw.js activate 阶段,手动清理废弃的 cache
self.addEventListener('activate', (event) => {
  const currentCaches = [
    'static-assets-v2',  // 当前版本
    'api-cache',
    'pages-cache',
  ]

  event.waitUntil(
    caches.keys().then((cacheNames) =>
      Promise.all(
        cacheNames
          .filter((name) => !currentCaches.includes(name))
          .filter((name) => !name.startsWith('workbox-precache')) // precache 的让 Workbox 自己管
          .map((name) => {
            console.log('[SW] 删除旧缓存:', name)
            return caches.delete(name)
          })
      )
    )
  )
})

另外一个实用的做法是加个 Storage 用量监控,快满的时候主动清理低优先级缓存:

ts 复制代码
async function checkStorageQuota() {
  if (!navigator.storage?.estimate) return

  const { usage, quota } = await navigator.storage.estimate()
  const usageRatio = (usage || 0) / (quota || 1)

  if (usageRatio > 0.8) {
    // 用了 80% 以上,清掉过期的运行时缓存
    const cache = await caches.open('static-assets-v2')
    const keys = await cache.keys()
    // 按时间删掉最老的一半
    const toDelete = keys.slice(0, Math.floor(keys.length / 2))
    await Promise.all(toDelete.map((key) => cache.delete(key)))
  }
}

灰度更新:线上不敢一把梭的时候

这是后来加的需求。有一次发版改了个核心组件,结果新版本有 bug,但 SW 已经把新资源 precache 了,用户刷新就加载新版本------回都回不来。

后来加了个简单的灰度机制。SW 安装前先问服务端:"我该不该用新版本?"

ts 复制代码
// sw.js install 阶段
self.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const resp = await fetch('/api/sw-config').catch(() => null)

      if (resp?.ok) {
        const config = await resp.json()
        // { version: "2.3.1", rolloutPercent: 30, forceUpdate: false }

        if (!shouldActivate(config)) {
          // 不在灰度范围内,不装新版本
          // 注意:这里不调 skipWaiting,新 SW 会被丢弃
          return
        }
      }

      // 正常执行 precache
      // workbox 的 precache 逻辑在这之后
    })()
  )
})

function shouldActivate(config) {
  // 用 clientId 或者随机数做灰度分桶
  const bucket = Math.random() * 100
  return bucket < config.rolloutPercent
}

说实话这个方案有点糙。Math.random() 每次 install 都重新算,同一个用户可能一会在灰度内一会不在。更好的做法是用 IndexedDB 存一个固定的 clientId 做分桶。但对于我们当时的场景(内部 B 端系统,用户量不大),够用了。

有个问题我到现在也没完全想明白

SW 的 install 事件里如果 precache 失败了(比如某个文件 404),整个 SW 安装就失败了。这意味着一个文件挂了,所有缓存更新都不生效

Workbox 没有提供"部分成功"的能力。要么全装,要么不装。

这在 CDN 发布的时候偶尔会出问题------新文件还没全部同步到 CDN 节点,SW 就开始装了,某个文件 404,安装失败,用户卡在旧版本。下次再访问的时候可能 CDN 同步好了,又能装成功了。但这个时间窗口里的用户体验是不可控的。

我的临时方案是 precache 的文件列表尽量精简,只放入口必须的文件,其他的用运行时缓存按需加载。减少 precache 失败的概率。但根本问题还是没解决。如果有人有更好的方案,真的想听听。

聊到这

SW 离线缓存这套东西,原理不复杂,但工程化做起来全是细节。分层策略、版本控制、增量更新、缓存清理、灰度发布------每一块都不难,串起来就有得折腾了。

我的经验是:先把更新机制想清楚,再去配缓存策略。 大部分线上事故不是"缓存没命中",而是"缓存了但更新不了"。

还有一点,workbox-webpack-pluginvite-plugin-pwa 能帮你省掉很多手动配置的活,但别完全当黑盒用。至少把生成的 sw.js 打开看一眼,知道它干了什么。不然出了问题连排查方向都没有。

相关推荐
进击的尘埃2 小时前
HTTP/3 的多路复用和 QUIC 到底能让页面快多少?聊聊连接迁移和 0-RTT
javascript
Maxkim3 小时前
前端工程化落地指南:pnpm workspace + Monorepo 核心用法与实践
前端·javascript·架构
小兵张健15 小时前
开源 playwright-pool 会话池来了
前端·javascript·github
codingWhat18 小时前
介绍一个手势识别库——AlloyFinger
前端·javascript·vue.js
Lee川18 小时前
深度拆解:基于面向对象思维的“就地编辑”组件全模块解析
javascript·架构
进击的尘埃19 小时前
Web Worker 与 OffscreenCanvas:把主线程从重活里解放出来
javascript
codingWhat19 小时前
手撸一个「能打」的 React Table 组件
前端·javascript·react.js
进击的尘埃19 小时前
用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具
javascript
yuki_uix19 小时前
Object.entries:优雅处理 Object 的瑞士军刀
前端·javascript