Skip to content

前端接口请求缓存机制实现

核心需求: 为指定的 API 请求增加一个持续数分钟的缓存。目的是为了优化那些后端无法再提速的慢接口,通过在前端缓存数据,减少重复请求,提升用户体验。


1. 核心挑战与思考

在实现该功能时,需要考虑以下三个关键问题:

  1. 无感封装 (Seamless Encapsulation):

    • 缓存机制应该对开发者透明。开发者只需像平常一样调用请求方法,无需关心该请求是否应该使用缓存。缓存逻辑应被完全封装。
  2. 自动过期 (Automatic Expiration):

    • 缓存需要在指定时间(如3-5分钟)后自动失效。
    • 如何高效实现?如果为每个缓存都设置一个独立的 setTimeout 定时器,当请求数量多时会造成严重的性能问题。
  3. 储存位置 (Storage Location):

    • 缓存数据应该存储在哪里?localStorage 还是内存中的一个 JavaScript 对象?
    • 不同存储方式的优劣势是什么?

2. 解决方案思路

2.1. 整体流程

  1. 封装一个统一的请求方法(如 request.get, request.post)。
  2. 当调用该方法时,首先检查是否存在针对该请求的本地缓存。
  3. 如果存在缓存:
    • 检查该缓存是否已过期(通过比较当前时间与缓存时记录的过期时间戳)。
    • 未过期:直接返回缓存数据。
    • 已过期:删除旧缓存,发起真实的 API 请求。
  4. 如果不存在缓存:
    • 直接发起真实的 API 请求。
  5. 当真实的 API 请求成功返回后,将返回结果连同计算出的 过期时间戳 一同存入缓存中。

2.2. 缓存数据结构设计

为了实现高效的过期判断,我们不使用定时器,而是在存储数据时附加一个过期时间戳。

  • 键 (Key): 使用接口的 URL 地址作为唯一的键。
  • 值 (Value): 存储一个包含 接口返回数据expire (过期时间戳) 的对象。
javascript
// cacheStore 的示例结构
{
  // 使用请求 URL 作为属性名
  "/api/order/detail?id=1": {
    // ...这里是 Axios 返回的原始 res 对象的全部内容
    "data": { "id": 1, "product": "商品A", "price": 100 },
    "status": 200,
    "config": { ... },
    // ...其他 res 属性

    // 额外添加一个过期时间字段
    "expire": 1721900400000 // 例如:当前时间 + 5分钟后的毫秒时间戳
  }
}

核心优势:每次使用缓存前,只需用当前时间戳与 expire 字段进行比较,即可判断缓存是否有效,避免了使用大量定时器带来的性能开销。


3. 核心代码实现

以下是一个基于 axios 的实现示例。

3.1. 缓存存储策略 (cacheStore)

我们选择将缓存存储在模块内的 JavaScript 对象中,而不是 localStorage

javascript
// request.js

// 使用一个普通对象作为内存缓存的容器
const cacheStore = {};
  • 优点: 当用户刷新页面时,cacheStore 对象会被重新初始化为空对象 {},所有缓存自动清除。这适用于希望刷新页面就能获取最新数据的场景。
  • 缺点: 缓存无法跨页面会话持久化。
  • 替代方案: 如果需要持久化缓存(即使刷新也不清除),可以将 cacheStore 的操作替换为对 localStoragesetItemgetItem 操作。

3.2. 响应拦截器:自动缓存数据

利用 axios 的响应拦截器,可以在每次请求成功后自动将数据存入缓存。

javascript
// request.js
import axios from 'axios';

const CACHE_DURATION = 5 * 60 * 1000; // 缓存有效期:5分钟

const service = axios.create({
  baseURL: '/api',
  timeout: 5000,
});

// 响应拦截器
service.interceptors.response.use(res => {
  // --- 核心缓存逻辑 ---
  const key = res.config.url; // 使用请求 URL 作为 key
  if (key) {
    const expire = Date.now() + CACHE_DURATION; // 计算过期时间戳
    cacheStore[key] = { ...res, expire }; // 将原始响应和过期时间一起存入缓存
  }
  return res;
});

设计亮点 (抹平差异): 使用 ...res 将整个响应对象展开存入缓存,而不是 data: res。这是为了确保无论数据来自缓存还是真实请求,其数据结构完全一致,调用者无需编写额外的判断代码来处理不同层级的数据。

3.3. 请求方法封装:无感使用缓存

我们导出的不是 axios 实例本身,而是一个包含了 getpost 方法的对象,在这些方法内部实现了缓存的检查逻辑。

javascript
// request.js (续)

/**
 * 检查并返回有效缓存
 * @param {string} key - 缓存的键 (URL)
 * @returns {object|false} - 如果存在且未过期,返回缓存对象;否则返回 false
 */
function hasCache(key) {
  if (cacheStore[key]) {
    const cache = cacheStore[key];
    // 检查时间戳
    if (Date.now() < cache.expire) {
      console.log('✅ 命中缓存:', key);
      return cache; // 缓存有效
    } else {
      // 缓存已过期,从 store 中删除
      delete cacheStore[key];
    }
  }
  return false; // 无有效缓存
}

// 导出的请求对象
export default {
  get(url, config) {
    const key = url; // 对于 GET 请求,URL 本身就是唯一标识
    const cache = hasCache(key);
    if (cache) {
      // 命中缓存,直接返回一个 resolved 的 Promise,模拟 axios 的行为
      return Promise.resolve(cache);
    }
    // 未命中缓存,发起真实请求
    return service.get(url, config);
  },

  post(url, data, config) {
    // 对于 POST,需要将 data 也作为 key 的一部分
    const key = url + JSON.stringify(data);
    const cache = hasCache(key);
    if (cache) {
      return Promise.resolve(cache);
    }
    return service.post(url, data, config);
  }
};

设计亮点 (Promise.resolve): 当命中缓存时,使用 Promise.resolve(cache) 将普通对象包装成一个 Promise。这样做是为了与 axios 的异步行为保持一致,让开发者可以始终使用 .then()await 来处理返回结果,而无需关心数据来源。


4. 进阶问题与优化

4.1. 处理带参数的接口

  • GET 请求: 参数通常拼接在 URL 中(如 ?id=1)。由于我们将整个 URL 作为 key,因此不同参数的 GET 请求会被视为不同的缓存,已天然支持。

  • POST 请求: 参数在请求体(data)中,不在 URL 里。

    • 解决方案: 在生成 key 时,将 URL 和请求体 data 一起组合成一个唯一的字符串。
    javascript
    // 在 post 方法中
    const key = url + JSON.stringify(data);
    
    // 在响应拦截器中,也需要同样处理
    service.interceptors.response.use(res => {
      let key = res.config.url;
      if (res.config.method.toLowerCase() === 'post') {
        key += JSON.stringify(JSON.parse(res.config.data)); // axios 的 data 是 string, 需要先 parse
      }
      // ...后续缓存逻辑
    });

4.2. 缓存数量与内存管理

  • 问题: 如果在短时间内请求大量不同的接口,cacheStore 对象会持续增大,可能导致内存压力。
  • 解决方案: 实现一个缓存淘汰策略,限制缓存的最大数量。例如,采用 先进先出(FIFO) 策略。
    • 维护一个记录了所有 key 的数组。
    • 在存入新缓存前,检查 cacheStore 的大小。
    • 如果超出预设阈值(如 20 个),则从 key 数组的头部取出一个最老的 key,并从 cacheStore 中删除对应项,然后再存入新的缓存。

4.3. 浏览器缓存 vs. 应用层缓存

  • 常见疑问: 为什么不使用浏览器自带的 HTTP 缓存(如 Cache-Control: max-age)?
  • 解答: 浏览器 HTTP 缓存主要用于缓存静态资源文件(如 JS、CSS、图片)。对于通过 XMLHttpRequestfetch 发起的动态数据接口请求,浏览器通常不会自动缓存其返回的 JSON 数据。因此,我们需要在应用层面手动实现这种针对 API 数据的缓存机制。