配了三年 SW,今天才发现它的缓存从来不是一道选择题
写过离线优先应用的人都踩过这个坑——用户在地铁里填完表单点了提交,结果网络断了,请求直接消失。问题不在应用层,在于 Service Worker 的缓存策略从来没有按资源类型分过。
2026 年了,Service Worker 缓存这件事终于有了一个清晰的决策框架:不是选一个策略用到底,而是按资源类型匹配策略。
三种策略,分别适合什么
Cache-first,也叫「缓存优先」,适合那些 URL 本身带内容哈希的资源。CSS 和 JS bundle 构建后会变成 app.a3f8e2.js,这个 URL 只在这个版本有效,下一次构建 URL 就变了。缓存这个 URL 永远不会「过期」,因为旧 URL 不会再有人请求。
Network-first,也叫「网络优先」,适合需要保持新鲜的数据。用户的个人信息、仪表盘数据、新闻流,这些内容过期了就失去了价值。先去网络拿,拿不到再降级到缓存。
Stale-While-Revalidate,也叫「SWR」,是中间地带。立刻把缓存返回给用户,同时在后台联网更新缓存。用户当前访问看到的是可能「有点旧」的内容,但下一次访问就全是新的。列表页、RSS 订阅、推荐内容,这些场景最合适。
三行路由,把策略分清楚
大多数 Service Worker 翻车的根本原因,不是策略选错了,而是把所有请求都塞进了同一个策略函数。
生产级别的路由是这样的:
// sw.js
const STATIC_CACHE = static-v2;
const DYNAMIC_CACHE = dynamic-v2;
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html',
];
// 安装时:把 App Shell 全部缓存
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// 激活时:清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(key => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
.map(key => caches.delete(key))
)
).then(() => self.clients.claim())
);
});
// 路由层:按类型分发
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET') return;
if (isStaticAsset(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
if (url.pathname.startsWith('/api/') || request.mode === 'navigate') {
event.respondWith(networkFirst(request));
return;
}
if (
url.pathname === '/' ||
url.pathname.endsWith('/index.xml') ||
url.pathname.endsWith('.json')
) {
event.respondWith(staleWhileRevalidate(request));
return;
}
event.respondWith(fetch(request));
});
function isStaticAsset(pathname) {
return /\.(js|css|png|jpg|jpeg|svg|gif|woff2?)$/.test(pathname);
}
三个策略函数各自独立,路由层只负责分发,互不污染。
function cacheFirst(request) {
return caches.match(request).then(cached =>
cached || fetch(request).then(response => {
const clone = response.clone();
caches.open(STATIC_CACHE).then(cache => cache.put(request, clone));
return response;
})
);
}
function networkFirst(request) {
return fetch(request)
.then(response => {
const clone = response.clone();
caches.open(DYNAMIC_CACHE).then(cache => cache.put(request, clone));
return response;
})
.catch(() =>
caches.match(request).then(cached =>
cached || caches.match('/offline.html')
)
);
}
function staleWhileRevalidate(request) {
return caches.open(DYNAMIC_CACHE).then(cache =>
cache.match(request).then(cached => {
const fetchPromise = fetch(request).then(response => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
})
);
}
缓存效果可测量
光配了策略还不够,怎么知道缓存真的命中了?用 Performance API 查 transferSize:
const observer = new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
const cached = entry.transferSize === 0;
console.log(`${entry.name}: ${cached ? 'cache hit' : 'network'} - ${entry.duration}ms`);
}
});
observer.observe({ type: 'resource', buffered: true });
transferSize === 0 就是命中了缓存,没有任何网络传输。
缓存不清场,硬盘会爆炸
Service Worker 缓存不会自动过期。如果你不清理,每次部署都会积累新的废弃缓存。activate 事件里清理旧缓存是关键一步:
// 激活时只保留当前版本,删除其余所有
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(key => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
.map(key => caches.delete(key))
)
)
);
不过这里有个陷阱:如果用的是 query string 做版本号,浏览器会认为 ?v=1 和 ?v=2 是不同 URL,都会保留下来。内容哈希文件名是更好的方案,因为旧文件改名后没有任何请求会再命中它,旧缓存自然「消失」。
进阶:离线优先的真实场景
缓存策略只是基础。真正让 Service Worker 发挥价值的是两个进阶 API:
Background Sync——用户在地铁里填完表单点了提交,网络断了。普通的 fetch 会直接失败,表单数据丢失。用 Background Sync,请求会被放入队列,等网络恢复后自动重发:
// 页面端注册同步
navigator.serviceWorker.ready.then(swRegistration => {
return swRegistration.sync.register('form-submit');
});
// Service Worker 监听同步事件
self.addEventListener('sync', event => {
if (event.tag === 'form-submit') {
event.waitUntil(submitFormData());
}
});
Background Fetch——用户点击下载一个播客,下载进度可见,即使切换到其他标签页也不会中断。下载完成后通知用户:
navigator.serviceWorker.ready.then(async swReg => {
const bgFetch = await swReg.backgroundFetch.fetch('podcast-download', [
'/ep-5.mp3',
], {
title: 'Episode 5 下载中',
icons: [{ sizes: '300x300', src: '/icon.png', type: 'image/png' }],
downloadTotal: 60 * 1024 * 1024,
});
bgFetch.addEventListener('progress', () => {
const percent = Math.round((bgFetch.downloaded / bgFetch.downloadTotal) * 100);
console.log(`下载进度: ${percent}%`);
});
});
结论
Service Worker 缓存不是一道选择题,而是一套路由系统:
- 静态资源(内容哈希)→ Cache-first,URL 即版本
- API 数据、导航请求 → Network-first,保证新鲜
- 列表页、Feed、JSON → Stale-While-Revalidate,速度与新鲜度兼顾
- 表单提交、离线场景 → Background Sync
- 大文件下载 → Background Fetch
配了三年 SW,最难的不是写策略函数,是想清楚「这个资源当前访问要快还是下次访问要新」这件事。路由分清楚,离线体验自然就好了。
配完 SW 之后记得测:开着飞行模式填个表单提交一下,看看是不是真的离线可用。
评论区
登录后可评论。