likes
comments
collection
share

文件上传与下载全方案介绍,关键是思路要清晰

作者站长头像
站长
· 阅读数 18

前言

文件上传和下载,挺常见的一个需求,但实现的方式却花样繁多,保持清晰思路,根据实际需求选择合适的方案很重要。这是一篇总结文,探讨了从原生支持出发的各种文件上传下载方案,如果有所遗漏或错误,欢迎评论区勘误

文件上传

前端实现文件上传,主要基于现代浏览器良好的 File API 支持,配合 http 的multipart/form-data二进制数据传输方式,可分解为三个步骤完成简单的文件上传:

  • 将待上传文件构造为 File/Blob 对象
  • 构造 FormData 作为传输载体,将 File/Blob 对象添加到其中
  • 构造 xhr/fetch 请求,完成上传

http 也可以使用 application/octet-stream 来进行文件的传输,表示未知文件类型的二进制数据传输,且仅能传输单文件,适用性不及 multipart/form-data

构造 File/Blob 对象

HTML5 开放了多种方法供我们选取文件,并将我们选取的文件生成 File 对象,FileBlob类文件对象的特殊类型,我们不需要关心文件的格式,File 会自动根据文件的二进制标识符判断对应的MIME类型,我们有三种方式选取文件:

Input 选择框

通过type = "file"input选择框,选择的文件会出现在 input 的 DomElement 的 files 属性中

拖拽选取

主要用到了 HTML5 中以下四种事件:

  • drop:当拖动的元素在自身范围内释放,即松开鼠标时,触发 drop 事件。
  • dragenter:当拖动的元素进入自身范围时, dragenter 事件被触发。
  • dragover:当拖动的元素处于自身范围时,循环触发 dragover 事件(每几百毫秒触发一次)。
  • dragleave:当拖动的元素离开自身范围时,触发 dragleave 事件。

值得注意的是仅注册drop事件监听是不会生效的,至少需要注册dragoverdrop事件才能触发 drop 事件,当我们拖拽文件到指定的 div 中,松开鼠标,文件会出现在 drop 事件的 dataTransfer 的 files 列表中,即e.dataTransfer.files

实现时需要阻止浏览器预览的默认事件

粘贴选取

粘贴选取需要通过contentEditable属性构建一个输入框,将文件复制粘贴到该输入框内,并监听paste事件,文件会出现在event.clipboardData.files

构造 FormData 对象

将 File 对象添加到其中,准备上传,可添加多个文件

formData = new FormData(form),
formData.append("fileName", file);

上传文件也可以采用 ArrayBuffer 或者 base64 的形式,但这些形式都会将文件的内容读取到内存中再上传,十分消耗性能,不适用于较大的文件。且 formData 可以多文件上传,是比较好的选择。

发送 xhr/fetch 上传请求

构造 xhr/fetch 请求,将 FromData 放在 body 中 POST 上传即可。

注意使用multipart/form-data进行文件上传时,需要指定----boundary分割符,用于多文件信息的编码分割,这个是必需的,但如果我们不指定,浏览器会自动设定。

所以在请求头中,要么我们显式添加Content-Type:multipart/form-data并指定----boundary,如果你没有指定----boundary的需求就干脆啥都不加,否则可能会出现未指定----boundary的错误

大文件上传

文件上传还有个难点,那就是大文件上传,由于现在家用网络都采用了 ADSL 技术,使得上行速度往往比下行速度慢很多,上传一个大文件需要很长的时间,假如中间发生了异常,好不容易上传的,又要从头开始,用户体验是极差的。

解决的思路就是将大文件拆分成多个小文件,再并发上传,多个小文件的上传请求更易于控制,服务器接收到所有小文件分片后再进行合并,完成上传。

在实现过程中我们需要考虑下面的问题:

  • 如何进行文件分片
  • 由于是并发,分片的到达是乱序的,如何确定分片顺序
  • 如何与服务器协商合成的时机
  • 如果上传过程中,发生了中断或者某些分片丢失,该如何处理(断点续传)

第一个问题浏览器已经提供了成熟的 API,后面的问题都是调度控制问题,我们需要找到一个前后端共用的分片索引方案来协商解决

文件分片

File提供了从Blob继承而来的slice方法,可以将文件按字节数拆分为多个Blob小文件,然后通过 http 并发上传:

let chunkList = [];
const size = file.size;
const piece = 100*1024*1024; // 分段长度100M
let current = 0;
while (current < size) {
  chunkList.push(file.slice(current, current + piece));
  current += piece;
}

不同的浏览器可并发的 TCP 连接数不同,Chrome 的最大并发数为 6,我们需要使用一个 promise 队列来控制上传请求数量;如果采用的是HTTP2,不同于HTTP1.1通过增加 TCP 连接数的并发,前者可以多路复用更多的连接,效果更好。

分片标记

为了与服务器协商合成时机与断点续传的问题,我们需要一份客户端和服务器都统一的分片索引(统一指的是该索引不能是单方定义的),用于记录上传的进程。一般的做法是使用MD5文件内容摘要算法,生成每个分片的 hash 标记索引:

spark-md5 生成索引

引入:

npm install --save spark-md5

通过FileReader读取 blob 字节流,再由spark-md5生成索引:

// 初始化接收ArrayBuffer数据的spark实例
const spark = new SparkMD5.ArrayBuffer();

// 构造reader
const reader = new FileReader();
reader.onload = (e) => {
  // append待生成数据
  spark.append(e.target.result);
}
reader.readAsArrayBuffer(file)

// 生成结果
const result = spark.end()

由于MD5摘要算法是基于文件内容本身,共同约定的算法,所以服务器也能解析出相同的标记,对于客户端来讲是统一的

内存管理和使用 web worker

FileBlob都不会将文件真正读入内存中处理,包括slice分片,以及构造ObjectURL等操作。但在生成MD5索引时,我们使用了FileReader获取了文件的ArrayBufferFileReader则会将读取的内容写入内存,尤其是读取多个大文件分片的时候,很容易内存爆掉。

比较合适的做法是文件分片时,切分大小不宜过大,且分片标记计算完成后,及时释放内存

Blob 引用的文件本身并没有被放入内存,所以其并不存在大小限制,但是却存在读取限制,当 Blob 文件容量超过了读取限制,将无法通过 FileReader 进行读取,这个限制是由用户电脑内存磁盘配置动态决定的,同时也与不同的浏览器内核有关。

且上传过程中大量并发的 http 请求和分片 hash 计算,很容易阻塞渲染线程,让用户只能干等着,所以最好是使用web worker新开一个线程来处理。

确定分片顺序与合成时机

在文件拆分和计算 hash 阶段,我们将分片以hash+顺序下标的方式进行命名,这样服务器就能根据分片名,在本地维护同样的一份索引表,确定哪些分片上传了,以及分片的顺序。

分片的合成时机也有两种方案,一种是服务器主动合并,客户端在每次传输的请求头添加总分片数字段,当服务器接收到数量足够的分片时,主动进行合并;另一种是客户端传输完毕所有分片后,在发一个请求通知服务器合并。

更建议采用第二种主动通知合并的方案,因为第一种方案虽然更简单,但客户端无法得知服务器是否合并成功,无法处理一些异常情况

分片上传时也需要考虑后端架构,如果没有存储服务器,需要保证上传到同一个服务器上

断点续传

断点续传即上传进程中断,重新上传。这里的中断,包括两种情况:

  • 用户主动暂停、网络连接丢失、错误中断等软中断,此时客户端依旧保留了待上传文件的信息
  • 页面刷新的硬中断,此时客户端丢失了之前未上传完成文件的信息,也无法保证下次重传相同的文件。

第一种情况很好处理,此时客户端依旧保有上传文件信息和进度信息,继续传输未上传的分片即可。断点续传主要是针对第二种情况,客户端发起重传时,需要和服务器确定之前是否有传输过当前文件和分片传输情况,且两次重传之间文件也有被修改过的风险,需要根据文件的最后修改时间lastModify加一层校验。

所以客户端一般需要在上传前,调用前置接口传递待上传文件信息,如果之前有传输过该文件,服务端会返回已上传的分片 hash 索引,客户端根据这份索引跳过已上传的分片。

也可以客户端直接上传分片,服务器去寻找本地有无相同 hash 的分片,如果有则直接提示上传成功,提供一种秒传的假象,但从服务器负载考虑上一种方案更好

断点续传实现重点不在客户端,而在于服务器如何高效管理那些未上传完成的垃圾分片

大文件上传总结

http 协议本身并没有对 POST 数据量做限制,但服务器和浏览器自身存在限制,例如 IE:2GB Firefox:2GB Chrome:4 GB Opera:4 GB,为了绕开限制以及防止用户因中断导致的重新上传,需要选择分片+并发的方案,这过程中需要考虑一系列的调度控制方案。但我们的初衷是为了保证用户的良好上传体验,实现的重点依旧在 worker 的使用和服务器垃圾分片的管理

文件下载

浏览器为文件下载提供了多种不同的途径:

  • 通过 url 直接访问下载
  • 通过 a 标签下载
  • 通过请求的方式

url 直接下载

通过在浏览器地址栏输入资源 URL 地址,直接进行下载,我们可以在代码中通过以下方式模拟这种行为,完成下载:

window.location.href = url;
window.open(url);

这种下载方式的局限性很多,首先单文件和 url 存在一一对应的关系,我们需要了解和维护这种关系,其次文件类型仅适用于浏览器无法解析的类型,如果是诸如 html、jpg、png、pdf 等文件类型,浏览器会直接解析预览,仅适用于 GET 请求。

a 标签下载

html 的a标签通常用作超链接跳转,但可以通过download参数强制触发文件下载,且可以自定义文件名,同样仅适用于 GET 请求:

<a href={url} download={fileName}>

// 隐式写法
const download = (filename, url) => {
    let a = document.createElement('a');
    a.style = 'display: none'; // 隐藏a标签
    a.download = filename;
    a.href = url;
    document.body.appendChild(a);
    a.click(); // 触发a标签的click事件
    document.body.removeChild(a);
}

请求方式下载文件

通过xhr/fetch请求方式下载文件,可以直接获得该文件的文件流,以 fetch 请求为例,文件会以ReadableStream的形象存在于response中,我们可以读取为ArrayBuffer,也可以直接response.blob()转化为 blob 类型。但此时文件依旧没有被下载到用户本地,我们还需要进一步地处理

Content-Disposition

最简单的方式,就是在请求的时候,就告诉浏览器,我的这次请求是要下载一个文件到本地,在请求头中添加:

Content-Disposition: attachment

// 或者进一步指定文件名
Content-Disposition: attachment; filename="filename.jpg"

ObjectURL

这种方式是将Blob文件转化为ObjectURL,相当于建立了一个BlobURL的映射,再提供给a标签进行下载,需要注意在下载完成后主动URL.revokeObjectURL()释放它们,保障更好的性能,否则Blob会一直驻留在内存中

showSaveFilePicker

这个 API 仅部分浏览器提供了支持,用于将一个Blob/File文件保存到本地,但它允许用户选择本地保存的位置,以及文件名称,类似于一些桌面应用的另存为。

目前支持这一 API 的浏览器只有 Chrome,Edge,Opera

特殊场景下的文件下载

除了上面的一些常见场景,还存在一些特殊的情况,比如有时候服务器在传输文件的时候,需要实时计算生成文件,这时候并不知道文件的具体大小,需要持续分块传输。又或者我们并不需要整个文件,而是文件的某个切片,即分片传输

分块传输

分块传输的实现,是基于 http 的分块传输能力和浏览器的ReadableStream处理能力。当服务器实时生成文件返回时,就会在响应头中添加Transfer-Encoding: chunked字段,标识服务器将以分块的形式,持续传输文件,此时将不会出现Content-Length字段。

客户端拿到fetchresponse对象后就可以开始对文件流进行处理:

// ReadableStream处理函数
const processor = async reader => {
  let res = [];
  await reader.read().then(async function processRead({ done, value }) {
    if (done) {
      return res;
    }
    res = [...res, ...value];
    await reader.read().then(processRead);
  });
  return res;
};

// 发送请求
fetch(url).then(async response => {
  const reader = response.body.getReader();
  const res = await processor(reader);
  return res
}).then((arrayBuffer) => {
  const typeView = new Uint8Array(arrayBuffer);
  const blob = new Blob([typeView], {type:'文件MIME类型'})
  const url = URL.createObjectURL(blob)
  return url
}).then((url) => {
  // ...
}).catch((err) => {
  // ...
})

分块传输需要不断读取文件流,这是一个十分消耗内存的操作,所以服务器传输的文件不宜过大,及时释放内存,或者设置一些熔断机制,否则爆内存是常有的事情。

分片传输

分片传输也是 http 协议提供的能力,但在正式发起下载请求前,我们还需要发起一个请求类型为HEAD的预检请求,判断服务器是否支持分片传输和事先得知文件的Content-Length,以确定后续的分片策略。

HEAD请求的响应头如果存在accept-ranges字段,且不为none(一般为 bytes),说明当前资源文件支持分片下载。在后续的请求中添加Range请求头,通用格式为:

range: <unit>=<start>- // 没有end表示后续全部
range: <unit>=<start>-<end>
range: <unit>=<start>-<end>, <start>-<end> // 可以截取多个分片

以 bytes 字节分片为例:

 range: `bytes=${start}-${end}`

即可获取目标范围内的文件分片,此时响应的状态码是206,请求成功。

分片传输用于大文件下载

分片传输用得不多,一般会有html/text类型的文件,或者大文件下载会用到,但是大文件下载适用分片传输的条件比较苛刻,不同于文件上传分片传输,其初衷是防止用户因中断而需要不断从头重传。文件下载使用分片传输往往是为了多个链接并发下载,提高下载速度。但一个 TCP 连接其实是可以跑满用户的带宽的,并发也不过是将带宽进行了分流,整体速度并没有提升,反而本地合并文件产生的调度会消耗性能增加耗时。只有当服务器限制了单个 TCP 的带宽,且用户的网络带宽大于这个限制时,并发下载才会存在速度上的提升。

用户下载速度是由用户网络带宽和服务器带宽共同决定的