背景
在部分业务场景下,为了安全性、参数复杂度等原因,团队会选择将所有 API 请求统一为 POST 方法。但浏览器和 Service Worker 的标准缓存机制只对 GET 请求生效,POST 请求无法直接缓存。本文介绍一种基于 Service Worker + IndexedDB 的 POST API 缓存方案,适用于幂等、数据变化不频繁的接口,用于提升接口响应速度和离线体验。
一、缓存策略设计与实现
1.1 缓存策略的分析
对于API的缓存可以参考文件的缓存策略:
-
Cache First(缓存优先)策略:
- 优先返回本地缓存,如果没有缓存再请求网络,并将网络响应缓存起来
- Cache First策略在API接口中的适用场景极少,除非你能100%确定该接口数据极少变动且对时效性要求不高。大部分业务API并不适合Cache First
-
Stale-While-Revalidate(SWR)策略:
-
命中缓存时:立即返回缓存数据,保证响应速度,同时:后台异步发起网络请求,获取最新数据并更新缓存
-
SWR适合对时效性有一定要求,但又希望响应速度快的场景,常见于:
- 幂等的、变化频率不高的的API
- 用户体验优先、允许短暂数据延迟的场景
-
-
Network First(网络优先)策略
- 优先请求网络,只有在网络不可用时才返回缓存
- 对数据实时性要求较高、需要保证获取最新数据,同时希望在离线或网络异常时仍能回退到缓存的API接口场景
通过以上分析,我们只需要把API划分为两类策略:Network First 与 SWR,其中Network First策略的主要目的是离线访问,SWR策略的主要目的是加快接口响应速度
2 策略配置与分流实现
设计思路:
- 通过白名单配置每个API的缓存策略和时效。
- Service Worker拦截POST请求,根据配置分流到不同的缓存处理逻辑。
配置示例:
const POST_API_CACHE_STRATEGY = {
'/api/xxx/yyy': { strategy: 'NetworkFirst' },
'/api/xxx/zzz': { strategy: 'StaleWhileRevalidate', maxAgeSeconds: 604800 },
};
分流流程图:
graph TD
A[拦截POST请求] --> B{是否在白名单}
B -- 否 --> C[直接请求网络]
B -- 是 --> D[查找策略]
D --> E{NetworkFirst or SWR}
E -- NetworkFirst --> F1[走NetworkFirst缓存逻辑]
E -- SWR --> F2[走SWR缓存逻辑]
代码实现要点:
- fetch事件中,根据API配置选择不同的缓存处理分支。
- NetworkFirst分支:先请求网络,失败时查缓存。
- SWR分支:先查缓存立即返回,后台异步请求网络并更新缓存。 关键代码(已脱敏,精简版):
self.addEventListener('fetch', (event) => {
const req = event.request;
const url = new URL(req.url);
if (req.method === 'POST' && POST_API_LIST.includes(url.pathname)) {
const config = POST_API_CACHE_STRATEGY[url.pathname];
const strategy = config.strategy || 'NetworkFirst';
event.respondWith(
(async () => {
const cacheKey = await generateCacheKey(req);
if (strategy === 'StaleWhileRevalidate') {
const cached = await getCachedResponse(cacheKey, strategy);
fetch(req.clone()).then(res => {
if (res.ok) saveResponse(cacheKey, res.clone(), strategy);
});
return cached || fetch(req);
} else {
try {
const res = await fetch(req.clone());
if (res.ok) saveResponse(cacheKey, res.clone(), strategy);
return res;
} catch {
return await getCachedResponse(cacheKey, strategy) || new Response('Offline', { status: 503 });
}
}
})()
);
}
});
二、缓存空间的分离与管理
2.1 为什么要分开存储空间?
- 不同策略的缓存数据生命周期和清理方式不同,如 SWR 允许短暂过期,NetworkFirst 只在离线兜底。
- 便于分别设置最大缓存条数、过期策略,防止互相影响。
- 管理和调试更清晰,便于后续扩展。
2.2 如何分开?
设计:
- IndexedDB 中为每种策略建立独立的 objectStore(如 NetworkFirstResponses、StaleWhileRevalidateResponses)。
- fetch 事件处理时,根据策略类型选择对应的存储空间。 关键代码讲解:
const DB_NAME = 'CampPostCacheDB';
const STORE_NETWORK_FIRST = 'NetworkFirstResponses';
const STORE_SWR = 'StaleWhileRevalidateResponses';
// 打开数据库时分别创建两个objectStore
const openDB = () => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 3);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NETWORK_FIRST)) {
db.createObjectStore(STORE_NETWORK_FIRST, { keyPath: 'key' });
}
if (!db.objectStoreNames.contains(STORE_SWR)) {
db.createObjectStore(STORE_SWR, { keyPath: 'key' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = (e) => reject(e.target.error);
});
};
三、唯一请求标识(Cache Key)设计
3.1 为什么需要唯一Key?
- POST请求的响应不仅与URL有关,还与请求体、请求头密切相关。
- 只有将这些内容都纳入Key,才能避免缓存串用。
3.2 设计思路与关键代码
- 取URL路径、请求体、请求头,拼接后做哈希,生成唯一Key。
- 这样即使同一个API,不同参数、不同用户的请求也不会串用缓存。
关键代码
const generateCacheKey = async (request) => {
const url = new URL(request.url);
const body = await request.clone().text();
// 收集并排序请求头
const headersObj = {};
for (const [key, value] of request.headers.entries()) {
headersObj[key] = value;
}
const sortedHeaderKeys = Object.keys(headersObj).sort();
const headersStr = sortedHeaderKeys.map((k) => `${k}:${headersObj[k]}`).join('|');
const keySource = body + '|' + headersStr;
// 生成hash
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(keySource));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return `POST|${url.pathname}|${hashHex}`;
};
四、缓存时效与清理机制
4.1 时效配置
- 每个接口可单独配置缓存时效(maxAgeSeconds),未配置时用默认值(如7天)。
4.2 时效实现
- 保存缓存时,记录过期时间(expires)。
- 读取缓存时,判断是否过期,过期则删除。
关键代码
// 保存缓存时
const saveResponse = async (key, response, strategyType) => {
const db = await openDB();
const storeName = strategyType === 'NetworkFirst' ? STORE_NETWORK_FIRST : STORE_SWR;
const apiConfig = POST_API_CACHE_STRATEGY[new URL(response.url).pathname];
const maxAgeSeconds = apiConfig?.maxAgeSeconds || 7 * 24 * 60 * 60;
const expires = Date.now() + maxAgeSeconds * 1000;
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
store.put({
key,
data: {
body: await response.clone().text(),
status: response.status,
headers: Array.from(response.headers.entries()),
},
expires,
});
};
// 读取缓存时
const getCachedResponse = async (key, strategyType) => {
const db = await openDB();
const storeName = strategyType === 'NetworkFirst' ? STORE_NETWORK_FIRST : STORE_SWR;
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.get(key);
return new Promise((resolve) => {
request.onsuccess = () => {
const result = request.result;
if (!result) return resolve(null);
if (Date.now() > result.expires) {
store.delete(key);
return resolve(null);
}
resolve(new Response(result.data.body, {
status: result.data.status,
headers: new Headers(result.data.headers),
}));
};
});
};
总结
- 通过 Service Worker + IndexedDB,可以为幂等的 POST 接口实现灵活的缓存策略。
- 设计唯一请求标识,避免缓存串用。
- 按策略分开存储空间,便于管理和清理。
- 支持缓存时效配置,保证数据新鲜度和安全性。