记一次大文件分片上传

2,889 阅读3分钟

项目背景

身处小公司,公司从别的地方购买了一个20年前的破旧jsp项目(醉了),历经七八代人之手,里面只有更乱,没有最乱,只有你想象不到的乱。没有babel,没有webpack,唯一有的是iframe+jQuery+tomcat+eclipse(iframe嵌套七八层那是常像,还要处理eclipse启动报错啊)。绝望...

分片上传核心代码

function upload(blobOrFile) {
  var xhr = new XMLHttpRequest();
  xhr.open('POST', '/server', true);
  xhr.onload = function(e) { ... };
  xhr.send(blobOrFile);
}

document.querySelector('input[type="file"]').addEventListener('change', function(e) {
  var blob = this.files[0];

  var BYTES_PER_CHUNK = 1024 * 1024; // 1MB chunk sizes.
  var SIZE = blob.size;
  var start = 0;
  var end = BYTES_PER_CHUNK;

  while(start < SIZE) {
    upload(blob.slice(start, end));
    start = end;
    end = start + BYTES_PER_CHUNK;
  }
}, false);

上传前后的破事处理(结合业务)

后台的文件接收,只能是同步的形式,异步的合并文件就报错,无语...本着优化的心态,那就做个队列吧 切片的上传,关键参数是切片的index,后台根据切片的index进行文件合并

  1. 文件类型检测
  2. 文件大小检测(我问产品给个限制,产品说不用,再我的再三要求下,他们给了10G...)
  3. 解析zip文件的子文件(FileReader处理,不是本次文章的关键,不细说)
  4. 获取文件MD5值(github.com/satazor/js-…)
  5. 创建上传任务(获取taskid) - creatModelTask
  6. 生成上传任务队列 - createQueueArr
  7. 处理队列,分片上传 - handleQueueArr+fileUpload
  8. 上传完毕,发起文件合并请求 - handleMerge

上传

function UploadModel() {
    this.DOM = {
        uploadModelInputFile: document.querySelector("#uploadModelInputFile") // 模型上传input
    }
    
    this.uploadConfig = {
        maxFileSize: 10 * 1024 * 1024 * 1024, // 文件最大值
        acceptFileTypes: ["zip", "rvt", "dwg"], // 接收的文件类型
        BYTES_PER_CHUNK: 3 * 1024 * 1024 // 切片大小3M
    }
    
    this.uploadModelArgs = {
        fileBlob: null, // 选择的文件
        modelName: null, // 文件名称
        md5: null, // 文件MD5
        fileSuffix: null, // 文件后缀(带点)
        fileCountTotal: null, // 切块总数量
        taskId: null // 创建上传任务获取到的任务id
    }
    
    this.api = {
        modelUpload_upload: function (formData) { // 上传
          return $.ajax({
            type: "POST",
            url: publicJS.tomcat_url_one + '/modelUpload_upload.action',
            data: formData,
            // dataType: "json",
            Accept: 'text/html;charset=UTF-8',
            // async: false,
            processData: false,  // 不处理数据
            contentType: false,   // 不设置内容类型
          });
    }
}

UploadModel.prototype = {
    creatModelTask: function() { // 创建上传任务 },
    setUploadPercent: function(fileIndex, fileCountTotal) { // 设置上传进度条
        var percent = Math.ceil(fileIndex / fileCountTotal * 100);
        percent = percent > 100 ? 100 : percent;
        console.log("上传进度==>", percent + "%", "fileIndex===>", fileIndex, "fileCountTotal===>", fileCountTotal);
    },
    createQueueArr: function(taskId) { // 上传处理,生成任务队列
        var start = 0;
        var end = _this.uploadConfig.BYTES_PER_CHUNK;
        var fileSize = _this.uploadModelArgs.fileBlob.size;
        var fileIndex = 0;
        var queueArr = []; // 上传任务队列
        
        while (start < fileSize) {
          var fd = new FormData();
          var chunk = _this.uploadModelArgs.fileBlob.slice(start, end); // 切割chunk
          console.log(chunk);
          fd.append('taskid', taskId);
          fd.append('fileIndex', fileIndex);
          fd.append('fileItemSplit', chunk);
          queueArr.push(_this.fileUpload.bind(_this, fd, fileIndex, _this.uploadModelArgs.fileCountTotal));
          fileIndex++;
          start = end;
          end = start + _this.uploadConfig.BYTES_PER_CHUNK;
        };
        
        return queueArr;
    },
    handleQueueArr: function(queueArr) { // 处理任务队列
        // https://github.com/mqyqingfeng/Blog/issues/90
        if (Array.isArray(queueArr) && queueArr.length > 0) {
          // 创建迭代器协议(非可迭代协议)
          var iterator = {
            queueArr: queueArr,
            nextIndex: 0,
            next() {
              return this.nextIndex < this.queueArr.length ? {
                value: this.queueArr[this.nextIndex++],
                done: false
              } : {
                  value: this.queueArr[this.nextIndex++],
                  done: true
                }
            }
        };

         var exectorIterator = function () {
            var obj = iterator.next();
            if (!obj.done) {
              if (typeof obj.value === 'function') {
                obj.value()
                  .then(function () {
                    exectorIterator(); // 递归
                  }, function (error) {
                    console.log(error);
                    console.log("文件上传失败,请重试")
                  })
              }
            }
          }

        exectorIterator();
    },
     /**
       * 分片上传
       * @param {FormData} fd 上传参数
       * @param {Number} fileIndex 当前切片index
       * @param {Number} fileCountTotal 切片总数
       * @param {Element} uploadPercentProgress 进度条DOM节点
       */
    fileUpload: function (fd, fileIndex, fileCountTotal) {
        var _this = this;
        return new Promise(function (resolve, reject) {
          _this.api.modelUpload_upload(fd)
            .then(function (res) {
              if(utils.isObject(res) && utils.hasOwnProperty(res, "code")) {
                var code = parseInt(res.code);
                if (code === 200) {
                  console.log("success fileUpload===>", res);
                  if (fileIndex + 1 === fileCountTotal) {
                    _this.setUploadPercent(uploadPercentProgress, 100);
                    _this.handleMerge.call(_this);
                    console.log("关闭进度条");
                  }
    
                  resolve();
                } else {
                  reject("文件上传失败" + fileIndex);
                }
              } else {
                reject("文件上传失败" + fileIndex);
              }
            }, function (error) {
              console.log("error fileUpload===>", error);
              reject("文件上传失败" + fileIndex);
            })
    })
  },
  handleMerge: function() { // 发起合并...},
  handleUpload: function() {
      this.creatModelTask()
        .then(this.createQueueArr)
        .then(this.handleQueueArr)
        .catch(function(error) {
            cosole.log(error);
        })
        
  },
  listenerAddFile: function(e) { // input框选择文件监听
      ...
      this.handleUpload();
  },
  bindEvents: function() {
      // 选择文件确定后,立即处理上传
      this.DOM.uploadModelInputFile.addEventListener("change", this.listenerAddFile.bind(this));
  },
  init: function() {
      this.bindEvents();
  }
}

var uploadModel = new UploadModel();
uploadModel.init();

扩展 如何实现断点续传

想到了xhr的abort方法取消发送请求,handleQueueArr方法需要改造,自己改造吧(手动滑稽);

还有对于已经上传过的文件,下次接着传,需要服务端配合(上面的文件MD5值判断有无上传过);

写在后面

代码还有很多漏洞和优化的地方,还需改进,有错误的地方,欢迎指出。

参考资料

【ES6基础】迭代器(iterator)

js-spark-md5

FileReader

彻底弄懂文件和二进制数据的操作

Blob对象 · Issue #10

为什么视频网站的视频链接地址是blob?

js压缩文件读取处理