Skip to content

学习笔记:实现高并发场景下的请求合并函数 syncOnce

一、 核心问题与目标

在前端开发中,可能会遇到多个组件在同一时间(例如页面加载时)请求同一个接口获取相同数据的场景。这会导致发出多个重复的网络请求,造成资源浪费。

目标: 创建一个高阶函数 syncOnce,它接受一个异步函数作为参数,并返回一个新函数。这个新函数具备以下特性:

  • 当被并发调用(即第一次调用尚未完成时,又发起了后续调用)时,只执行一次底层的异步函数。
  • 所有并发的调用都会收到这唯一一次执行的结果。
javascript
// 目标函数签名
function syncOnce(asyncFunction) {
  // ... 实现 ...
  return function(...args) {
    // ... 返回一个新的、经过优化的函数 ...
  }
}

// 使用场景
const loadData = async (id) => { /* ...发请求... */ };
const getOnce = syncOnce(loadData);

// 多个组件同时调用
getOnce(1); // 发起网络请求
getOnce(1); // 不会发起新请求,等待上一个结果
getOnce(1); // 同上

二、 初版实现:处理无参数的情况

最简单的思路是利用一个状态标记(flag)来判断当前是否有请求正在进行中。

1. 实现思路

  • 闭包变量:使用闭包来维护两个状态变量:
    • isPending (boolean): 标记是否有请求正在处理。
    • cbs (callbacks, array): 一个回调函数数组,用于存储在请求进行中时发起的其他调用的 resolvereject 函数。
  • 执行逻辑
    1. 当包装后的函数被调用时,立即将其 resolvereject 回调存入 cbs 数组。
    2. 检查 isPending 状态:
      • 如果为 true,说明已有请求在途,直接 return,等待被处理。
      • 如果为 false,说明是首次调用,将 isPending 设为 true,然后执行原始的异步函数。
    3. 异步函数执行完毕后(无论成功或失败):
      • 遍历 cbs 数组,用得到的结果或错误,统一处理所有等待中的 Promise。
    4. finally 块中,重置 isPendingfalse 并清空 cbs 数组,以便函数可以被再次调用。

2. 代码实现

javascript
function syncOnce(promiseFn) {
  let isPending = false;
  const cbs = []; // 存储 resolve 和 reject

  return function(...args) {
    return new Promise((resolve, reject) => {
      // 无论如何,先把当前调用的 resolve/reject 入队
      cbs.push({ resolve, reject });

      // 如果正在请求中,则直接返回,等待被唤醒
      if (isPending) {
        return;
      }

      isPending = true;
      promiseFn(...args)
        .then(data => {
          // 成功后,唤醒所有等待的 promise
          cbs.forEach(({ resolve }) => resolve(data));
        })
        .catch(err => {
          // 失败后,同样处理
          cbs.forEach(({ reject }) => reject(err));
        })
        .finally(() => {
          // 重置状态,以便下次可以重新发起请求
          isPending = false;
          cbs.length = 0;
        });
    });
  };
}

💡 优化点

cbs.push 操作无条件地放在检查 isPending 之前,可以简化逻辑。


三、 问题升级与最终实现:支持不同参数

初版实现有一个明显缺陷:它无法区分带有不同参数的调用。例如,getOnce(1)getOnce(2) 会被错误地认为是同一个请求,导致 getOnce(2) 拿到 getOnce(1) 的结果。

1. 解决方案:使用 Map

为了管理不同参数的请求状态,我们需要一个更强大的数据结构来取代单一的 isPending 标记。Map 是理想的选择。

  • Key: 将函数的参数序列化(例如 JSON.stringify)作为 Map 的唯一键。
  • Value: 存储与该 key 对应的独立状态,包括 isPendingcbs 数组。

2. 最终实现思路

  1. 创建一个全局的 Map 来存储所有请求的状态。
  2. 当包装函数被调用时,将传入的参数 args 序列化成一个 key
  3. 检查 Map 中是否存在该 key
    • 如果不存在,为这个 key 初始化一个状态对象 { isPending: false, cbs: [] } 并存入 Map
  4. Map 中获取当前 key 对应的状态对象 state
  5. 后续逻辑与初版实现类似,但所有操作都针对 state 对象(state.isPending, state.cbs)。
  6. 关键一步:清理。在异步操作的 finally 块中,必须从 Map 中删除当前的 key (map.delete(key))。这确保了合并仅针对并发调用。当一组并发请求完成后,下一次使用相同参数的调用将能发起新的请求。

3. 最终代码 (通用版本)

javascript
/**
 * 接受一个异步函数,返回一个新函数。
 * 当新函数被并发调用时,只有第一次会真正执行,
 * 后续调用会返回第一次执行的 Promise。
 * 不同参数的调用会被视为不同的请求。
 * @param {function} promiseFn 一个返回 Promise 的异步函数
 */
function syncOnce(promiseFn) {
  // 使用 Map 来存储不同参数对应的请求状态
  const cache = new Map();

  return function(...args) {
    // 1. 将参数序列化,作为唯一键
    // 注意:这里简单 stringify,复杂对象可能需要更稳健的序列化方案
    const key = JSON.stringify(args);

    return new Promise((resolve, reject) => {
      // 2. 检查缓存中是否有此 key 的状态
      let state = cache.get(key);
      if (!state) {
        // 如果没有,初始化状态
        state = {
          isPending: false,
          cbs: []
        };
        cache.set(key, state);
      }

      // 3. 将当前调用的回调入队
      state.cbs.push({ resolve, reject });

      // 4. 如果此 key 对应的请求已在进行中,则直接返回
      if (state.isPending) {
        return;
      }

      // 5. 如果是新请求,则执行
      state.isPending = true;

      promiseFn(...args)
        .then(data => {
          // 成功,用结果 resolve 所有等待的 promise
          state.cbs.forEach(({ resolve }) => resolve(data));
        })
        .catch(err => {
          // 失败,用错误 reject 所有等待的 promise
          state.cbs.forEach(({ reject }) => reject(err));
        })
        .finally(() => {
          // 6. 请求完成,从缓存中删除该 key,以便下次能重新请求
          cache.delete(key);
        });
    });
  };
}

// --- 示例 ---
const getUser = (id) => {
  console.log(`发起请求: 获取用户 ${id}...`);
  return new Promise(resolve => setTimeout(() => resolve({ id, name: `用户${id}` }), 1000));
};

const getOnceUser = syncOnce(getUser);

console.log("并发调用 getOnceUser(1)...");
getOnceUser(1).then(console.log);
getOnceUser(1).then(console.log);

console.log("并发调用 getOnceUser(2)...");
getOnceUser(2).then(console.log);

// 预期输出:
// 并发调用 getOnceUser(1)...
// 发起请求: 获取用户 1...
// 并发调用 getOnceUser(2)...
// 发起请求: 获取用户 2...
// (1秒后)
// { id: 1, name: '用户1' }
// { id: 1, name: '用户1' }
// { id: 2, name: '用户2' }

四、 总结

syncOnce 函数是前端性能优化中的一个实用技巧,其核心思想可以总结为:

  • 闭包:用于创建私有作用域,存储 Map 缓存。
  • 状态标记 isPending:防止对同一个资源发起重复请求。
  • Map 数据结构:通过将参数序列化为键,实现了对不同参数调用的精细化管理。
  • 请求后清理 map.delete(key):这是设计的关键,它将函数的适用范围限定在“处理并发请求”,而不是实现一个永久性的数据缓存。