记一次批量查询优化过程

572 阅读4分钟

页面上存在很多获取下拉选项的请求,需要合并,减少请求次数。

接口情况:获取下拉选项是一个通用接口,根据不同的 code 返回对应的下拉选项。 为了支持批量功能,接口已改造为接受使用逗号连接的多个 code

解决方式有2种,一种是修改业务代码,手动拼接 code。另一种则是改造 api 通过防抖批量查询,业务无感知,下面就介绍下这种方案。

方案

  1. 排队,接收待发送的请求,并添加订阅
  2. 批量查询,使用防抖
  3. 查询结果返回后批量通知

排队

调用 api 后,进入队列,并添加订阅。

// 待请求队列
let toFetch = [];

// 发布订阅队列
let resolves = [];
let rejects = [];

// 向外提供的获取下拉选项的api
export const getOptions = (code) => new Promise((resolve, reject) => {
  // 消息订阅
  resolves.push(resolve);
  rejects.push(reject);

  // 进入队列
  toFetch.push(code);
});

批量查询

使用防抖进行批量查询

  • 如果有新的请求,重置定时器;
  • 如果一段时间内没有新的请求,开始批量查询并清空队列。

// 定时器
let timeout;
// 等待间隔
const interval = 100;

// 批量查询接口
const batchFetch = () => {
  request.get(`${url}/${toFetch.join(',')}`) // TODO 修改url
  // 清空队列
  toFetch = [];
};

// 向外提供的获取下拉选项的api
export const getOptions = (code) => new Promise((resolve, reject) => {
  ...

  /* 结尾添加防抖 */
  // 如果有新的请求,重置定时器
  clearTimeout(timeout);
  timeout = setTimeout(() => {
    // 批量查询
    batchFetch();
  }, interval);
})

通知

存储返回结果,通知各请求取结果。

// 下拉选项结果集合
let optionsMap = {}

// 批量查询接口
const batchFetch = () => {
  // TODO 修改url
  request.get(`${url}/${toFetch.join(',')}`)
    .then((data) => {
      // 存储结果
      optionsMap = data;

      // 发布成功通知
      resolves.forEach((resolve) => resolve());
    })
    .catch(() => {
      // 发布失败通知
      rejects.forEach((reject) => reject());
    })
    .finally(() => {
      // 清空订阅队列
      resolves = [];
      rejects = [];
    });
  // 清空队列
  toFetch = [];
};

// 向外提供的获取下拉选项的接口
export const getOptions = (code) => new Promise((resolve, reject) => {
  ...
}).then(() => optionsMap[code]); // 根据code取结果

优化

  • code 重复
  • 队列长度
  • 结果缓存

code 重复

多个请求的 code 相同,不用重复排队。

待请求队列不重复排队

// 向外提供的获取下拉选项的接口
export const getOptions = (code) => new Promise((resolve, reject) => {
  // 消息订阅
  resolves.push(resolve);
  rejects.push(reject);

  /* ++++++++++++++ */
  // 不重复排队
  if (toFetch.includes(code)) {
    return;
  }
  /* ++++++++++++++ */

  ...

}).then(() => optionsMap[code]); // 根据code取结果

请求中队列

这样还不够,在接口请求发出后,返回前,因为队列已被清空,此时 code 相同仍需排队。

因此添加请求中队列。

// 请求中队列
let fetching = [];

const batchFetch = () => {
  /* ++++++++++++++ */
  // 队列交接,清空排队队列
  fetching = toFetch;
  toFetch = [];
  /* ++++++++++++++ */

  // TODO 修改url
  // `code` 拼接使用请求中队列
  request.get(`${url}/${fetching.join(',')}`)
    ...
    .finally(() => {
      // 清空订阅队列
      resolves = [];
      rejects = [];

      // 清空请求中队列
      fetching = [];
    })
};

// 向外提供的获取下拉选项的接口
export const getOptions = (code) => new Promise((resolve, reject) => {
  // 消息订阅
  resolves.push(resolve);
  rejects.push(reject);

  /* ---+++++++++++ */
  // 不重复排队
  if (fetching.includes(code) || toFetch.includes(code)) {
    return;
  }
  /* ---+++++++++++ */

  ...
}).then(() => optionsMap[code]); // 根据code取结果

队列长度

批量请求 code 过多也会影响性能,因此添加长度限制。

可能存在多个批量请求同时返回的情况,因此存储结果不能直接替换,要改为合并了。

// 批量查询结果
const optionsMap = {}

// 队列最大长度
const maxLength = 10;

const batchFetch = () => {
  ...

  // TODO 修改url
  request.get(`${url}/${fetching.join(',')}`)
    .then((data) => {
      /* ---+++++++++++ */
      // 存储结果只添加和覆盖,不再直接替换
      Object.keys(data).forEach((key) => {
        optionsMap[key] = data[key];
      });
      /* ---+++++++++++ */

      // 发布成功通知
      resolves.forEach((resolve) => resolve());
    })

    ...
};

// 向外提供的获取下拉选项的接口
export const getOptions = (code) => new Promise((resolve, reject) => {
  /* 修改防抖部分函数 */
  /* ---+++++++++++ */
  clearTimeout(timeout);

  // 队列满直接发请求
  if (toFetch.length >= maxLength) {
    batchFetch();
  } else {
    timeout = setTimeout(() => {
      batchFetch();
    }, interval);
  }
  /* ---+++++++++++ */

  ...
}).then(() => optionsMap[code]); // 根据code取结果

结果缓存

经过上一步修改,下拉选项结果一直在内存中,有两处需要调整。

  1. 如果新的请求已有结果,可以直接使用。
  2. 需要适时释放内存。
export const getOptions = (code) => {
  // 如果内存中有结果,直接使用
  if (optionsMap[code]) {
    return Promise.resolve(optionsMap[code]);
  }
  return new Promise(...).then(() => optionsMap[code]); // 根据code取结果
}

// 清空队列时释放内存

const batchFetch = () => {
  ...
  request.get(`${url}/${fetching.join(',')}`)
    ...
    .finally(() => {
      // 清空订阅队列
      resolves = [];
      rejects = [];

      // 释放内存
      fetching.forEach((code) => {
        delete optionsMap[code]
      })

      // 清空请求中队列
      fetching = [];
    })
};

最终代码

// 待请求队列
let toFetch = []; 
// 请求中队列
let fetching = []; 

// 订阅队列
let resolves = [];
let rejects = [];

// 定时器
let timeout;
// 定时器间隔
const interval = 100;

// 下拉选项结果
const optionsMap = {};

// 队列最大长度
const maxLength = 10;

// 批量查询
const batchFetch = () => {
  // 队列交接,清空排队队列
  fetching = toFetch;
  toFetch = [];

  // TODO 修改url
  request.get(`${url}/${fetching.join(',')}`)
    .then((data) => {
      // 存储结果合并
      Object.keys(data).forEach((key) => {
        optionsMap[key] = data[key];
      });

      // 发布成功通知
      resolves.forEach((resolve) => resolve());
    })
    .catch(() => {
      // 发布失败通知
      rejects.forEach((reject) => reject());
    })
    .finally(() => {
      // 清空订阅队列
      resolves = [];
      rejects = [];

      // 释放内存
      fetching.forEach((code) => {
        delete optionsMap[code]
      })

      // 清空请求中队列
      fetching = [];
    });
};

// 向外提供的获取下拉选项的接口
export const getOptions = (code) => {
  // 如果已包含结果,直接使用
  if (optionsMap[code]) {
    return Promise.resolve(optionsMap[code]);
  }
  return new Promise((resolve, reject) => {
    // 消息订阅
    resolves.push(resolve);
    rejects.push(reject);

    // 不重复排队
    if (fetching.includes(code) || toFetch.includes(code)) {
      return;
    }

    // 进入队列
    toFetch.push(code);
    
    // 如果有新的请求,重置定时器
    clearTimeout(timeout);

    // 队列满直接发请求
    if (toFetch.length >= maxLength) {
      batchFetch();
    } else {
      // 设置定时器
      timeout = setTimeout(() => {
        batchFetch();
      }, interval);
    }
  }).then(() => optionsMap[code]); // 根据code取结果
}