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 打开看一眼,知道它干了什么。不然出了问题连排查方向都没有。

相关推荐
程序员小李白11 分钟前
vue题目
前端·javascript·vue.js
方安乐21 分钟前
ESLint代码规范(二)
前端·javascript·代码规范
zzginfo27 分钟前
var、let、const、无申明 四种变量在赋值前,使用的情况
开发语言·前端·javascript
贺小涛30 分钟前
Vue介绍
前端·javascript·vue.js
cch89181 小时前
React Hooks的支持
前端·javascript·react.js
ofoxcoding1 小时前
React 性能优化实战:我把一个卡成 PPT 的页面优化到丝滑的全过程
javascript·react.js·ai·性能优化
蜡台1 小时前
vue.config.js 配置
前端·javascript·vue.js·webpack
吴声子夜歌1 小时前
TypeScript——webpack
javascript·webpack·typescript
han_2 小时前
JavaScript设计模式(六):职责链模式实现与应用
前端·javascript·设计模式
进击的尘埃2 小时前
Navigation API 如何重塑前端路由
javascript