JavaScript 使用递归异步的请求

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

场景

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/**
* 文件数据信息类
*/
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
      • 是:返回结果
      • 否:什么都不做

具体实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
/**
* 文件数据信息类
*/
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 函数测试了一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 测试函数的执行时间
* 注:如果函数返回 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日
许可协议