JavaScript 使用递归异步的请求

本文最后更新于:2020年12月25日 晚上

场景

之前写了个 user.js 脚本来抓取百度网盘的文件元信息列表,用来进行二级查看和分析,脚本放到了 GreasyFork。最开始为了简化代码直接使用了 async/await 单线程进行异步请求,导致请求的速度十分不理想!
关键代码如下

/**
 * 文件数据信息类
 */
class File {
  /**
   * 构造函数
   * @param {String} path 全路径
   * @param {String} parent 父级路径
   * @param {String} name 文件名
   * @param {String} size 大小(b)
   * @param {String} isdir 是否为文件
   * @param {String} {origin} 百度云文件信息的源对象
   */
  constructor(path, parent, name, size, isdir, origin) {
    this.path = path;
    this.parent = parent;
    this.name = name;
    this.size = size;
    this.isdir = isdir;
    this.orgin = origin;
  }
}
/**
 * 获取指定文件夹下的一级文件/文件夹列表
 * @param {String} path 绝对路径
 * @returns {Promise} 文件/文件夹列表
 */
async function getDir(path) {
  var baseUrl = "https://pan.baidu.com/api/list?";
  try {
    var res = await fetch(`${baseUrl}dir=${encodeURIComponent(path)}`);
    var json = await res.json();
    return json.list;
  } catch (err) {
    console.log(`读取文件夹 ${path} 发生了错误:`, err);
    return [];
  }
}
/**
 * 将数组异步压平一层
 * @param {Array} arr 数组
 * @param {Function} fn 映射方法
 */
async function asyncFlatMap(arr, fn) {
  var res = [];
  for (const i in arr) {
    res.push(...(await fn(arr[i])));
  }
  return res;
}
/**
 * 递归获取到所有的子级文件/文件夹
 * @param {String} path 指定获取的文件夹路径
 * @returns {Array} 指定文件夹下所有的文件/文件夹列表
 */
async function syncList(path) {
  var fileList = await getDir(path);
  return asyncFlatMap(fileList, async (file) => {
    var res = new File(
      file.path,
      path,
      file.server_filename,
      file.size,
      file.isdir,
      file
    );
    if (res.isdir !== 1) {
      return [res];
    }
    return [res].concat(await syncList(res.path));
  });
}

可以看到,使用的方式是 递归 + 单异步,这就导致了脚本的效率不高,使用体验很差!

解决

吾辈想要使用多异步模式,需要解决的问题有二:

  • 如何知道现在有多个异步在执行并且在数量过多时等待
  • 如何知道所有的请求都执行完成了然后结束

解决思路

  1. 判断并限定异步的数量
    1. 添加记录正在执行的异步请求的计数器 execQueue
    2. 每次请求前先检查 execQueue 是否到达限定值
      • 如果没有,execQueue + 1
      • 如果有,等待 execQueue 减小
    3. 执行请求,请求结束 execQueue - 1
  2. 判断所有请求都执行完成
    1. 添加记录正在等待的异步请求的计数器 waitQueue
    2. 在判断 execQueue 是否到达限定值之前 waitQueue + 1
    3. 在判断 execQueue 是否到达限定值之后(等待之后) waitQueue - 1
    4. 请求结束后判断 waitQueuewaitQueue 是否均为 0
      • 是:返回结果
      • 否:什么都不做

具体实现如下

/**
 * 文件数据信息类
 */
class File {
  /**
   * 构造函数
   * @param {String} path 全路径
   * @param {String} parent 父级路径
   * @param {String} name 文件名
   * @param {String} size 大小(b)
   * @param {String} isdir 是否为文件
   * @param {String} {origin} 百度云文件信息的源对象
   */
  constructor(path, parent, name, size, isdir, origin) {
    this.path = path;
    this.parent = parent;
    this.name = name;
    this.size = size;
    this.isdir = isdir;
    this.orgin = origin;
  }
}
/**
 * 等待指定的时间/等待指定表达式成立
 * @param {Number|Function} param 等待时间/等待条件
 * @returns {Promise} Promise 对象
 */
function wait(param) {
  return new Promise((resolve) => {
    if (typeof param === "number") {
      setTimeout(resolve, param);
    } else if (typeof param === "function") {
      var timer = setInterval(() => {
        if (param()) {
          clearInterval(timer);
          resolve();
        }
      }, 100);
    } else {
      resolve();
    }
  });
}
/**
 * 获取指定文件夹下的一级文件/文件夹列表
 * @param {String} path 绝对路径
 * @returns {Promise} 文件/文件夹列表
 */
async function getDir(path) {
  var baseUrl = "https://pan.baidu.com/api/list?";
  try {
    var res = await fetch(`${baseUrl}dir=${encodeURIComponent(path)}`);
    var json = await res.json();
    return json.list;
  } catch (err) {
    console.log(`读取文件夹 ${path} 发生了错误:`, err);
    return [];
  }
}
/**
 * 递归获取所有文件/文件夹
 * 测试获取 34228 条数据
 * - 100 线程:156518ms
 * - 5   线程:220500ms
 * - 1   线程:超过 20min
 * 实现:
 * 1. 请求文件夹下的所有文件/文件夹
 * 2. 如果是文件则直接添加到结果数组中
 * 3. 如果是文件夹则递归调用当前方法
 * @param {String} [path] 指定文件夹,默认为根路径
 * @param {Number} [limit] 指定限定异步数量,默认为 5
 * @returns {Promise} 异步对象
 */
async function asyncList(path = "/", limit = 5) {
  return new Promise((resolve) => {
    var count = 1;
    var execCount = 0;
    var waitQueue = 0;

    // 结果数组
    var result = [];
    async function children(path) {
      waitQueue++;
      await wait(() => execCount < limit);
      waitQueue--;
      execCount++;
      getDir(path).then((fileList) => {
        fileList.forEach((file) => {
          var res = new File(
            file.path,
            path,
            file.server_filename,
            file.size,
            file.isdir,
            file
          );
          result.push(res);
          if (res.isdir === 1) {
            children(res.path);
          }
        });
        if (--execCount === 0 && waitQueue === 0) {
          resolve(result);
        }
      });
    }
    children(path);
  });
}

吾辈使用 timing 函数测试了一下

/**
 * 测试函数的执行时间
 * 注:如果函数返回 Promise,则该函数也会返回 Promise,否则直接返回执行时间
 * @param {Function} 需要测试的函数
 * @returns {Number|Promise} 执行的毫秒数
 */
function timing(fn) {
  var begin = performance.now();
  var result = fn();
  if (!(result instanceof Promise)) {
    return performance.now() - begin;
  }
  return result.then(() => performance.now() - begin);
}

请求了 2028 次,两个函数的性能比较如下(单位是毫秒)

  • asyncList:109858.80000004545
  • syncList:451904.3000000529

差距近 4.5 倍,几乎等同于默认的异步倍数了,看起来优化还是很值得呢!

附:其实 asyncList 如果使用单异步的话效率反而更低,因为要做一些额外的判断导致单次请求更慢,但因为多个异步请求同时执行的缘故因此缺点被弥补了


那么,关于 JavaScript 使用递归异步的请求就到这里啦


JavaScript 使用递归异步的请求
https://blog.rxliuli.com/p/8067904c808c4b02a99eb2caeb9e7214/
作者
rxliuli
发布于
2020年2月2日
许可协议