文件上传与下载全方案介绍,关键是思路要清晰
前言
文件上传和下载,挺常见的一个需求,但实现的方式却花样繁多,保持清晰思路,根据实际需求选择合适的方案很重要。这是一篇总结文,探讨了从原生支持出发的各种文件上传下载方案,如果有所遗漏或错误,欢迎评论区勘误
文件上传
前端实现文件上传,主要基于现代浏览器良好的 File API 支持,配合 http 的multipart/form-data
二进制数据传输方式,可分解为三个步骤完成简单的文件上传:
- 将待上传文件构造为 File/Blob 对象
- 构造 FormData 作为传输载体,将 File/Blob 对象添加到其中
- 构造 xhr/fetch 请求,完成上传
http 也可以使用 application/octet-stream 来进行文件的传输,表示未知文件类型的二进制数据传输,且仅能传输单文件,适用性不及 multipart/form-data
构造 File/Blob 对象
HTML5 开放了多种方法供我们选取文件,并将我们选取的文件生成 File
对象,File
是Blob
类文件对象的特殊类型,我们不需要关心文件的格式,File
会自动根据文件的二进制标识符判断对应的MIME
类型,我们有三种方式选取文件:
Input 选择框
通过type = "file"
的input
选择框,选择的文件会出现在 input 的 DomElement 的 files 属性中
拖拽选取
主要用到了 HTML5 中以下四种事件:
drop
:当拖动的元素在自身范围内释放,即松开鼠标时,触发 drop 事件。dragenter
:当拖动的元素进入自身范围时, dragenter 事件被触发。dragover
:当拖动的元素处于自身范围时,循环触发 dragover 事件(每几百毫秒触发一次)。dragleave
:当拖动的元素离开自身范围时,触发 dragleave 事件。
值得注意的是仅注册drop
事件监听是不会生效的,至少需要注册dragover
和drop
事件才能触发 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
File
和Blob
都不会将文件真正读入内存中处理,包括slice
分片,以及构造ObjectURL
等操作。但在生成MD5
索引时,我们使用了FileReader
获取了文件的ArrayBuffer
,FileReader
则会将读取的内容写入内存,尤其是读取多个大文件分片的时候,很容易内存爆掉。
比较合适的做法是文件分片时,切分大小不宜过大,且分片标记计算完成后,及时释放内存
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
,相当于建立了一个Blob
到URL
的映射,再提供给a标签
进行下载,需要注意在下载完成后主动URL.revokeObjectURL()
释放它们,保障更好的性能,否则Blob
会一直驻留在内存中
showSaveFilePicker
这个 API 仅部分浏览器提供了支持,用于将一个Blob/File
文件保存到本地,但它允许用户选择本地保存的位置,以及文件名称,类似于一些桌面应用的另存为。
目前支持这一 API 的浏览器只有 Chrome,Edge,Opera
特殊场景下的文件下载
除了上面的一些常见场景,还存在一些特殊的情况,比如有时候服务器在传输文件的时候,需要实时计算生成文件,这时候并不知道文件的具体大小,需要持续分块传输。又或者我们并不需要整个文件,而是文件的某个切片,即分片传输。
分块传输
分块传输的实现,是基于 http 的分块传输能力和浏览器的ReadableStream
处理能力。当服务器实时生成文件返回时,就会在响应头中添加Transfer-Encoding: chunked
字段,标识服务器将以分块的形式,持续传输文件,此时将不会出现Content-Length
字段。
客户端拿到fetch
的response
对象后就可以开始对文件流进行处理:
// 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 的带宽,且用户的网络带宽大于这个限制时,并发下载才会存在速度上的提升。
用户下载速度是由用户网络带宽和服务器带宽共同决定的
转载自:https://juejin.cn/post/7208817904560406589