各种文件上传的场景,梳理一下下叭

上传原理

简单点说就是根据 httpmultipart/form-data 协议,浏览器解析文件并打包成二进制数据流,然后再通过 http 把数据流传到服务端
multipart/form-data 结构如下:
请求头

  1. Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDCntfiXcSkPhS4PN

消息体 - Form Data

  1. ------WebKitFormBoundary4Ze9aQ5NKoUihnkZ
  2. Content-Disposition: form-data; name="file"; filename="哟哟哟.txt"
  3. Content-Type: text/plain
  4. ------WebKitFormBoundary4Ze9aQ5NKoUihnkZ

原始的文件上传

使用 form 表单上传

  1. <form method="post" action="http://localhost:8100" enctype="multipart/form-data">
  2. <input type="file" name="f1" />
  3. <input type="file" name="f1" multiple /> // 多文件上传可以设置 multiple 属性
  4. <button type="submit" id="btn-0">上传</button>
  5. </form>

局部刷新上传 - iframe

最原始的上传方式,会导致页面刷新,因此需要借用 iframe 实现表面上不刷新页面上传

局部刷新

页面内放置了一个隐藏的 iframe,指定 form 表单的 target 属性值为 iframe 标签的 name 属性值,这样 form 表单的 submit 行为的跳转就会在 iframe 内完成,整体页面不会刷新

拿接口数据

为 iframe 添加 load 事件,得到 iframe 的页面内容,将结果转换为 JSON 对象,这样就拿到了接口的数据

  1. <iframe id="temp-iframe" name="temp-iframe" src="" style="display:none;"></iframe>
  2. <form method="post" target="temp-iframe" action="http://localhost:8100" enctype="multipart/form-data">
  3. <input type="file" name="f1" id="f1" multiple />
  4. <button type="submit" id="btn-0">上传</button>
  5. </form>
  6. <script>
  7. var iframe = document.getElementById('temp-iframe');
  8. iframe.addEventListener('load', function () {
  9. var result = iframe.contentWindow.document.body.innerText;
  10. //接口数据转换为 JSON 对象
  11. var obj = JSON.parse(result);
  12. if (obj && obj.fileUrl.length) {
  13. alert('上传成功')
  14. }
  15. console.log(obj);
  16. });
  17. </script>

无刷新上传

可以自己构造一个 FormData 对象,然后使用 XMLHttpRequestfetch 发送数据,这里用我们常用的 XMLHttpRequestaxios举个例子

XMLHttpRequest

  1. const fileList = document.getElementById('file').files;
  2. const formData = new FormData();
  3. // 多文件上传需要遍历添加到 fromdata 对象
  4. for(let i =0;i<fileList.length;i++){
  5. fd.append('file', fileList[i]);
  6. }
  7. const xhr = new XMLHttpRequest(); // 创建 XMLHttpRequest 对象
  8. xhr.open('POST', 'http://localhost:8100/', true);
  9. xhr.send(fd); // 发送时 Content-Type 默认就是: multipart/form-data;
  10. xhr.onreadystatechange = function () {
  11. if (this.readyState == 4 && this.status == 200) {
  12. const obj = JSON.parse(xhr.responseText); // 返回值
  13. console.log(obj);
  14. }
  15. }

Axios

  1. const handleChange = (e) => {
  2. // 获取文件
  3. const files = e.target.files;
  4. const formData = new FormData();
  5. formData.append('file', files[0]); // 这里就上传一个文件,多个文件的话可以 append 多个
  6. axios({
  7. method: 'post',
  8. url: '/file/upload',
  9. data: formData,
  10. header: {
  11. 'content-type': 'multipart/form-data;charset-UTF-8',
  12. }
  13. });
  14. }
  15. const dom = (
  16. <input
  17. ref={uploadRef}
  18. style={{display: 'none'}}
  19. onChange={handleChange}
  20. type={'file'}
  21. accept={'.xlsx,.xls,.csv'}
  22. >
  23. )

上传进度

主要是通过绑定 onprogress 事件实现,主要是以下两种方式:

  1. const setProgress = (event) => {
  2. const {lengthComputable, loaded, total} = event;
  3. }
  4. // 第一种
  5. xhr.upload.onprogress = setProgress
  6. // 第二种
  7. xhr.upload.addEventListener('progress', setProgress)

其中 progress 的回调函数中,有这几个常用的参数:

  • event.lengthComputable这是一个状态,表示发送的长度有了变化,可计
  • event.loaded表示发送了多少字节
  • event.total 表示文件总大小

其中根据 loadedtotal 就能计算上传的进度了,具体代码如下:

  1. const fileList = document.getElementById('file').files;
  2. const formData = new FormData();
  3. // 多文件上传需要遍历添加到 fromdata 对象
  4. for(let i =0;i<fileList.length;i++){
  5. fd.append('file', fileList[i]);
  6. }
  7. const xhr = new XMLHttpRequest(); // 创建 XMLHttpRequest 对象
  8. xhr.open('POST', 'http://localhost:8100/', true);
  9. xhr.onreadystatechange = function () {
  10. if (this.readyState == 4 && this.status == 200) {
  11. const obj = JSON.parse(xhr.responseText); // 返回值
  12. console.log(obj);
  13. }
  14. }
  15. const setProgress = (event) => {
  16. if (event.lengthComputable) {
  17. const progress = (event.loaded / event.total * 100).toFixed(2);
  18. // 更新进度状态 && 进度条样式等等逻辑
  19. }
  20. }
  21. xhr.upload.onprogress = setProgress
  22. // 注意 send 一定要写在最下面,否则 onprogress 只会执行最后一次 也就是100%的时候
  23. xhr.send(fd);

PS:假如使用的是 axios,有对外暴露一个 onUploadProgress API,具体可自行查看文档

其他上传文件方式

拖拽上传

  1. 首先定义一个拖放的区域 div
  2. 再在 div 监听 drop 事件,获取文件信息 e.dataTransfer.files(记得取消默认行为和冒泡,否则浏览器会直接打开文件)
  3. div 上 dragover 事件也需要取消默认行为

    剪切板上传

    一般常在可编辑的 div 中使用,即:开启 contenteditable 的 div

  4. 首先需要为 div 绑定 paste 事件,并从 event.clipboardData 或 window.clipboardData 获取文件数据

  5. 这里拿到的数据,需要再使用 getAsFile 方法转换后,才是文件对象数据
  6. 最后再实现在光标位置插入文件 ```javascript const div = document.getElementById(‘div’);

// 绑定paste事件 div.addEventListener(‘paste’, function (event) { const data = (event.clipboardData || window.clipboardData);

const items = data.items; const fileList = [];//存储文件数据

if (items && items.length) { for (let i = 0; i < items.length; i++) { fileList.push(items[i].getAsFile()); } } uploadFile(fileList) event.preventDefault(); // 阻止默认行为 });

function uploadFile(fileList) { // 这里省略上传文件逻辑

// 光标位置插入文件,举一个图片的例子 const img = document.createElement(‘img’); img.src = ‘xxx’;

insertElement(div, img) }

// 光标位置插入文件 function insertElement(div, ele) { const node = window.getSelection().anchorNode; // 光标起点位置

// 判断是否有光标 if (node != null) { const range = window.getSelection().getRangeAt(0);// 获取光标起始位置 range.insertNode(ele);// 在光标位置插入该对象 } else { div.append(ele); } }

  1. <a name="jUWDZ"></a>
  2. ### 大文件-分片上传
  3. 如果上传的文件很大,比如几百兆,甚至几 G 的话,用前面常规的方法可能就会导致请求超时,导致无法成功上传文件,因此我们可以采用将文件分片的方式,比如每次只上传很小的一部分,比如 2M,而且可以支持多个并发进行分片上传,提升上传的速度
  4. 原理说明:<br />首先了解一下 `Blob` 对象,它表示了一个不可变的原始数据,也就是二进制数据,它提供了对数据进行截取的方法 `slice`,然而我们的 `File` 对象继承了 `Blob` 对象,因此也支持对文件进行截取
  5. 具体流程:
  6. 1. 前端将大文件进行分片,比如每部分 2M,发送到服务端携带一个标志(可以使用文件的 md5),用来标识一个完整的文件
  7. 2. 服务端将各个分片文件保存
  8. 3. 前端等所有分片上传完成后,发送给服务端一个合并文件的请求
  9. 4. 服务端根据文件标识、类型、各分片顺序进行文件合并
  10. 5. 删除分片文件
  11. ```javascript
  12. const chunkSize = 2 * 1024 * 1024; // 分片大小 2M
  13. const file = document.getElementById('file').files[0]; // 文件对象
  14. const md5 = getMd5(file); // 可使用 SparkMD5 获取
  15. const chunks = [], // 保存分片数据
  16. const chunkCount = 0;
  17. const sendChunkCount = 0; // 发送完成的分片数
  18. // 文件分片
  19. if(file.size > chunkSize){
  20. let start=0,end=0;
  21. while (true) {
  22. end += chunkSize;
  23. const blob = file.slice(start, end);
  24. start+=chunkSize;
  25. if(!blob.size) {// 截取的数据为空 则结束
  26. break;
  27. }
  28. chunks.push(blob);//保存分段数据
  29. }
  30. } else {
  31. chunks.push(file.slice(0));
  32. }
  33. // 发送分片文件(数量大的话,需要做并发控制,设置最大同时发请求的数量)
  34. for(let i = 0;i < chunks.length; i++) {
  35. const fd = new FormData();
  36. fd.append('token', md5);
  37. fd.append('file', chunks[i]);
  38. fd.append('index', i);
  39. // 发送请求
  40. xhrSend(fd, () => {
  41. sendChunkCount+=1;
  42. if(sendChunkCount=== chunks.length){
  43. //上传完成,发送合并请求
  44. const fdMerge = new FormData();
  45. fdMerge.append('type','merge');
  46. fdMerge.append('token',token);
  47. fdMerge.append('chunkCount',chunks.length);
  48. fdMerge.append('filename',name);
  49. xhrSend(formD);
  50. }
  51. });
  52. }
  53. // 带回调函数的请求方法
  54. function xhrSend(fd, cb) {
  55. const xhr = new XMLHttpRequest();
  56. xhr.open('POST', 'http://localhost:8100/', true);
  57. xhr.onreadystatechange = function () {
  58. if (xhr.readyState == 4) {
  59. cb && cb();
  60. }
  61. }
  62. xhr.send(fd);
  63. }

大文件-断点续传

分片上传解决了大文件上传超时和文件上传速度的问题,但假如上传期间断网或者刷新页面了,就需要重新上传,难受,因此就有了断点续传的需求,可以跳过已经上传的部分,只传未上传的部分

实现方式可以有两种

  • 浏览器端自行处理
  • 服务端返回,告知从哪开始

浏览器端自行处理思路:文件分片上传中,已经实现在将文件进行分片,现在问题就是,断点续传时,如何检测每个分片是否已经上传完成呢?

  1. 首先,可以使用 spark-md5 来针对每一个分片的文件,生成唯一的 hash
  2. hash 值作为 key,将上传成功的分片信息保存在浏览器缓存中
  3. 重新上传时,和本地分片 hash 值进行对比,如果相同的话则跳过,继续下一个分片的上传 ```javascript // 上传逻辑,支持断点续传 const uploadedInfo = getUploadedFromStorage(); // 获得已上传的分片信息

for(let i = 0; i < chunkList.length; i++) { const hash = chunkList[i].hash; if (uploadedInfo[hash]) { // 判断该分片已完成上传 continue; }

// 。。。。省略上传的逻辑 var fd = new FormData(); //构造FormData对象 xhrSend(fd, (file) => { // 回调函数 // 上传完成一段分片后,缓存信息 setUploadedToStorage(file.hash); }) }

// 获得本地缓存的数据 function getUploadedFromStorage(){ return JSON.parse(localStorage.getItem(‘cacheChunk’) || “{}”); }

// 写入缓存 function setUploadedToStorage(hash) { const obj = getUploadedFromStorage();

obj[hash] = true;
localStorage.setItem(‘cacheChunk’, JSON.stringify(obj)); }

```

参考: