多文件断点续传、分片上传、秒传、重试机制

9,051 阅读5分钟

文章已更新,请各位移步至:juejin.cn/post/685003…

目前这篇就先留着吧,这是初始版本,方便各位查阅。

在掘金白嫖许久,想来应该回馈下,为了可以继续白嫖😁

背景

很早之前就在掘金看到过关于实现断点续传的文章,但未曾实践过,正好最近项目中也遇到了此场景,便去重温了一遍,从头到底做了实现。

技术栈:VUE+Elementui+localstorage+Worker

寄语:本人并非"巨人",只是有幸站在了巨人的肩膀

请先阅读以下博文:

juejin.cn/post/684490… juejin.cn/post/684490…

总结

先写写总结,个人认为,本项目主要难点在于处理多个文件上传时,如何将每个文件的状态及进度对应到相关的界面展示中。绕了很多坑。

做到重试时,卡了半天,实在想不明白,最后去厕所呆了会,然后就很快写出来了。

注意:以下代码算是伪代码,不建议直接使用到项目中,只是方便讲解逻辑。正式代码,我需要再次整理下,本周会更新的。为了涨粉,先发布文章吧,哈哈。

思路

  • 文件上传逻辑:遍历所选文件-》创建切片-》计算HASH-》请求文件校验接口,是否已存在该文件-》若存在-》结束-》若不存在,进行切片上传-》-请求合并接口-》结束
  • 断点续传逻辑:断点续传一般有两种方式,一为服务器存储切片数,优点是用户换个浏览器也能续传。二为浏览器端存储:似乎没啥特别的优点,实现起来简单些吧,缺点倒是挺多,不巧的是本项目就是在浏览器端存储切片。哈哈哈
    • 思路:存储已上传的切片到localstorage,以hash为key值->每次运行前,先判断缓存中是否存在,存在的话,跳过已上传的即可。

详细思路各位可参考上面2篇文章,不再赘述!

前端部分

//简单粗暴
<input type="file" multiple @change="handleFileChange" />
<el-button @click="handleUpload">上传</el-button>
<el-button @click="handleResume">恢复</el-button>
<el-button @click="handlePause">暂停</el-button>

//js
const SIZE = 50 * 1024 * 1024; // 切片大小, 1M
var fileIndex = 0; // 当前正在被遍历的文件下标

export default {
  name: 'SimpleUploaderContainer',
  data: () => ({
    container: {
      hashArr: [], // 存储已计算完成的hash
      data: []
    },
    tempFilesArr: [], // 存储files信息
    uploadMax: 3, // 上传时最大切片的个数,
    cancels: [] // 存储要取消的请求
  })
  }
  • handleFileChange方法实现,因fileList对象为只读属性,所以需要需要拷贝一份filelist数据
handleFileChange(e) {
  const files = e.target.files;
  console.log('handleFileChange -> file', files);
  if (!files) return;
  Object.assign(this.$data, this.$options.data()); // 重置data所有数据
  fileIndex = 0; // 重置文件下标

  this.container.files = files;
  // 拷贝filelist 对象
  for (const key in this.container.files) {
    if (this.container.files.hasOwnProperty(key)) {
      const file = this.container.files[key];
      var obj = { statusStr: '正在上传', chunkList: [], uploadProgress: 0, hashProgress: 0 };
      for (const k in file) {
        obj[k] = file[k];
      }
      this.tempFilesArr.push(obj);
    }
  }
}
  • 文件上传

    创建切片-》-》计算HASH-》判断是否为秒传-》上传切片-》存储已上传的切片下标。 hash计算方式也是通过worker处理。

    async handleUpload() {
     if (!this.container.files) return;
     const filesArr = this.container.files;
     var tempFilesArr = this.tempFilesArr;

     console.log('handleUpload -> filesArr', filesArr);
     for (let i = 0; i < filesArr.length; i++) {
       fileIndex = i;
       const fileChunkList = this.createFileChunk(filesArr[i]);
       // hash校验,是否为秒传
       const hash = await this.calculateHash(fileChunkList, filesArr[i].name);
       console.log('handleUpload -> hash', hash);
       const verifyRes = await this.verifyUpload(filesArr[i].name, hash);
       if (!verifyRes.data.presence) {
         this.$message('秒传');
         tempFilesArr[i].statusStr = '已秒传';
         tempFilesArr[i].uploadProgress = 100;
       } else {
         console.log('开始上传文件----》', filesArr[i].name);
         const getChunkStorage = this.getChunkStorage(hash);
         tempFilesArr[i].fileHash = hash; // 文件的hash,合并时使用
         tempFilesArr[i].chunkList = fileChunkList.map(({ file }, index) => ({
           fileHash: hash,
           fileName: filesArr[i].name,
           index,
           hash: hash + '-' + index,
           chunk: file,
           size: file.size,
           uploaded: getChunkStorage && getChunkStorage.includes(index), // 标识:是否已完成上传
           progress: getChunkStorage && getChunkStorage.includes(index) ? 100 : 0,
           status: getChunkStorage && getChunkStorage.includes(index) ? 'success' : 'wait' // 上传状态,用作进度状态显示
         }));

         console.log('handleUpload ->  this.chunkData', tempFilesArr[i]);

         await this.uploadChunks(this.tempFilesArr[i]);
       }
     }
   }
   
   // 创建文件切片
   createFileChunk(file, size = SIZE) {
     const fileChunkList = [];
     var count = 0;
     while (count < file.size) {
       fileChunkList.push({
         file: file.slice(count, count + size)
       });
       count += size;
     }
     return fileChunkList;
   }
   
   
     // 存储已上传完成的切片下标
   addChunkStorage(name, index) {
     const data = [index];
     const arr = getObjArr(name);
     if (arr) {
       saveObjArr(name, [...arr, ...data]);
     } else {
       saveObjArr(name, data);
     }
   },
   // 获取已上传完成的切片下标
   getChunkStorage(name) {
     return getObjArr(name);
   }
   
   // 生成文件 hash(web-worker)
   calculateHash(fileChunkList, name) {
     return new Promise((resolve) => {
       this.container.worker = new Worker('./hash/md5.js');
       this.container.worker.postMessage({ fileChunkList });
       this.container.worker.onmessage = (e) => {
         const { percentage, hash } = e.data;
         //当时想将每个文件的hash放在同一个节目展示,所以就存在了一个数组里
         this.tempFilesArr[fileIndex].hashProgress = percentage;
         if (hash) {
           resolve(hash);
         }
       };
     });
   }
   

uploadChunks 上传切片方法,这个当时想了1天,才做出来

 // 将切片传输给服务端
   async uploadChunks(data) {
     var chunkData = data.chunkList;
     const requestDataList = chunkData
       .filter(({ uploaded }) => !uploaded)
       .map(({ fileHash, chunk, fileName, index }) => {
         const formData = new FormData();
         formData.append('md5', fileHash);
         formData.append('file', chunk);
         formData.append('fileName', index);
         return { formData, index, fileName };
       });

     try {
       const ret = await this.sendRequest(requestDataList, chunkData);
       console.log('uploadChunks -> chunkData', chunkData);
       console.log('ret', ret);
       data.statusStr = '上传成功';
     } catch (error) {
       // 上传有被reject的
       data.statusStr = '上传失败,请重试';
       this.$message.error('亲 上传失败了,考虑重试下呦');
       return;
     }

     // 合并切片
     const isUpload = chunkData.some((item) => item.uploaded === false);
     console.log('created -> isUpload', isUpload);
     if (isUpload) {
       alert('存在失败的切片');
     } else {
       // 执行合并
       await this.mergeRequest(data);
     }

sendRequest 并发上传切片+重试机制

重试机制参考的也是上面博文:

  • 请求出错将失败的任务放到队列中
  • 数组存储每个文件hash请求的重试次数,做累加,比如[1,0,2],就是第0个文件切片报错1次,第2个报错2次。超过3次停止上传,抛出失败

并发:通过for循环控制起始值,在函数体内进行递归调用,便达到了并发的效果。

    // 并发处理
   sendRequest(forms, chunkData) {
     console.log('sendRequest -> forms', forms);
     console.log('sendRequest -> chunkData', chunkData);
     var finished = 0;
     const total = forms.length;
     const that = this;
     const retryArr = []; // 数组存储每个文件hash请求的重试次数,做累加 比如[1,0,2],就是第0个文件切片报错1次,第2个报错2次

     return new Promise((resolve, reject) => {
       const handler = () => {
         console.log('handler -> forms', forms);
         if (forms.length) {
           // 出栈
           const formInfo = forms.shift();
           const formData = formInfo.formData;
           const index = formInfo.index;

           instance
             .post('fileChunk', formData, {
               onUploadProgress: that.createProgresshandler(chunkData[index]),
               cancelToken: new CancelToken((c) => this.cancels.push(c)),
               timeout: 0
             })
             .then((res) => {
               console.log('handler -> res', res);
               // 更改状态
               chunkData[index].uploaded = true;
               chunkData[index].status = 'success';

               // 存储已上传的切片下标
               this.addChunkStorage(chunkData[index].fileHash, index);

               finished++;
               handler();
             })
             .catch((e) => {
               console.warn('出现错误', e);
               console.log('handler -> retryArr', retryArr);
               if (typeof retryArr[index] !== 'number') {
                 retryArr[index] = 0;
               }

               // 更新状态
               chunkData[index].status = 'warning';

               // 累加错误次数
               retryArr[index]++;

               // 重试3次
               if (retryArr[index] >= 3) {
                 console.warn(' 重试失败--- > handler -> retryArr', retryArr, chunkData[index].hash);
                 return reject('重试失败', retryArr);
               }

               console.log('handler -> retryArr[finished]', `${chunkData[index].hash}--进行第 ${retryArr[index]} '次重试'`);
               console.log(retryArr);

               this.uploadMax++; // 释放当前占用的通道

               // 将失败的重新加入队列
               forms.push(formInfo);
               handler();
             });
         }

         console.log('handler -> total', total);
         console.log('handler -> finished', finished);

         if (finished >= total) {
           resolve('done');
         }
       };

       // 控制并发
       for (let i = 0; i < this.uploadMax; i++) {
         handler();
       }
     });
   }

进度处理

// 切片上传进度
createProgresshandler(item) {
 return (p) => {
   item.progress = parseInt(String((p.loaded / p.total) * 100));
   this.fileProgress();
 };
}
   
// 文件总进度
fileProgress() {
//通过全局变量 fileIndex 定位当前正在传输的文件。
 const currentFile = this.tempFilesArr[fileIndex];
 const uploadProgress = currentFile.chunkList.map((item) => item.size * item.progress).reduce((acc, cur) => acc + cur);
 const currentFileProgress = parseInt((uploadProgress / currentFile.size).toFixed(2));
 currentFile.uploadProgress = currentFileProgress;
}

mergeRequest 合并切片

mergeRequest(data) {
     const obj = {
       md5: data.fileHash,
       fileName: data.name,
       fileChunkNum: data.chunkList.length
     };

     instance.post('fileChunk/merge', obj, 
       {
         timeout: 0
       })
       .then((res) => {
         // 清除storage
         clearLocalStorage(data.fileHash);
         this.$message.success('上传成功');
       });
   }

handlePause 暂停

本项目使用的是axios,暂停的关键就是取消当前的请求,axios也提供了方法,我们需要简单处理下。

 handlePause() {
     while (this.cancels.length > 0) {
       this.cancels.pop()('取消请求');
     }
   }
   
//在并发请求除,存储了当前正在传输的请求,调用每个请求的cancels方法即可

![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2020/7/7/17329dbc5014e7df~tplv-t2oaga2asx-image.image)