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

2023-10-31 14:59:37 浏览数 (3)

前端获取下载进度,从入门到放弃,讲讲如何使用 fetch/xhr 获取下载进度,有哪些弊端,业务正确的处理方式是什么。

背景

前端大文件的下载,友好的交互方式是能够显示一个进度条,获取到当前下载了多少,还剩余多少。目前有两种原生的方式获取下载进度,分别是 XMLHttpRequestprogress 事件 和 fetchresponse.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。这是为什么?

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 如下:

代码语言:txt复制
const response = await fetch('xxxx')
const body = await response.json()

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

代码语言:javascript复制
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 之后的,但是这样无异于饮鸩止渴,无论是服务端,还是客户端都需要付出巨大的带宽成本,有些因小失大了。

那业务应该如何来处理下载进度呢?有两种方式,一是把文件的大小存放在数据库中,在下载的前先获取文件的大小,然后结合已下载的文件大小,就能够正常的获取到下载进度了,缺点是需要维护一份文件的大到到业务存储中。第二种方式是server端实时去获取文件的大小,也是在下载前先获取文件的大小,不同的是文件的大小是通过os提供的能力实时去获取的,这样做的缺点是,如果是热点资源,一直去读取磁盘,效率会很低,而且业务上,一般也不会把可下载的数据和业务逻辑放在一个server上,成本会比较高。当然,也并不是说 xhr 和 fetch 就完全不能用来获取下载进度了,只要能保证 response headers 中的 content-length 和文件大小是一致的,就也可以用xhr/fetch这种方式来获取下载进度。

总结

本文提供了三种获取下载进度的方法,各有优劣,具体业务上使用哪种方式来获取下载进度,还是要结合具体的业务来选择。

P.S. 而且之前没细想,其实从这其中也不难发现, gzip 具有边下载边解压的能力。

我正在参与2023腾讯技术创作特训营第三期有奖征文,组队打卡瓜分大奖!

0 人点赞