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