Promise应用-网络请求优化

16 阅读11分钟

背景

我们之前已经聊了很多Promise的原理知识,下面走进它的应用领域,也就是它有哪些性能优化上的用途,显然 性能优化有一part是 异步处理优化,也就是网络请求的优化。Promise在这方面可以说是大有作为的,下面就一些经典场景来说明!

减少并发请求数量

问题:同时发送过多请求,并发过高

一、浏览器端:直接影响用户体验,甚至页面卡死

浏览器对并发请求有天然限制(比如Chrome默认对同一域名最多允许6个TCP连接),超过这个限制后,请求会排队等待,进而引发一系列问题:

1. 请求队列阻塞,关键请求延迟飙升
  • 现象:10个分页请求同时发起,浏览器会先处理前6个,剩下4个进入队列等待;如果此时用户触发“提交表单”“加载详情”等核心请求,这些核心请求会被分页请求的队列“插队”,导致响应时间从正常的100ms变成数秒。
  • 实际影响:用户点击按钮后无响应,页面看似“卡住”,体验极差。
2. 资源占用过高,页面卡顿/掉帧
  • 网络层面:每个请求都会占用浏览器的网络线程、内存(存储请求头、响应数据、Promise状态),10个请求同时处理时,浏览器的主线程(负责渲染、JS执行)会被抢占资源,导致:
    • 页面滚动、点击等交互操作卡顿;
    • 列表渲染延迟(比如Table组件迟迟加载不出数据);
    • 严重时出现页面掉帧(帧率从60fps降到10fps以下)。
  • 内存层面:10个请求的响应数据(比如每页10条数据,共100条)会同时存入内存,若数据量大(比如每条数据包含图片URL、大文本),可能导致浏览器内存占用飙升,甚至触发垃圾回收(GC),GC过程会阻塞主线程,让页面短暂卡死。
3. 超时/失败概率升高
  • 浏览器的请求超时时间通常是默认值(比如Chrome默认30秒),但并发过高时,队列中的请求等待时间过长,可能触发:
    • 部分请求因“队列等待超时”被浏览器终止;
    • 响应数据返回顺序混乱(比如第5页的数据比第1页先返回),导致前端数据渲染错乱(列表显示页码和数据不匹配)。
4. 浏览器连接池耗尽
  • 浏览器对同一域名的TCP连接数有限制(Chrome/Edge是6个),10个请求会占用所有连接池,且连接的建立/释放需要时间(TCP三次握手/四次挥手),频繁创建连接会增加网络开销,进一步降低请求效率。

二、服务器端:压力陡增,甚至引发雪崩

服务器端的问题比浏览器更严重,尤其是高并发场景下,10个请求看似不多,但如果是多用户同时触发(比如100个用户各发10个请求),会直接压垮服务器:

1. 服务器线程/进程池耗尽
  • 服务器处理请求依赖线程池(比如Node.js的事件循环、Java的Tomcat线程池),假设服务器线程池最大数是200,100个用户各发10个请求,总共1000个请求,会导致:
    • 线程池被占满,新请求进入等待队列,响应时间从100ms变成10s;
    • 线程切换开销增加(CPU在多个线程间频繁切换),服务器CPU利用率飙升到100%,但处理效率反而下降(上下文切换耗时>实际处理耗时)。
2. 数据库压力倍增,查询性能下降
  • 分页请求最终都会落到数据库(SELECT * FROM table LIMIT x OFFSET y),10个分页请求会触发10次数据库查询:
    • 数据库的连接池被占满,其他业务的数据库操作(比如订单提交)排队等待;
    • 分页查询(尤其是OFFSET大的查询,比如OFFSET 90 LIMIT 10)需要扫描更多数据行,10个此类查询同时执行,会导致数据库CPU/IO利用率飙升,查询耗时从10ms变成100ms以上;
    • 严重时触发数据库锁等待(比如查询和更新操作冲突),导致死锁。
3. 缓存击穿/失效
  • 若服务器有缓存(比如Redis),10个分页请求可能同时查询缓存中不存在的数据(缓存未命中),进而全部穿透到数据库,这种“缓存击穿”会让数据库瞬间承受数倍压力。
4. 服务器雪崩风险
  • 当服务器因并发过高导致响应延迟,浏览器会认为请求超时,用户可能刷新页面,触发更多请求;更多请求又加剧服务器压力,形成“请求超时→用户刷新→更多请求→服务器更卡”的恶性循环,最终导致服务器雪崩(无法处理任何请求)。
5. 资源浪费
  • 10个请求同时返回数据,服务器需要同时序列化(比如JSON.stringify)10份响应数据,占用大量内存和CPU;若前端因数据顺序混乱丢弃部分数据,这些服务器资源就被白白浪费了。

三、举个实际场景的对比(直观感受影响)

场景单用户请求耗时服务器CPU利用率用户体验
并发10个分页请求平均2000ms80%页面卡顿,按钮无响应
用调度器分段(3并发)平均500ms30%流畅,无明显等待

总结

多个请求同时发起的并发过高问题,看似是“小问题”,但会从浏览器体验服务器稳定性两个维度引发连锁反应:

  • 浏览器端:请求阻塞、页面卡顿、数据错乱;
  • 服务器端:线程池耗尽、数据库压力飙升、甚至雪崩。

方案RequestScheduler 的价值是“削峰”,把瞬时高并发变成平稳的分段请求,保护两端性能;

- 当短时间内发起大量请求(比如一次性触发10个分页请求),它会限制同时执行的请求数(比如3个);
- 先执行前3个,其中一个完成后,再从队列里取第4个执行,以此类推;
- 本质是“把并发请求变成串行+并行结合的分段执行”,避免请求堆积导致的服务器压力、浏览器资源耗尽、请求延迟升高等问题。
  • 代码实现
/**
 * 带并发限制的请求调度器
 * @param {number} limit - 最大并发数
 */
class RequestScheduler {
  constructor(limit = 3) {
    this.limit = limit; // 最大并发数
    this.running = 0; // 正在运行的请求数
    this.queue = []; // 等待队列
  }

  /**
   * 添加请求到调度器
   * @param {Function} requestFn - 返回Promise的请求函数
   * @returns {Promise} 请求结果
   */
  add(requestFn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ requestFn, resolve, reject });
      this.run(); // 尝试执行请求
    });
  }

  run() {
    // 若达到并发上限 或 无等待请求,直接返回
    if (this.running >= this.limit || this.queue.length === 0) return;

    this.running++;
    const { requestFn, resolve, reject } = this.queue.shift();

    // 执行请求并处理结果
    Promise.resolve(requestFn())
      .then(resolve)
      .catch(reject)
      .finally(() => {
        this.running--; // 释放并发槽位
        this.run(); // 继续执行队列中的下一个请求
      });
  }
}

// 用法示例
const scheduler = new RequestScheduler(3); // 限制最多3个并发请求

// 分页加载时,通过调度器发起请求,避免并发过高
async function loadPageWithScheduler(page) {
  return scheduler.add(() => 
    fetch(`/api/data?page=${page}&size=10`)
      .then(res => res.json())
  );
}
  • 核心优化点:通过 Promise 队列控制并发数,避免请求堆积,减少服务器和客户端的处理压力,间接降低单个请求的延迟。

四、为什么 RequestScheduler 能解决这些问题

RequestScheduler 通过限制并发数(比如3个),把“10个请求同时发起”变成“分4批执行(3→3→3→1)”,核心作用是:

  1. 浏览器端:避免请求队列阻塞,核心请求优先执行;减少资源占用,页面流畅;
  2. 服务器端:削峰填谷,避免线程池/数据库连接池瞬间被占满,降低CPU/IO压力;
  3. 数据层面:请求按顺序执行,响应数据顺序一致,避免渲染错乱。

5、实现关键点

  1. 「队列模式(FIFO)」(核心)
  2. 「享元模式(池化思想)」
    • 模式定义:把「并发槽位」当成「可复用的资源池」(比如 limit=3 就是 3 个槽位),请求只能占用空闲槽位执行,用完释放;
    • 设计价值:限制资源占用(并发数),避免无限制并发导致的系统崩溃;
    • 类比记忆:像停车场,只有 3 个车位,停满了就排队,开走一辆才能进一辆。 返回新 Promise:保证调用方能用 .then/.catch/await 接收结果,符合「透明性原则」;
  3. 凡是不能确认什么时候触发resolve,都要提前存起来!!!

重试

问题:网络抖动

一、要解决的具体业务问题

1. 偶发性网络波动/瞬时故障
  • 问题场景
    • 移动端切换网络(比如从4G切WiFi)时的瞬时断连;
    • 服务端临时拥堵(比如秒杀场景下的瞬间高并发,个别请求被拒绝);
    • 网络丢包、DNS解析短暂失败等“一次性”问题。
  • 痛点:这些问题不是“永久性失败”,如果只发一次请求就报错,用户体验差(比如点击“提交订单”,只因瞬时网络问题提示“失败”),也会增加人工重试的成本。
2. 服务端“自愈”前的请求容错
  • 问题场景
    • 服务端实例重启、负载均衡切换时,个别请求会命中“正在重启的实例”而失败;
    • 缓存击穿后,数据库瞬时压力大,少量查询请求超时失败。
  • 痛点:这类问题通常持续时间极短(几秒),但单次请求刚好命中就会失败;如果等几秒再发请求,服务端已经恢复。
3. 减少人工重试的开发成本
  • 问题场景
    • 如果没有通用的重试逻辑,每个异步请求都要手动写“try-catch + 循环重试”,代码冗余且易出错;
    • 不同请求的重试次数、延迟时间可能不同,手动维护成本高。
  • 痛点:重复造轮子,代码可读性差,且容易遗漏“重试次数限制”“延迟逻辑”等边界条件。
4. 可控的失败兜底(避免无限重试)
  • 问题场景
    • 如果没有重试次数限制,遇到“永久性失败”(比如接口下线、参数错误)会无限重试,导致客户端卡死、服务端被轰炸;
    • 无限制重试还会消耗客户端资源(比如手机电量、网络流量)。
  • 痛点:重试变成“灾难”,反而加剧问题。

重试机制 + Promise(提升请求成功率)

请求失败时自动重试,通过 Promise 的 catch 捕获错误,递归重试直到成功/次数用尽。

/**
 * 重试机制 + Promise 封装
 * @param {Function} fn - 请求函数(返回 Promise)
 * @param {number} maxRetry - 最大重试次数(默认3次)
 * @param {number} delay - 重试间隔(ms,默认1000)
 * @returns {Function} 带重试的请求函数(返回 Promise)
 */
function retryPromise(fn, maxRetry = 3, delay = 1000) {
  // 步骤1:外层返回函数 + new Promise包裹结果回调
  return function (...args) {
    return new Promise((resolve, reject) => {
      let retryCount = 0;

      // 步骤2:抽离单次执行函数attempt
      const attempt = () => {
        fn.apply(this, args)
          .then((result) => {
            resolve(result); // 本次成功:直接返回结果
          })
          .catch((err) => {
            retryCount++; // 更新计数
            // 判断终止条件
            if (retryCount >= maxRetry) {
              reject(new Error(`重试${maxRetry}次失败:${err.message}`));
              return;
            }
            // 步骤3:未终止 → 延迟后重新调用attempt(替代递归)
            setTimeout(() => {
              attempt();
            }, delay * retryCount); // 指数退避延迟
          });
      };

      // 步骤4:启动第一次执行
      attempt();
    });
  };
}

// 用法示例:易失败的接口请求
const fetchUnstableData = () => fetch('/api/unstable').then(res => res.json());
const retryFetch = retryPromise(fetchUnstableData, 3, 1000);

// 请求失败会自动重试3次
retryFetch().then(res => console.log('成功:', res)).catch(err => console.error('最终失败:', err));

retryPromise 的解决方式

  • 失败后自动重试,利用“重试”覆盖这些偶发问题,大概率能成功。
  • 通过“延迟重试”(尤其是递增延迟),给服务端留恢复时间,重试时大概率能命中正常的服务实例。
  • 作为通用装饰器,一次定义,所有异步函数都能复用,只需传入重试次数/延迟等参数,无需重复写重试逻辑。
  • 设置最大重试次数,用尽后抛出明确错误,既保证“能重试的机会都用掉”,又避免无限重试。

思路拆解(4步万能公式)

不管是「重试」「轮询」「并发调度」这类复杂异步逻辑,只要是「需要重复执行+异步等待」,都能按这4步改:

步骤1:用 new Promise 包裹,提前存好 resolve/reject

这是「结果兜底」的核心——不管内部逻辑多复杂,最终的成功/失败结果,都通过这两个函数传递给调用方。

// 万能模板第一步:包裹结果回调
function 改造后的函数(原函数, 配置) {
  return function (...args) {
    // 存好最终的成功/失败回调,后续不管执行多少次,都靠这两个函数返回结果
    return new Promise((resolve, reject) => {
      // 内部逻辑写在这里
    });
  };
}

✅ 作用:把「不确定什么时候结束的异步逻辑」,和「调用方需要的结果」绑定,调用方只用关心最终的 then/catch

为什么要return new Promise

看「普通return」的局限性 比如如下生成器的执行是「异步递归」的:

function goNext(data) {
  let result = gene.next(data);
  if (result.done) return result.value; // 这里return的是最终值,但在递归深处!
  
  // value是Promise(异步),回调里的goNext是另一个调用栈
  result.value.then(res => {
    goNext(res); // 这里的return值,无法传递到外层的goNext调用
  });
}

// 外层调用:goNext() → 立刻返回undefined,因为内部的then是异步的
const res = goNext(); 
console.log(res); // undefined,拿不到递归深处的result.value

原因:
异步操作(比如 then 回调)的执行时机在「事件循环的微任务阶段」,普通return只能返回「当前同步调用栈」的结果,无法穿透异步调用栈传递到外层。

Promise 本质是一个「能存储异步结果的容器」—— 不管异步操作在哪个调用栈完成,都能通过 resolve/reject 把结果传递到最外层:

重点:「同步结果直接return,异步结果Promise兜」。!!!!

步骤2:抽离「单次执行逻辑」成独立函数(核心复用)

把「执行原函数+判断结果」的逻辑,抽成一个单独的函数(比如叫 attempt/execute),这个函数只做一件事:执行一次原函数,处理本次的成功/失败

// 万能模板第二步:抽离单次执行函数
function 改造后的函数(原函数, 配置) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      // 定义单次执行逻辑
      const attempt = () => {
        // 执行原函数(保留this和参数)
        原函数.apply(this, args)
          .then((result) => {
            // 本次成功:直接用外层的resolve返回结果,结束所有逻辑
            resolve(result);
          })
          .catch((err) => {
            // 本次失败:先判断是否要重试/继续,再决定下一步
            // 后续逻辑写在这里
          });
      };
    });
  };
}

✅ 作用:把「重复执行的逻辑」和「判断逻辑」分离,代码不嵌套,一眼能看懂「单次执行要做什么」。

步骤3:失败后「延迟+重新调用单次执行函数」(替代递归)

原递归写法是「失败后立刻调用自己」,改造后换成「失败后延迟一段时间,再调用抽离好的单次执行函数」,本质是「循环执行单次逻辑」,而非递归。

// 万能模板第三步:失败后延迟重试
const attempt = () => {
  原函数.apply(this, args)
    .then(resolve)
    .catch((err) => {
      // 1. 先更新计数/判断终止条件
      计数++;
      if (计数 >= 最大次数) {
        // 终止:调用外层reject返回最终错误
        reject(最终错误);
        return;
      }
      // 2. 未终止:延迟后重新调用单次执行函数(替代递归)
      setTimeout(() => {
        attempt(); // 重新执行单次逻辑,相当于「重试一次」
      }, 延迟时间);
    });
};

✅ 作用:用「延迟+函数调用」替代递归,逻辑更线性——你能清晰看到「失败→等→再执行」的流程,而非递归的「嵌套调用」。

步骤4:启动第一次执行(触发逻辑)

最后一步:在 new Promise 内部,调用一次「单次执行函数」,启动整个流程。

// 万能模板第四步:启动第一次执行
function 改造后的函数(原函数, 配置) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      let 计数 = 0;
      const attempt = () => { /* 单次执行逻辑 */ };
      
      // 启动第一次尝试,整个流程开始运转
      attempt();
    });
  };
}

✅ 作用:相当于「扣下第一次扳机」,没有这一步,内部逻辑永远不会执行。

降级策略

降级策略 + Promise(弱网/故障时兜底)

真实场景:开发键盘,输入法请求网络词库,弱网下走本地词库

弱网/接口故障时,降级返回兜底数据,通过 Promise.race 检测请求超时,实现降级逻辑。

/**
 * 降级策略 + Promise 封装
 * @param {Function} fn - 正常请求函数(返回 Promise)
 * @param {any} fallbackData - 降级兜底数据
 * @param {number} timeout - 超时阈值(ms,默认3000)
 * @returns {Function} 带降级的请求函数(返回 Promise)
 */
function fallbackPromise(fn, fallbackData, timeout = 3000) {
  // 检测网络状态(简单版,复杂场景可结合 navigator.connection)
  const isWeakNetwork = () => {
    const nav = navigator.connection;
    return nav?.downlink < 1 || nav?.effectiveType === 'slow-2g'; // 下行带宽<1Mbps 或 2G弱网
  };

  return async function (...args) {
    // 弱网直接返回兜底数据
    if (isWeakNetwork()) {
      return Promise.resolve(fallbackData);
    }

    // 正常网络:超时则降级
    const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(fallbackData), timeout));
    const requestPromise = fn.apply(this, args);

    try {
      // 谁先完成就返回谁:请求超时则返回兜底数据
      return await Promise.race([requestPromise, timeoutPromise]);
    } catch (err) {
      // 请求失败也返回兜底数据
      return Promise.resolve(fallbackData);
    }
  };
}
// 简单版
const fallbackPromise = (pFac, timeout = 1000, fallbackData) => {
  // 外层函数接收参数,保证透传this和调用参数
  return function (...args) {
    // 1. 正确的超时Promise:超时后resolve兜底数据
    const timeoutPromise = new Promise((resolve) => {
      setTimeout(() => resolve(fallbackData), timeout); // ✅ 传fallbackData
    });

    // 2. 业务Promise:执行pFac并透传this和参数,兼容同步/异步
    const businessPromise = Promise.resolve(pFac.apply(this, args));

    // 3. 竞态执行 + 失败兜底(直接返回兜底数据,无需包Promise)
    return Promise.race([timeoutPromise, businessPromise])
      .catch(() => fallbackData); // ✅ 去掉花括号,直接返回值
  };
};


// 用法示例:列表请求降级
const fetchList = () => fetch('/api/list').then(res => res.json());
// 兜底数据:空列表 + 提示
const fallbackList = { list: [], msg: '网络不佳,显示本地数据’, type: 'timeout' };
const fallbackFetchList = fallbackPromise(fetchList, fallbackList);

// 弱网/超时/失败时,返回 fallbackList
fallbackFetchList().then(res => console.log('列表数据:', res));

合并请求

  • 🌟 若想真正减少请求次数(从N次→1次):必须依赖后端提供「批量接口」(合并参数查询),这是核心场景;
  • 💡 若只是「合并执行时机」(N次请求仍发,但延迟后一次性执行):无需后端支持,前端单独就能实现。

一、核心场景:真正减少请求次数 → 必须后端支持

这是请求合并最有价值的用法,也是实际项目中最常用的,完全依赖后端

1. 为什么必须后端支持?

前端只能控制「发请求的时机/参数」,但无法改变「一个请求对应一个接口响应」的规则——想要把「查a、查b、查c」的3次请求变成1次,必须有后端接口能接收「a,b,c」的批量参数,并返回3个结果的数组。

2. 前后端配合示例(最典型)
前端操作后端支持
收集100ms内的关键词:a、b、c提供批量接口:/api/search/batch
合并参数为:keywords=a,b,c接收批量参数,查询后返回:[{keyword:'a', data: ...}, {keyword:'b', data: ...}]
发1次请求 → 拿到3个结果后端一次性查3个关键词,减少数据库查询压力

✅ 核心价值:前端请求次数从3→1,后端数据库查询次数从3→1(双重性能优化),这也是请求合并的核心目标。

二、前端单独能做的合并:仅合并执行时机 → 无需后端支持

这种场景不需要后端配合,前端单独就能实现,但不会减少请求次数(N次请求仍发,只是延迟后一次性执行):

1. 适用场景(前端侧合并)
  • 串行请求(性能差,易踩坑)
  • 解决「请求乱序问题」:比如短时间内发3次请求,避免「后发的请求先返回」导致的页面展示错误;
  • 解决「高频执行导致的页面抖动」:比如筛选条件快速切换,3次请求延迟后一次性执行,页面不会反复刷新;
/**
 * 通用请求合并(不依赖后端批量接口)
 * 收集短时间内的多个独立请求,并行执行后聚合结果
 * @param {number} delay - 收集请求的延迟时间(ms),默认100ms
 * @returns {Function} 接收请求函数,返回 Promise
 */
function universalMergeRequests(delay = 100) {
  let requestQueue = []; // 收集待执行的请求函数
  let timer = null;      // 延迟执行的定时器

  // 执行并聚合所有请求的结果
  const executeAll = async () => {
    if (requestQueue.length === 0) return;
    // 用 Promise.allSettled 确保所有请求都执行完毕(无论成功失败)
    const results = await Promise.allSettled(
      requestQueue.map(async (request) => await request())
    );
    // 分发结果到对应的 Promise
    results.forEach((result, index) => {
      const { resolve, reject } = requestQueue[index];
      if (result.status === "fulfilled") {
        resolve(result.value);
      } else {
        reject(result.reason);
      }
    });
    // 清空队列
    requestQueue = [];
    timer = null;
  };

  return function (requestFn) {
    return new Promise((resolve, reject) => {
      // 存入请求函数 + 对应的resolve/reject
      requestQueue.push({
        request: requestFn,
        resolve,
        reject,
      });
      // 延迟后批量执行
      if (!timer) {
        timer = setTimeout(executeAll, delay);
      }
    });
  };
}
举个实际场景:筛选条件切换(串行→并行+合并)
场景:用户快速切换3个筛选条件(日期、类型、状态),原本是串行请求
// 原本的串行逻辑(用户切换一次发一次,且串行)
filterInput.addEventListener('change', async (e) => {
  const type = e.target.value;
  // 串行:先查日期→再查类型→再查状态,总耗时久
  const dateRes = await fetch('/api/filter?date=2024');
  const typeRes = await fetch(`/api/filter?type=${type}`);
  const statusRes = await fetch('/api/filter?status=active');
  renderPage(dateRes, typeRes, statusRes);
});
改造后:并行+请求合并(延迟100ms,并行执行所有请求)
// 创建合并工具
const mergeFilter = universalMergeRequests(100);

// 改造为:收集请求→并行执行
filterInput.addEventListener('change', (e) => {
  const type = e.target.value;
  // 收集3个请求(原本串行,现在存入队列等待并行执行)
  mergeFilter(() => fetch('/api/filter?date=2024')).then(dateRes => {/* 处理 */});
  mergeFilter(() => fetch(`/api/filter?type=${type}`)).then(typeRes => {/* 处理 */});
  mergeFilter(() => fetch('/api/filter?status=active')).then(statusRes => {/* 处理 */});
});

✅ 核心优化:

  1. 串行→并行:3个请求从「依次执行」变成「同时执行」,总耗时从「500+500+500=1500ms」降到「500ms」;
  2. 合并执行时机:100ms内的高频切换,只触发一次并行执行,避免多次串行请求的叠加耗时;
  3. 结果不乱序:Promise.allSettled保证结果按调用顺序返回,即使并行执行,也能按「日期→类型→状态」的顺序处理。

关键细节:并行执行+结果顺序保证

你可能会问:「并行执行的请求返回顺序可能乱,为什么结果能精准分发?」

  • 核心:Promise.allSettled按传入Promise的顺序返回结果,和请求实际完成顺序无关! 比如:请求a耗时500ms,请求b耗时100ms,并行执行后,results数组的第0位依然是a的结果,第1位是b的结果——这也是能「精准分发结果」的关键。

面试答题要点(串行→并行+请求合并)

  1. 核心价值:请求合并工具不仅能「合并执行时机/参数」,还能把原本的串行请求改成并行执行,大幅降低总耗时;
  2. 技术核心:通过 Promise.allSettled + 数组map 实现并行执行,且保证结果顺序和调用顺序一致;
  3. 场景举例:筛选条件切换、批量数据查询等场景,从「串行依次请求」改造成「并行批量执行」,性能提升显著;
  4. 和后端配合:若后端提供批量接口,还能进一步把「并行N次请求」改成「1次批量请求」,性能再升级。
  5. 减少页面抖动:一次性更新所有结果,而非多次零散更新,提升用户体验;

总结

「串行请求改造成并行」是请求合并工具的重要价值之一:

  1. 工具内的 Promise.allSettled(map(...)) 是「并行执行」的核心,把收集的多个请求从串行变成并行;
  2. 并行执行+合并执行时机,实现「耗时最小化+请求次数最小化」的双重优化;
  3. Promise.allSettled 保证结果顺序和调用顺序一致,不会因为并行导致结果串错。

简单说:请求合并工具既解决了「高频请求多」的问题,又解决了「串行请求慢」的问题——这也是它比单纯防抖/节流更强大的核心原因。

减少重复请求

具体业务场景(最易理解)

用「页面多次查询同一用户信息」这个高频场景举例,你就能立刻明白它的价值:

场景:重复请求+性能浪费
// 异步函数:查询用户信息(发网络请求,耗时500ms)
async function fetchUser(id) {
  console.log('发起请求:查询用户', id);
  const res = await fetch(`/api/user/${id}`);
  return res.json();
}

// 页面多个模块都需要用户1的信息,多次调用
async function initPage() {
  // 模块1查用户1:发请求,耗时500ms
  const user1 = await fetchUser(1);
  // 模块2查用户1:又发请求,再耗时500ms
  const user1Again = await fetchUser(1);
  // 总耗时1000ms,且后端收到2次重复请求
}
initPage();

❌ 问题:

  • 相同参数(id=1)的请求被重复调用,后端要处理多次重复查询,增加接口QPS压力;
  • 每次调用都要发起网络请求,总耗时翻倍,页面加载慢;
  • 若网络波动,重复请求可能导致数据返回不一致(比如第一次请求成功,第二次失败)。

解决方案:请求缓存

/**
 * 请求缓存 + Promise 封装
 * @param {Function} fn - 请求函数(返回 Promise)
 * @param {number} expire - 缓存过期时间(ms,默认5分钟)
 * @returns {Function} 带缓存的请求函数(返回 Promise)
 */
function cachePromise(fn, expire = 5 * 60 * 1000) {
  const cacheMap = new Map(); // 缓存键:请求参数拼接;值:{ result, time }

  return async function (...args) {
    // 生成唯一缓存键(简单拼接参数,复杂场景可序列化)
    const cacheKey = JSON.stringify(args);
    const now = Date.now();

    // 命中缓存且未过期,直接返回缓存结果
    if (cacheMap.has(cacheKey)) {
      const { result, time } = cacheMap.get(cacheKey);
      if (now - time < expire) {
        return Promise.resolve(result);
      }
      // 过期则删除缓存
      cacheMap.delete(cacheKey);
    }

    // 未命中缓存,执行请求并缓存结果
    try {
      const result = await fn.apply(this, args);
      cacheMap.set(cacheKey, { result, time: now });
      return result;
    } catch (err) {
      // 请求失败不缓存
      return Promise.reject(err);
    }
  };
}

// 用法示例:缓存用户信息请求
const fetchUserInfo = (uid) => fetch(`/api/user/${uid}`).then(res => res.json());
const cachedFetchUser = cachePromise(fetchUserInfo);

// 第一次请求:发网络请求,缓存结果
cachedFetchUser(1001).then(res => console.log('用户1001:', res));
// 第二次请求:直接返回缓存结果(5分钟内)
cachedFetchUser(1001).then(res => console.log('缓存的用户1001:', res));
用这个工具解决问题(缓存结果)
// 给fetchUser添加缓存,过期时间5分钟
const cachedFetchUser = cachePromise(fetchUser);

async function initPage() {
  // 模块1查用户1:无缓存,发请求,耗时500ms,结果存入缓存
  const user1 = await cachedFetchUser(1);
  // 模块2查用户1:命中缓存,直接返回结果,耗时0ms
  const user1Again = await cachedFetchUser(1);
  // 总耗时500ms,后端只收到1次请求
}
initPage();

✅ 解决的问题:

  1. 减少重复请求:相同参数的请求,有效期内只发一次,后端压力大幅降低;
  2. 提升响应速度:缓存命中时从内存直接返回,响应耗时从「网络请求级(500ms)」降到「内存级(0ms)」;
  3. 避免数据不一致:相同参数的请求复用同一结果,不会出现「多次调用结果不同」的情况;
  4. 自动过期清理:5分钟后缓存失效,下次调用会重新发请求,保证数据不会长期过时。

其他典型应用场景

除了用户信息查询,还有这些场景能用到:

  1. 列表数据缓存:页面切换后返回,相同筛选条件的列表数据直接从缓存读取,不用重新请求;
  2. 复杂计算缓存:异步的复杂逻辑计算(比如大数据量统计),相同参数的计算结果缓存,避免重复计算;
  3. 接口限流兜底:对高频调用的接口(比如商品详情)做缓存,减少接口调用次数,避免触发限流;
  4. 本地缓存替代localStorage:临时的内存缓存(比localStorage读写快),且支持自动过期,适合短期复用的数据。

对比普通全局变量缓存:它的独特价值

你可能会问「用全局变量存结果也能缓存,为什么要用这个工具?」——核心区别:

方案普通全局变量缓存cachePromise工具
过期机制无,需手动清理自动过期,过期后删除缓存
参数级缓存只能存一个结果按参数生成唯一键,支持多参数缓存(比如fetchUser(1)、fetchUser(2)分别缓存)
this 上下文易丢失保留原函数的this指向
失败处理可能缓存错误结果请求失败不缓存,避免脏数据
调用透明性需手动判断缓存逻辑调用方无感知,和原函数用法一致

核心解决的问题总结(记忆要点)

  1. 性能问题:减少重复异步请求/计算,降低后端压力,提升前端响应速度;
  2. 数据一致性问题:相同参数的请求复用同一结果,避免多次调用返回不同数据;
  3. 缓存时效性问题:支持过期时间,自动清理过期缓存,避免返回长期脏数据;
  4. 开发效率问题:无侵入式缓存改造,不改变原函数用法,调用方无需适配。

面试答题话术

这个函数是「异步函数的带过期时间缓存工具」,核心解决三类问题:

  1. 减少重复异步请求/计算:相同参数的调用在有效期内只执行一次,降低后端接口压力和前端计算开销;
  2. 提升响应速度:缓存命中时从内存直接返回结果,大幅缩短响应时间;
  3. 保证缓存时效性:支持过期时间,自动清理过期缓存,避免返回脏数据; 同时它遵循缓存的「透明性原则」,保留原函数的this上下文和返回值类型,调用方无需感知缓存的存在,是前端性能优化的常用方案。