第一次实现:大文件切片上传,断点续传

523 阅读3分钟

在项目中,我们平常会遇到这样的需求场景:测试:你这个上传文件怎么传不呀?整个页面卡住没反应!!! 遇到这样的问题,是上传了大文件,前后端没有做大文件切片处理。下面来说一下大文件切片上传,断点续传具体实现步骤!

流程图

image.png

第一步: 在自定义上传组件中,对上传的文件进行MD5计算

  1. 安装下载 npm i spark-md5 -S 库
  2. 组件:文件上传组件,进度条组件。 采用上传文件组件对文件进行处理,进度条参数 percent 表示一个文件上传的进度。
   <a-upload
      :file-list="fileList"
      :customRequest="handleUpload"
      :remove="handleRemove"
    >
      <a-button> <a-icon type="upload" />上传文件</a-button>
    </a-upload>
    <a-progress :percent="percent" status="active" />
  1. 将hanndleUpload的回调文件file,进行MD5加密处理,默认切片chunkSize大小为20M。计算MD5文件大小也需要花费时间,文件越大计算时间越长。这里我设置占进度条的50%
   function md5(file, chunkSize = 20 * 1024 * 1024) {
      const _this = this;
      return new Promise((resolve, reject) => {
        const startMs = new Date().getTime();
        const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
        const chunks = Math.ceil(file.size / chunkSize);
        let currentChunk = 0;
        const spark = new SparkMD5.ArrayBuffer(); // 追加数组缓冲区。
        const fileReader = new FileReader(); // 读取文件
        fileReader.onload = function (e) {
          spark.append(e.target.result);
          currentChunk++;
          if (currentChunk < chunks) {
            loadNext();
          } else {
            const md5 = spark.end(); // 完成md5的计算,返回十六进制结果。
            console.log('文件md5计算结束,总耗时:', (new Date().getTime() - startMs) / 1000, 's');
            resolve(md5);
          }
        };
        fileReader.onerror = function (e) {
          reject(e);
        };

        function loadNext() {
          console.log('当前part number:', currentChunk, '总块数:', chunks);
          _this.percent = Math.ceil((50 / chunks) * (currentChunk + 1)); //进度条
          const start = currentChunk * chunkSize;
          let end = start + chunkSize;
          end > file.size && (end = file.size);
          fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
        }
        loadNext();
      });
    },

第二步:调用接口校验文件的MD5,查询是否存在名称为 md5 的文件夹,如果存在,就直接返回已上传的信息,通过后端返回的finished判断是否已经上传完成,这里就包括如有一部分切片上传失败,则返回给前端已经上传成功的切片name,前根据返回的切片,计算出未上传成功的剩余切片,然后把剩余的切片继续上传,即可实现"断点续传"。 不存在就调接口初始化一个上传任务。

      // 2.校验文件的MD5,后台查询是否存在名称为 md5 的文件夹
     // 初始化上传任务
    async getTaskInfo(file) {
      // 1.生成文件md5值
      const identifier = await this.md5(file);
      // 2.校验文件的MD5,后台查询是否存在名称为 md5 的文件夹
      const res = await taskInfo(identifier);
      if (!res) {
        // 如果不存在,则上传文件任务
        const initTaskData = {
          identifier,
          fileName: file.name,
          totalSize: file.size,
          chunkSize: 20 * 1024 * 1024,
        };
        const res = await creatSlice(initTaskData);
        if (!res) {
          this.$message.error('文件上传错误');
          return null;
        }
        return res;
      }
      return res;
      // 得到上传的结果
    },

第三步: 查询取每个分片的预签名上传地址。对返回的签名地址,通过PUT文件流的方式对文件切片上传

 handleUploadFile(file, taskRecord, options) {
      let lastUploadedSize = 0; // 上次断点续传时上传的总大小
      let uploadedSize = 0; // 已上传的大小
      const totalSize = file.size || 0; // 文件总大小
      const startMs = new Date().getTime(); // 开始上传的时间
      const { exitPartList, chunkSize, chunkNum, fileIdentifier } = taskRecord;
      // 获取从开始上传到现在的平均速度(byte/s)
      const getSpeed = () => {
        // 已上传的总大小 - 上次上传的总大小(断点续传)= 本次上传的总大小(byte)
        const intervalSize = uploadedSize - lastUploadedSize;
        const nowMs = new Date().getTime();
        // 时间间隔(s)
        const intervalTime = (nowMs - startMs) / 1000;
        return intervalSize / intervalTime;
      };
      const uploadNext = async (partNumber) => {
        const start = Number(chunkSize) * (partNumber - 1);
        const end = start + Number(chunkSize);
        const blob = file.slice(start, end);
        const res = await getPartUrl({ identifier: fileIdentifier, partNumber: partNumber });
        if (res) {
          await axios.request({
            url: res,
            method: 'PUT',
            data: blob,
            headers: { 'Content-Type': 'application/octet-stream' },
          });
          return Promise.resolve({ partNumber: partNumber, uploadedSize: blob.size });
        }
        return this.$message.error(`分片${partNumber}, 获取上传地址失败`);
      };

第四步:更新上传进度条,并对多个上次接口请求进行处理。这里我采用的是npm i promise-queue-plus -S 对切片请求进行promise队列等待处理,依次按照切片上传顺序执行。

       * 更新上传进度
       * @param increment 为已上传的进度增加的字节量
       */
      const updateProcess = (increment) => {
        increment = Number(increment);
        const { onProgress } = options;
        const factor = 1000; // 每次增加1000 byte
        let from = 0;
        // 通过循环一点一点的增加进度
        while (from <= increment) {
          from += factor;
          uploadedSize += factor;
          const percent = Math.round((uploadedSize / totalSize) * 100);
          this.percent = Math.ceil(percent / 2 + 50); // 算入md5计算时间
          onProgress({ percent: percent });
        }

        const speed = getSpeed();
        const remainingTime = speed !== 0 ? Math.ceil((totalSize - uploadedSize) / speed) + 's' : '未知';
        console.log('剩余大小:', (totalSize - uploadedSize) / 1024 / 1024, 'mb');
        console.log('当前速度:', (speed / 1024 / 1024).toFixed(2), 'mbps');
        console.log('预计完成:', remainingTime);
      };
      return new Promise((resolve) => {
        const failArr = [];
        const queue = Queue(5, {
          retry: 3,
          retryIsJump: false,
          workReject: function (reason, queue) {
            failArr.push(reason);
          },
          queueEnd: function (queue) {
            resolve(failArr);
          },
        });
        this.fileUploadChunkQueue[file.uid] = queue;
        for (let partNumber = 1; partNumber <= chunkNum; partNumber++) {
          const exitPart = (exitPartList || []).find((exitPart) => exitPart.partNumber === partNumber);
          if (exitPart) {
            // 分片已上传完成,累计到上传完成的总额中,同时记录一下上次断点上传的大小,用于计算上传速度
            lastUploadedSize += exitPart.size;
            updateProcess(exitPart.size);
          } else {
            queue.push(() =>
              uploadNext(partNumber).then((res) => {
                // 单片文件上传完成再更新上传进度
                console.log(res);
                updateProcess(res.uploadedSize);
              }),
            );
          }
        }
        if (queue.getLength() === 0) {
          // 所有分片都上传完,但未合并,直接return出去,进行合并操作
          resolve(failArr);
          return;
        }
        queue.start();
      });

第五步:全部切片上传完毕,调用接口,通知后端进行切片合并操作。并拿到文件上传完成的地址

    const res = await mergeSlice(identifier); // identifier MD5值