前端 Service Worker最佳实践(下):基于IndexedDB的POST请求缓存方案

12 阅读5分钟

背景

在部分业务场景下,为了安全性、参数复杂度等原因,团队会选择将所有 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 接口实现灵活的缓存策略。
  • 设计唯一请求标识,避免缓存串用。
  • 按策略分开存储空间,便于管理和清理。
  • 支持缓存时效配置,保证数据新鲜度和安全性。