前端获取下载进度,从入门到放弃,讲讲如何使用 fetch/xhr 获取下载进度,有哪些弊端,业务正确的处理方式是什么。
背景
前端大文件的下载,友好的交互方式是能够显示一个进度条,获取到当前下载了多少,还剩余多少。目前有两种原生的方式获取下载进度,分别是 XMLHttpRequest
的 progress
事件 和 fetch
的 response.body
。
XMLHttpRequest
的方式
XMLHttpRequest 是一个比较旧的 API 了,可以通过监听 XMLHttpRequest 的 progress 事件,来获取下载进度,示例代码如下:
代码语言:javascript复制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
。这是为什么?
progress
的 event
实例有以下三个属性需要关注
lengthComputable
: Boolean 值,指出下载进度能否被计算loaded
: 已下载的大小,单位为B
total
: 文件总大小,单位为B,大小和respone.headers
中的Content-Length
一致,实际测试发现,当lengthComputable
为false
时,total
为0
现网会走到 lengthComputable
为 false
的场景,我遇到的一个原因是 gzip
,现网请求时,文件不再以原大小的方式直接返回,而是通过 gzip
之后再返回。
这样就 total
也就是 response.headers
中的 Content-Length
不再是实际文件的大小,而是gzip之后的, 而 loaded
属性是文件已经下载的 gzip 解压之后的实际大小,并不是已经下载的gzip内容的大小,所以从JS层面无法再正确获取到下载的实际进度,所以 lengthComputable
为 false
也就可以解释了。
fetch
的方式
fetch
是一个比较新的API,从发请求的角度来说,fetch
相比于 XMLHttpRequest
更方便调用。在 fetch
刚推出的时候,普遍认为的一个劣势是 fetch
没有办法获取到下载进度,其实借鉴 XMLHttpRequest
的方式,fetch
也能实时获取到下载进度。
fetch
把请求分为了两步,第一步是从发起请求到接收返回头,第二步是 body
内容,所以在 fetch
调用时,如果要获取返回,一般有两个 await
如下:
const response = await fetch('xxxx')
const body = await response.json()
平时用的比较多的应该是 response.json()
或 response.arrayBuffer()
这两个方法,实际上除此之外, response
还有一个 body
的属性,这个 body
是一个ReadableStream
实例,一说 Stream
大家应该都懂了,流式读取数据,可以通过 response.body
实时获取后台返回的数据,代码如下:
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
解压之后的内容,所以 total
和 value
不配套的情况下,无法在起始阶段就分配缓冲区大小,也无法获取到实际的下载进度。
解决方案
事情到了这里,不管是用 XMLHttpRequest
, 还是使用 fetch
也好,最终都回到了同一个问题上,gzip
之后,无法获取下载进度,除非每次请求都不使用 gzip
之后的,但是这样无异于饮鸩止渴,无论是服务端,还是客户端都需要付出巨大的带宽成本,有些因小失大了。
那业务应该如何来处理下载进度呢?有两种方式,一是把文件的大小存放在数据库中,在下载的前先获取文件的大小,然后结合已下载的文件大小,就能够正常的获取到下载进度了,缺点是需要维护一份文件的大到到业务存储中。第二种方式是server端实时去获取文件的大小,也是在下载前先获取文件的大小,不同的是文件的大小是通过os提供的能力实时去获取的,这样做的缺点是,如果是热点资源,一直去读取磁盘,效率会很低,而且业务上,一般也不会把可下载的数据和业务逻辑放在一个server上,成本会比较高。当然,也并不是说 xhr 和 fetch 就完全不能用来获取下载进度了,只要能保证 response headers 中的 content-length 和文件大小是一致的,就也可以用xhr/fetch这种方式来获取下载进度。
总结
本文提供了三种获取下载进度的方法,各有优劣,具体业务上使用哪种方式来获取下载进度,还是要结合具体的业务来选择。
P.S. 而且之前没细想,其实从这其中也不难发现, gzip
具有边下载边解压的能力。
我正在参与2023腾讯技术创作特训营第三期有奖征文,组队打卡瓜分大奖!