Vue实现大文件分片上传,包括断点续传以及上传进度条

首先解释一下什么是分片上传

分片上传就是把一个大的文件分成若干块,一块一块的传输。这样做的好处可以减少重新上传的开销。比如:如果上传的文件是一个很大的文件,那么上传的时间应该会比较久,再加上网络不稳定各种因素的影响,很容易导致传输中断,用户除了重新上传文件外没有其他的办法,但是可以使用分片上传来解决这个问题。通过分片上传技术,如果网络传输中断,重新选择文件只需要传剩余的分片。而不需要重传整个文件,大大减少了重传的开销。

但是要如何选择一个合适的分片呢?因此要考虑如下几个事情:

  1. 分片越小,那么请求肯定越多,开销就越大。因此不能设置太小。
  2. 分片越大,灵活度就少了。
  3. 服务器端都会有个固定大小的接收 Buffer。分片的大小最好是这个值的整数倍。

图片概览

de321c2893b6864beffb05193fa81c68.png

分片上传的步骤

  1. 先对文件进行 md5 加密。使用 md5 加密的优点是:可以对文件进行唯一标识,同样可以为后台进行文件完整性校验进行比对。
  2. 拿到 md5 值以后,服务器端查询下该文件是否已经上传过,如果已经上传过的话,就不用重新再上传。
  3. 对大文件进行分片。比如一个 100M 的文件,一个分片是 5M 的话,那么这个文件可以分 20 次上传。
  4. 向后台请求接口,接口里的数据就是已经上传过的文件块。(注意:为什么要发这个请求?就是为了能断点续传,比如使用百度网盘对吧,网盘里面有续传功能,当一个文件传到一半的时候,突然想下班不想上传了,那么服务器就应该记住之前上传过的文件块,当打开电脑重新上传的时候,那么它应该跳过之前已经上传的文件块。再上传后续的块)。
  5. 开始对未上传过的文件块进行上传。(这个是第二个请求,会把所有的分片合并,然后上传请求)。
  6. 上传成功后,服务器会进行文件合并。最后完成。

接着讲讲具体的做法:

首先是跟后端约定好,每个分片是多大。

但是要如何选择一个合适的分片呢?因此要考虑如下几个事情:

  1. 分片越小,那么请求肯定越多,开销就越大。因此不能设置太小。
  2. 分片越大,灵活度就少了。

然后要判断文件大小,如果文件还没有一个分片大,那就直接走单文件直接上传的逻辑。否则就走分片上传的逻辑。约定的大小是 5mb

首先将文件 md5 加密,获得加密的 md5 值,然后切片,(字节流)slice 方法来切割的。切完片过后呢,开始走上传,这个时候做的秒传功能就体现出来了。在第一个分片里带上文件的 md5 值,后端判断,这个文件是否已经上传过,如果上传过,就直接返回一个标识符,就不用继续上传 ,直接秒传成功

假如没有,然后开始上传,上传使用的是并行上传。这里需要判断是并行上传还是串行上传。如果是串行上传的话,就对那个分片数组进行 for 循环,用 async/await 进行控制。如果是并行上传,就使用 promise.allSettled 来控制,这个 api 可以接收一个 promise 数组,然后并行执行里面的 promise,然后返回一个结果数组,这个数组里面的每一项正好对应了那个 promise 数组里面的每一项 promise 的结果。

全部上传完成过后呢,会调用一个接口,在这个接口里后端会返回给,他有哪些分片没有接收到,在传给他的第一个分片中,已经告诉了他这个文件一共多少片,然后在上传每一片的时候,会带一个这一片是第几片的参数,也就是 index,所以他能知道有哪些分片他没接收到。

如果真的有分片没有接收到。就得走续传的逻辑,这个时候再重新上传,但是这次的重新上传,就只会上传上一次上传失败的那些分片,而不是全部重新上传。这次上传完过后,再去请求那个最后的接口,让后端告诉他接收完了吗。如果接收完了,文件上传就结束了。如果没接收完。还是继续

文件上传时,会走 http-request 方法,如果定义了这个方法,组件的 submit 方法就会被拦截掉(注意别在这个方法里面调用组件的 submit 方法,会造成死循环),在这个方法里面就可以搞想搞的事情了。

http-request 这个传入的回调函数应该返回一个 Promise,所以自己定义了一个无用的 Promise 进去

const prom = new Promise((resolve, reject) => {})
prom.abort = () => {}
return prom

如果要实现断点续传,需要和后端确定好,如何配合。 这里的方案是,在把所有的分片全部上传一遍后,会请求一个查询接口,后端在这个接口里面返回给哪些分片没有上传成功(会给序号),这个时候,再去重新上传那些没有被上传成功的分片

完整代码

<template>
  <div :class="showProgress == true ? 'loading' : ''">
    <!-- on-preview	点击文件列表中已上传的文件时的钩子 -->
    <!-- http-request	覆盖默认的上传行为,可以自定义上传的实现 -->
    <!-- limit	最大允许上传个数 -->
    <!-- before-upload	上传文件之前的钩子,参数为上传的文件,若返回 false 或者返回 Promise 且被 reject,则停止上传。 -->
    <!-- accept	接受上传的文件类型(thumbnail-mode 模式下此参数无效) -->
    <!-- multiple	是否支持多选文件 -->
    <!-- on-change	文件状态改变时的钩子,添加文件、上传成功和上传失败时都会被调用 -->
    <!-- on-remove	文件列表移除文件时的钩子 -->
    <!-- file-list	上传的文件列表, 例如: [{name: 'food.jpg', url: 'https://xxx.cdn.com/xxx.jpg'}] -->
    <!-- on-exceed	文件超出个数限制时的钩子 -->
    <!-- auto-upload	是否在选取文件后立即进行上传 -->
    <!-- action	必选参数,上传的地址  例如  action="https://jsonplaceholder.typicode.com/posts/"-->
    <el-upload
      drag
      multiple
      :auto-upload="true"
      :http-request="checkedFile"
      :before-remove="removeFile"
      :limit="10"
      action="/tools/upload_chunk/"
      :disabled="showProgress"
    >
      <i class="el-icon-upload"></i>
      <div class="el-upload__text">
        将文件拖到此处,或
        <em>点击上传</em>
      </div>
    </el-upload>
    <!-- 正在上传的弹窗 -->
    <el-dialog title="正在上传" :visible.sync="showProgress" width="50%">
      <el-progress
        type="circle"
        :percentage="progress"
        class="progress"
        v-if="showProgress"
      ></el-progress>
    </el-dialog>
    <!-- <el-progress type="circle" :percentage="progress" class="progress" v-if="showProgress"></el-progress> -->
  </div>
</template>
<script>
import axios from 'axios'
import SparkMD5 from 'spark-md5'
export default {
  data() {
    return {
      maxSize: 5 * 1024 * 1024 * 1024, // 上传最大文件限制  最小单位是b
      multiUploadSize: 100 * 1024 * 1024, // 大于这个大小的文件使用分块上传(后端可以支持断点续传)  100mb
      eachSize: 100 * 1024 * 1024, // 每块文件大小   100mb
      requestCancelQueue: [], // 请求方法队列(调用取消上传
      url: '/tools/upload_chunk/',
      //上传进度
      progress: 0,
      showProgress: false,
      // 每上传一块的进度
      eachProgress: 0,
      // 总共有多少块。断点续传使用
      chunksKeep: 0,
      // 切割后的文件数组
      fileChunksKeep: [],
      // 这个文件,断点续传
      fileKeep: null,
      // 断点续传,文件md5
      fileMd5Keep: ''
    }
  },
  mounted() {},
  methods: {
    async checkedFile(options) {
      // console.log(options);
      const { maxSize, multiUploadSize, getSize, splitUpload, singleUpload } = this // 解构赋值
      const { file, onProgress, onSuccess, onError } = options // 解构赋值
      if (file.size > maxSize) {
        return this.$message({
          message: `您选择的文件大于${getSize(maxSize)}`,
          type: 'error'
        })
      }
      this.fileKeep = file
      const uploadFunc = file.size > multiUploadSize ? splitUpload : singleUpload // 选择上传方式
      try {
        await uploadFunc(file, onProgress)
        onSuccess()
      } catch (e) {
        console.error(e)
        this.$message({
          message: e.message,
          type: 'error'
        })
        this.showProgress = false
        this.progress = 0
        onError()
      }
      const prom = new Promise((resolve, reject) => {}) // 上传后返回一个promise
      prom.abort = () => {}
      return prom
    },
    // 格式化文件大小显示文字
    getSize(size) {
      return size > 1024
        ? size / 1024 > 1024
          ? size / (1024 * 1024) > 1024
            ? (size / (1024 * 1024 * 1024)).toFixed(2) + 'GB'
            : (size / (1024 * 1024)).toFixed(2) + 'MB'
          : (size / 1024).toFixed(2) + 'KB'
        : size.toFixed(2) + 'B'
    },
    // 单文件直接上传
    async singleUpload(file, onProgress) {
      await this.postFile({ file, uid: file.uid, fileName: file.fileName, chunk: 0 }, onProgress)
      // var spark = new SparkMD5.ArrayBuffer();
      // spark.append(file);
      // var md5 = spark.end();
      // console.log(md5);

      const reader = new FileReader()

      reader.readAsArrayBuffer(file)
      let hashMd5 = ''
      console.log(hashMd5)
      const that = this
      function getHash(cb) {
        console.log('进入单个上传的getHash')
        reader.onload = function (e) {
          console.log('进入单个上传的getHash的函数2')
          console.log(hashMd5)
          console.log(this)
          // console.log(e)
          const hash = SparkMD5.ArrayBuffer.hash(e.target.result)
          // const hash = SparkMD5.ArrayBuffer.hash(file);
          console.log(hash)
          that.hashMd5 = hash
          console.log(that.hashMd5)
          that.fileMd5Keep = hash
          cb(hash)
        }
      }
      await getHash(function (hash) {
        console.log(hash)
        console.log(that)
        // 请求接口
        that.validateFile({
          name: file.name,
          uid: file.uid,
          md5: hash,
          chunks: 1,
          filter_type: 'user_data_file'
        })
      })
    },
    // getMd5(file, chunkCount) {
    //   const spark = new SparkMD5.ArrayBuffer();
    //   let currentChunk = 0;

    //   const reader = new FileReader();

    //   reader.onload = function(e) {
    //     spark.append(e.target.result);
    //     currentChunk++;

    //     if (currentChunk < chunkCount) {
    //       console.log(currentChunk);
    //       loadNext();
    //     } else {
    //       console.log(spark.end());
    //       // 在这里请求接口
    //       return spark.end();
    //     }
    //   };

    //   function loadNext() {
    //     const start = currentChunk * chunkSize;
    //     const end =
    //       start + chunkSize >= file.size ? file.size : start + chunkSize;
    //     reader.readAsArrayBuffer(file.slice(start, end));
    //   }

    //   loadNext();
    // },
    // 大文件分块上传
    splitUpload(file, onProgress) {
      return new Promise(async (resolve, reject) => {
        try {
          const { eachSize } = this
          const chunks = Math.ceil(file.size / eachSize)
          this.chunksKeep = chunks
          const fileChunks = await this.splitFile(file, eachSize, chunks)
          this.fileChunksKeep = fileChunks
          console.log('fileChunks,文件数组切割后')
          console.log(fileChunks)
          //判断每上传一个文件,进度条涨多少,保留两位小数
          this.eachProgress = parseInt(Math.floor((100 / chunks) * 100) / 100)
          this.showProgress = true
          let currentChunk = 0

          for (let i = 0; i < fileChunks.length; i++) {
            // 服务端检测已经上传到第currentChunk块了,那就直接跳到第currentChunk块,实现断点续传
            console.log(currentChunk, i)
            // 此时需要判断进度条
            if (Number(currentChunk) === i) {
              // 每块上传完后则返回需要提交的下一块的index
              await this.postFile(
                {
                  chunked: true,
                  chunk: i,
                  chunks,
                  eachSize,
                  fileName: file.name,
                  fullSize: file.size,
                  uid: file.uid,
                  file: fileChunks[i]
                },
                onProgress
              )
              currentChunk++

              // 上传完一块后,进度条增加
              this.progress += this.eachProgress
              // 不能超过100
              this.progress = this.progress > 100 ? 100 : this.progress
            }
          }
          // this.getMd5(file, chunks);
          // var spark = new SparkMD5.ArrayBuffer();
          // spark.append(file);
          // var md5 = spark.end();
          // console.log(md5);
          const spark = new SparkMD5.ArrayBuffer()
          let currentChunkMd5 = 0
          const that = this
          const reader = new FileReader()
          reader.onload = async function (e) {
            spark.append(e.target.result)
            currentChunkMd5++

            if (currentChunkMd5 < chunks) {
              loadNext()
            } else {
              // console.log(spark.end());
              var hashMd5111 = spark.end()
              that.fileMd5Keep = hashMd5111
              console.log(that)
              console.log(hashMd5111)
              // 在这里请求接口
              await that.validateFile({
                name: file.name,
                uid: file.uid,
                md5: hashMd5111,
                chunks: fileChunks.length,
                filter_type: 'git_secret_file'
                // chunk: fileChunks.length,
              })
            }
          }

          async function loadNext() {
            const start = currentChunkMd5 * eachSize
            const end = start + eachSize >= file.size ? file.size : start + eachSize
            await reader.readAsArrayBuffer(file.slice(start, end))
          }
          this.$message({
            message: '正在进行文件加密校验',
            type: 'info'
          })
          await loadNext()
          // let hashMd5 = "";
          // // console.log(hashMd5)
          // const that = this;
          // console.log("进入分片上传的getHash");
          // function getHash(cb) {
          //   reader.onload = function(e) {
          //     console.log("进入分片上传的getHash的函数");
          //     const hash = SparkMD5.ArrayBuffer.hash(e.target.result);
          //     // const hash = SparkMD5.ArrayBuffer.hash(file);
          //     console.log(hash);
          //     that.hashMd5 = hash;
          //     console.log(that.hashMd5);
          //     that.fileMd5Keep = hash;
          //     cb(hash);
          //   };
          //   reader.readAsArrayBuffer(file);
          // }
          // await getHash(function() {
          //   console.log(that);
          //   that.validateFile({
          //     name: file.name,
          //     uid: file.uid,
          //     md5: that.hashMd5,
          //     chunks: fileChunks.length
          //     // chunk: fileChunks.length,
          //   });
          // });
          // 请求接口

          // console.log('fileChunks.length')
          // 请求接口
          // this.validateFile({
          //   fileName: file.name,
          //   uid: file.uid,
          //   md5:md5,
          //   chunks:1
          // });
          resolve()
        } catch (error) {
          reject(error)
        }
      })
    },
    // 断点续传
    againSplitUpload(file, array) {
      console.log('file,array')
      console.log(file)
      console.log(array)
      return new Promise(async (resolve, reject) => {
        try {
          const { eachSize, fileKeep } = this
          const chunks = this.chunksKeep
          const fileChunks = this.fileChunksKeep
          this.showProgress = true
          // let currentChunk = 0;
          for (let i = 0; i < array.length; i++) {
            // 服务端检测已经上传到第currentChunk块了,那就直接跳到第currentChunk块,实现断点续传
            // console.log(currentChunk, i);
            // 此时需要判断进度条
            // 每块上传完后则返回需要提交的下一块的index
            await this.postFile({
              chunked: true,
              chunk: array[i],
              chunks,
              name: file.name,
              fullSize: fileKeep.size,
              uid: file.uid,
              file: fileChunks[array[i]]
            })
            // currentChunk++

            // 上传完一块后,进度条增加
            // this.progress += this.eachProgress;
            // 不能超过100
            this.progress = this.progress > 100 ? 100 : this.progress
          }
          // var spark = new SparkMD5.ArrayBuffer();
          // spark.append(fileKeep);
          // var md5 = spark.end();
          // console.log(md5);

          var fileMd5KeepTwo = this.fileMd5Keep

          const isValidate = await this.validateFile({
            chunks: fileChunks.length,
            // chunk: fileChunks.length,
            name: file.name,
            uid: file.uid,
            md5: fileMd5KeepTwo,
            filter_type: 'git_secret_file'
            // task_id:file.uid
          })
          // if (!isValidate) {
          //   throw new Error("文件校验异常");
          // }
          // 关闭进度条
          this.showProgress = false
          // 重置进度条
          this.progress = 0
          resolve()
        } catch (e) {
          reject(e)
        }
      })
    },
    // 文件分块,利用Array.prototype.slice方法
    splitFile(file, eachSize, chunks) {
      return new Promise((resolve, reject) => {
        try {
          setTimeout(() => {
            const fileChunk = []
            for (let chunk = 0; chunks > 0; chunks--) {
              fileChunk.push(file.slice(chunk, chunk + eachSize))
              chunk += eachSize
            }
            resolve(fileChunk)
          }, 0)
        } catch (e) {
          console.error(e)
          reject(new Error('文件切块发生错误'))
        }
      })
    },
    removeFile(file) {
      this.requestCancelQueue[file.uid]()
      delete this.requestCancelQueue[file.uid]
      return true
    },
    // 提交文件方法,将参数转换为FormData, 然后通过axios发起请求
    postFile(param, onProgress) {
      // console.log(param);
      const formData = new FormData()
      // for (let p in param) {
      // formData.append(p, param[p]);
      // }
      formData.append('file', param.file) //  改了
      formData.append('uid', param.uid)
      formData.append('chunk', param.chunk)
      formData.append('filter_type', 'git_secret_file')
      const { requestCancelQueue } = this
      const config = {
        cancelToken: new axios.CancelToken(function executor(cancel) {
          if (requestCancelQueue[param.uid]) {
            requestCancelQueue[param.uid]()
            delete requestCancelQueue[param.uid]
          }
          requestCancelQueue[param.uid] = cancel
        }),
        onUploadProgress: (e) => {
          if (param.chunked) {
            e.percent = Number(
              (((param.chunk * (param.eachSize - 1) + e.loaded) / param.fullSize) * 100).toFixed(2)
            )
          } else {
            e.percent = Number(((e.loaded / e.total) * 100).toFixed(2))
          }
          onProgress(e)
        }
      }
      // return axios.post('/api/v1/tools/upload_chunk/', formData, config).then(rs => rs.data)
      return this.$http({
        url: '/tools/upload_chunk/',
        method: 'POST',

        data: formData
        // config
      }).then((rs) => rs.data)
    },
    // 文件校验方法
    validateFile(file) {
      // return axios.post('/api/v1/tools/upload_chunk/', file).then(rs => rs.data)
      return this.$http({
        url: '/tools/upload_chunk/upload_success/',
        method: 'POST',
        data: file
      }).then((res) => {
        if (res && res.status == 1) {
          this.againSplitUpload(file, res.data.error_file)
          this.$message({
            message: '有文件上传失败,正在重新上传',
            type: 'warning'
          })
        } else if (res && res.status == 0) {
          this.$message({
            message: '上传成功',
            type: 'success'
          })
          this.showProgress = false
          this.progress = 0
        } else if (res && res.status == 40008) {
          this.$message.error(res.message)
          this.showProgress = false
          this.progress = 0
        }
      })
    }
  }
}
</script>
<style scoped>
.loading {
  /* 整体页面置灰 */
  /* background: rgba(0, 0, 0, 0.5); */
}

.progress {
  /* 在当前页面居中 */
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  margin-top: 40px;
  /* 宽度 */
}

/deep/ .el-dialog {
  position: relative;
  height: 500px;
}
</style>

问题解答

如果在上传途中断网中断了。怎么处理

通过 localStorage 记录切片上传的信息或者每次在上传切片前向后台询问该切片是否已经上传

暂停上传

请求一个接口,中断上传,(也就是中断 promise.allSettled 的外层循环)自己这边和后端都能知道已经上传多少了。下一次就跳过已上传的就行

大文件分片上传的难点

加密,之前加密的值不一样,换了一种加密方法,

文件如何切片

并行上传,查了很多资料。

秒传,也是查资料,获得的

断点续传,根据那个失败的 index 数组重新上传,也可以让后端告诉。最好是后端告诉,这样最稳定,

暂停上传

如何分片,分成了什么

文件分块,利用 Blob.prototype.slice 方法

分成的是字节流,blob 格式

上传应该是有顺序的,如何保证顺序的

其实都不用保证,因为给了每次上传加了 index,后端去排序就行

假如某个分片上传失败了,是怎么处理的

在 promise.allSettled 的失败数组里面有存储

后台如何知道是哪些分片没接收到

他可以通过 index 判断,第一个分片也告诉他总共多少分片了

怎么做并行上传

promise.allSettled

上传分片是怎么判断这个分片上传失败

promise 报错就算失败,而且最后也是看后端告诉哪些失败了

这几百个分片,如何同时上传一部分?

使用 promise.allSettled,传递一个 promise 数组进去,返回的一个数组,对应的就是每个 promise 的响应

posted @ 2024-01-16 09:50  柯基与佩奇  阅读(329)  评论(0编辑  收藏  举报