vue-大文件分片及断点上传

最近开发过程中,有一个大文件分片上传的功能,借鉴于网上的思路,结合自己后端的逻辑,完成了这个功能,在此记录如下:

界面展示:
在这里插入图片描述

在这里插入图片描述

一、将大文件分片上传写为一个组件,可以全局注册该组件,也可以在使用的页面注册该组件,使用vuex通讯进行组件间传值

由于我有好几个页面需要使用大文件上传,所以我是在App.vue注册该组件

<template>
  <a-config-provider :locale="locale">
    <div id="app">
      <router-view />
      <!-- 将分片上传组件全局注册 -->
      <big-uploader></big-uploader>
    </div>
  </a-config-provider>
</template>
<script>
...
import BigUploader from './components/bigUploader.vue';
export default {
  data () {
    return {
      ...
    };
  },
  components: { BigUploader },
  ...
};
</script>

二、文件选择上传框通过文件上传前的钩子,会有项目的业务逻辑判断,校验文件空间是否还有,判断文件是否为重复文件,提示是覆盖还是重新上传,校验都通过后就去调起大文件上传的组件

beforeUpload(file) {
      const self = this
      let size = file.size
      let params = {
        fileSize: size,
        projectCode: self.projectCode
      }
      if (size === 0) {
        this.$message.error('上传文件不能为0KB')
        return false
      }
      const fileName = file.name
      let fileType = fileName.split('.')
      fileType = fileType[fileType.length - 1].toLowerCase()//转小写
      if (this.accept.indexOf(fileType) == -1) {
        this.$message.error('请上传指定文件类型')
        return false;
      }
      if (this.uploadFlag) return false;
      this.uploadFlag = true
      return new Promise(() => {
        try {
          // 一、校验文件空间
          request(xxx).then(res => {
            if (res.code === 200) {
              // 二、计算MD5
              self.calculate(file, async function (md5) {
                const par = {
                  fileName: file.name,
                  md5Value: md5,
                  parantCode: self.parentCode,
                  projectCode: self.projectCode
                }
                // 三、文件覆盖检查
                request(xxx).then(res => {
                  console.log(res)
                  if (res.code === 200 && res.data) {
                    //同名同类型提示是否覆盖
                    self.$confirm({
                      title: false,
                      content: '该文件夹下存在同名同类型的文件,是否确认覆盖?',
                      onOk() {
                        // 覆盖
                        self.implementSetFile(file, md5)
                      },
                      onCancel() {
                        self.handelModelCancal();
                      },
                    });
                  } else {
                    // 新文件
                    self.implementSetFile(file, md5)
                  }
                  return false
                })
              })
            }
          }).catch(() => {
            self.uploadFlag = false
            return false;
          })
        }
        catch {
          self.uploadFlag = false
          return false;
        }
      })
    }, 
implementSetFile(file, md5) {
      // 设置当前上传文件
      this.setFile({
        page: this.curMenuName.name,
        bucket: 'privately',
        projectCode: this.projectCode,
        parentCode: this.parentCode,
        record: this.record,
        file: file,
        md5: md5
      })
      // 设置显示上传框
      this.setShowBigUpload(true)
      // this.$emit('ok');
      this.uploadFlag = false
      this.$emit('closeUpload')
    },
    /**
     * 计算md5,实现断点续传及秒传
     * @param file
     */
    calculate(file, callBack) {
      var fileReader = new FileReader(),
        blobSlice = File.prototype.mozSlice || File.prototype.webkitSlice || File.prototype.slice,
        chunkSize = 2097152,
        // read in chunks of 2MB  
        chunks = Math.ceil(file.size / chunkSize),
        currentChunk = 0,
        spark = new SparkMD5();

      fileReader.onload = function (e) {
        spark.appendBinary(e.target.result); // append binary string  
        currentChunk++;

        if (currentChunk < chunks) {
          loadNext();
        }
        else {
          callBack(spark.end());
        }
      };

      function loadNext() {
        var start = currentChunk * chunkSize,
          end = start + chunkSize >= file.size ? file.size : start + chunkSize;

        fileReader.readAsBinaryString(blobSlice.call(file, start, end));
      }

      loadNext();
    }, 

三、大文件组件

data中定义当前上传下标,上传并发数,允许每个分片最多允许重传数,上传文件列队数组等值

通过mapGetters获取vuex值

...mapGetters({
      showUploadBox: `${NAMESPACE_UPLOAD}/${GET_SHOW_BIG_UPLOAD}`,
      file: `${NAMESPACE_UPLOAD}/${GET_FILE}`,
      chunkSize: `${NAMESPACE_UPLOAD}/${GET_CHUNKSIZE}`,
      threads: `${NAMESPACE_UPLOAD}/${GET_THREADS}`,
      getUplodRes: `${NAMESPACE_UPLOAD}/${GET_UPLOAD_RES}`,
    }),

通过监听file变化,触发大文件上传排队

watch: {
    file: {
      immediate: true,
      handler: function (newVal) {
        if (newVal) {
          const tempMd5 = newVal.md5
          // 判断文件是否在排队中
          if (this.upFileObj[tempMd5]) {
            this.$message.warn('当前文件已经在上传排队中,请不要重复选择')
          } else {
            newVal.fileIndex = this.uploadFiles.length
            this.upFileObj[tempMd5] = newVal
            this.uploadQueue(tempMd5)

          }

        }
      }
    }
  }

通过uploadQueue组件上传排队当前文件的分片,通过md5校验资源是否已经存在,记录资源是否存在和是否需要上传

/*
    * 上传排队
    * 计算当前文件的Md5和分片
    */
    uploadQueue(md5) {
      console.log('1.当前文件上传排队')
      const self = this;
      const tempFile = self.upFileObj[md5]
      const fileName = tempFile.file.name
      const curFileSize = tempFile.file.size
      self.handleInitRawFile(tempFile);
      // 检验资源是否存在
      request(xxx).then(res => {
        if (res.code === 200) {
          var resData = res.data
          const fileIndex = tempFile.fileIndex
          const filesArr = self.uploadFiles;
          if (resData) {
            // 资源已经存在
            console.log('3.上传文件已经存在', resData)
            filesArr[fileIndex].Already = true
            filesArr[fileIndex].resData = resData
            self.$set(filesArr, fileIndex, filesArr[fileIndex]);
          } else {
            // 需要上传
            let needChunk = false
            // 不需要分片
            if (curFileSize < self.chunkSize) {
              needChunk = false
            } else {
              needChunk = true
            }
            filesArr[fileIndex].needChunk = needChunk
            self.$set(filesArr, fileIndex, filesArr[fileIndex]);
          }
          // 判断是否正在上传中
          if (self.uploadLock) {
            console.log('有文件正在上传中...')
          } else {
            self.beforeUpload()
          }
        }
      })
    },
    /*
    ** 初始化部分自定义上传属性
    */
    handleInitRawFile(rawFile) {
      console.log('2.初始化部分自定义上传属性')
      rawFile.status = fileStatus.md5;
      rawFile.initFail = this.file
      rawFile.chunkList = [];
      rawFile.uploadProgress = 0;
      rawFile.fakeUploadProgress = 0; // 假进度条,处理恢复上传后,进度条后移的问题
      rawFile.hashProgress = 0;
      this.uploadFiles.push(rawFile);
    },

文件正式上传前,判断资源是否存在直接秒传,是否需要上传,上传的话是否需要进行分片,秒传和不需要分片都是简单的业务逻辑,下面展示一下主要的分片代码

需要分片,组件分片上传数组

/*
    ** 开始组建上传数组
    */
    async handleUpload() {
      console.log('6.开始组建上传数组')
      if (!this.uploadLock) return;
      const filesArr = this.uploadFiles;
      const fileUpIdx = this.fileUpIdx
      const fileChunkList = this.createFileChunk(filesArr[fileUpIdx].file);
      if (filesArr[fileUpIdx].status !== 'resume') {
        this.status = Status.hash;
        // hash校验,是否为秒传
        filesArr[fileUpIdx].hash = await this.calculateHash(fileChunkList);
        // 若清空或者状态为等待,则跳出循环
        if (this.status === Status.wait) {
          console.log('若清空或者状态为等待,则跳出循环');
          return
        }
      }

      this.status = Status.uploading;
      filesArr[fileUpIdx].status = fileStatus.uploading;
      filesArr[fileUpIdx].fileHash = filesArr[fileUpIdx].hash; // 文件的hash,合并时使用
      filesArr[fileUpIdx].chunkList = fileChunkList.map(({ file }, index) => ({
        fileHash: filesArr[fileUpIdx].hash,
        fileName: filesArr[fileUpIdx].file.name,
        index,
        hash: filesArr[fileUpIdx].hash + '-' + index,
        chunk: file,
        size: file.size,
        uploaded: false, // 标识:是否已完成上传
        progress: 0,
        status: 'wait' // 上传状态,用作进度状态显示
      }));
      this.$set(filesArr, fileUpIdx, filesArr[fileUpIdx]);
      ...
    },

分片-初始化任务的时候,将分片数组及文件md5传给后端,后端返回该文件上传的任务id,及没有上传的片段数组及小片段id,已经上传的片段,实现秒传效果,这里就是断点续传的主要逻辑思路

initJob() {
      console.log('7.分片-初始化任务')
      const filesArr = this.uploadFiles;
      const fileUpIdx = this.fileUpIdx
      const uploadFileMd5 = filesArr[fileUpIdx].md5
      let detailList = []
      filesArr[fileUpIdx].chunkList.forEach((item, idx) => {
        detailList.push({
          extInfo: '',
          // file: item.chunk,
          num: idx
        })
      })
      const params = {
        md5HashValue: uploadFileMd5,
        detailList: detailList
      }
      request(xxx).then(res => {
        console.log('分片-初始化任务', res)
        if (res.code === 200) {
          const resData = res.data
          if (resData.jobStatus === 1) {
            console.log('上传文件已经存在', resData)
          } else {
            let sliceList = []
            this.jobCode = resData.jobCode
            resData.sliceList.forEach(item => {
              const tt = {
                ...item,
                ...filesArr[fileUpIdx].chunkList[item.num]
              }
              sliceList.push(tt)
            })
            // this.sliceList = sliceList
            filesArr[fileUpIdx].chunkList = sliceList
            this.$set(filesArr, fileUpIdx, filesArr[fileUpIdx]);
            //没有上传的分片
            let noUpSlice = [], yesUpSlice = [];
            sliceList.filter((item, idx) => {
              item.num = idx
              if (item.uploadStatus === 0) {
                noUpSlice.push({ ...item });
              } else {
                yesUpSlice.push({ ...item });
              }
            })
            // const noUpSlice = sliceList.filter(({ uploadStatus }) => uploadStatus === 0)
            console.log('没有上传的分片', noUpSlice)
            if (noUpSlice.length === 0) {
              this.mergeRequest();
            } else {
              if (yesUpSlice.length > 0) {
                yesUpSlice.forEach(item => {
                  this.createProgresshandler(100, item.num)
                })
              }
              this.uploadChunks(noUpSlice, sliceList);
            }
          }
        }
      })
    },

将切片传输给服务端,进行并发上传处理,所有的分片上传完成,向服务端进行合并请求

async uploadChunks(data, allData) {
      console.log('8.将切片传输给服务端')
      return new Promise(async (resolve, reject) => {
        const requestDataList = data.map(({ sliceCode, num, chunk }) => {
          const formData = new FormData();
          formData.append('sliceCode', sliceCode);
          formData.append('file', chunk);
          return { formData, num };
        })
        try {
          const ret = this.sendRequest(requestDataList, data, allData);
        } catch (error) {
          // 上传有被reject的
          this.$message.error('亲 上传失败了,考虑重试下呦' + error);
          return;
        }
        // 合并切片
        const isUpload = data.some((item) => item.uploadStatus === 0);
        if (!isUpload) {
          // 执行合并
          try {
            await this.mergeRequest();
            resolve();
          } catch (error) {
            reject();
          }
        }
      });
    },
sendRequest(forms, chunkData, allData) {
      console.log('9.分片-并发上传处理')
      var finished = 0;
      const total = forms.length;
      const that = this;
      const retryArr = []; // 数组存储每个文件hash请求的重试次数,做累加 比如[1,0,2],就是第0个文件切片报错1次,第2个报错2次
      // const md5Hash = allData[0].fileHash
      const filesArr = this.uploadFiles;
      const fileUpIdx = this.fileUpIdx
      const md5Hash = filesArr[fileUpIdx].md5

      return new Promise((resolve, reject) => {
        const handler = () => {
          if (forms.length) {
            // 出栈
            const formInfo = forms.shift();
            const formData = formInfo.formData;
            const index = formInfo.num;
            /*
            ***  开始分片上传
            */
            // console.log('当前分片上传', allData[index])
            if (allData[index]) {
              request(xxx)
                .then(res => {
                  // 更改状态
                  allData[index].uploaded = true;
                  allData[index].uploadStatus = 1;
                  allData[index].status = 'success';
                  finished++;
                  if (finished === chunkData.length) {
                    // 执行合并
                    this.mergeRequest();
                  }
                  handler();
                }).catch((e) => {
                  // 若状态为暂停或等待,则禁止重试
                  if ([Status.pause, Status.wait].includes(this.status)) return;
                  console.warn('出现错误', e);
                  console.log('当前分片上传报错', retryArr);
                  if (typeof retryArr[index] !== 'number') {
                    retryArr[index] = 0;
                  }
                  // 更新状态
                  allData[index].status = 'warning';
                  // 累加错误次数
                  retryArr[index]++;
                  // 重试3次
                  if (retryArr[index] >= this.chunkRetry) {
                    console.warn(' 重试失败--- > handler -> retryArr', retryArr, allData[index].hash);
                    return reject('重试失败', retryArr);
                  }
                  // console.log('handler -> retryArr[finished]', `${allData[index].hash}--进行第 ${retryArr[index]} '次重试'`);
                  // console.log(retryArr);

                  this.tempThreads++; // 释放当前占用的通道
                  // 将失败的重新加入队列
                  forms.push(formInfo);
                  handler();
                })
            }
          }
          if (finished >= total) {
            resolve('done');
          }
        };
        // 控制并发
        for (let i = 0; i < this.tempThreads; i++) {
          handler();
        }
      });
    },

通知服务端合并切片,设置总的文件进度,并设置上传结果,回传给页面展示新增文件,进行下一步业务操作

在此大文件上传的整个思路就完成了。

参考文章:http://blog.ncmem.com/wordpress/2023/11/15/vue-%e5%a4%a7%e6%96%87%e4%bb%b6%e5%88%86%e7%89%87%e5%8f%8a%e6%96%ad%e7%82%b9%e4%b8%8a%e4%bc%a0/
欢迎入群一起讨论

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值