前端获取下载进度——从入门到放弃

前端获取下载进度,从入门到放弃,讲讲如何使用 fetch/xhr 获取下载进度,有哪些弊端。

背景

前端大文件的下载,友好的交互方式是能够显示一个进度条,获取到当前下载了多少,还剩余多少。目前有两种原生的方式获取下载进度,分别是 XMLHttpRequestprogress 事件 和 fetchresponse.body

XMLHttpRequest 的方式

XMLHttpRequest 是一个比较旧的 API 了,可以通过监听 XMLHttpRequest 的 progress 事件,来获取下载进度,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
const xhr = new XMLHttpRequest()
xhr.addEventListener('progress', (event) => {
if (event.lengthComputable) {
console.log(event.loaded, event.total)
} else {
console.log('cant get progress', event)
}
})

xhr.open('GET', './data.txt')
xhr.send()

正常情况下,这段代码是可以跑通的。但是显然大多数场景都是不正常的情况,会在控制台输出 cant get progress。这是为什么?

progressevent 实例有以下三个属性需要关注

  1. lengthComputable: Boolean 值,指出下载进度能否被计算
  2. loaded: 已下载的大小,单位为 B
  3. total: 文件总大小,单位为B,大小和 respone.headers 中的 Content-Length 一致,实际测试发现,当 lengthComputablefalse 时,total 为0

现网会走到 lengthComputablefalse 的场景,我遇到的一个原因是 gzip,现网请求时,文件不再以原大小的方式直接返回,而是通过 gzip 之后再返回。

这样就 total 也就是 response.headers 中的 Content-Length不再是实际文件的大小,而是gzip之后的, 而 loaded 属性是文件已经下载的 gzip 解压之后的实际大小,并不是已经下载的gzip内容的大小,所以从JS层面无法再正确获取到下载的实际进度,所以 lengthComputablefalse 也就可以解释了。

fetch 的方式

fetch 是一个比较新的API,从发请求的角度来说,fetch 相比于 XMLHttpRequest 更方便调用。在 fetch 刚推出的时候,普遍认为的一个劣势是 fetch 没有办法获取到下载进度,其实借鉴 XMLHttpRequest 的方式,fetch 也能实时获取到下载进度。

fetch 把请求分为了两步,第一步是从发起请求到接收返回头,第二步是 body 内容,所以在 fetch 调用时,如果要获取返回,一般有两个 await 如下:

1
2
const response = await fetch('xxxx')
const body = await response.json()

平时用的比较多的应该是 response.json()response.arrayBuffer() 这两个方法,实际上除此之外, response 还有一个 body 的属性,这个 body 是一个 ReadableStream 实例,一说 Stream 大家应该都懂了,流式读取数据,可以通过 response.body 实时获取后台返回的数据,代码如下:

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
const downloadWithProgress = async (url, onUpdate) => {
const response = await fetch(url)
const total = +response.headers.get('Content-Length')
const result = []
let progress = 0
const reader = response.body.getReader()
while(true) {
const { done, value } = await reader.read()
if (done) {
break;
}
result.push(value)
progress += value.length
onUpdate && onUpdate(progress, total)
}

let data = new Uint8Array(progress)

let position = 0
result.forEach(item => {
data.set(item, position)
position += item.length
})

return data
}

猛一看,可能会有点疑问,既然已经拿到了 total 值,为什么不一开始创建一个 Uint8Array,逐次往里面 set,而要全部返回后再实例化 Uint8Array ?

其实和 XMLHttpRequest 是同样的道理,total 是通过 response.headers 中的 Content-Length 获取的,当使用了 gzip 之后,这个 total 值就不准了,而在每一次拿到的 value 值,是 gzip 解压之后的内容,所以 totalvalue 不配套的情况下,无法在起始阶段就分配缓冲区大小,也无法获取到实际的下载进度。

总结

事情到了这里,不管是用 XMLHttpRequest, 还是使用 fetch 也好,最终都回到了同一个问题上,gzip 之后,无法获取下载进度,除非每次请求都不使用 gzip 之后的,但是这样无异于饮鸩止渴,无论是使用方,还是提供方都需要付出巨大的带宽成本。

此时就需要业务存储文件的实际大小,配合 fetchXMLHttpRequest 才能处理下载进度了。

而且之前没细想,其实也不难发现, gzip 具有加下载加解压的能力。