切片上传(秒传、断点续传)

结果展示

每个方块都代表一个文件切片的上传状态。绿色表示此文件切片已经上传成功;蓝色高度代表正在上传的文件切片的上传进度;红色代表上传失败。
在这里插入图片

前端实现

1. 文件切片

将文件分割成指定大小的chunk,存储到数组中

createFileChunk(file, size=CHUNK_SIZE) {
  const chunks = []
  let cur = 0
  while (cur < this.file.size) {
    chunks.push({index: cur, file: this.file.slice(cur, cur+size)})
    cur+=size
  }
  return chunks
}

2.计算文件hash

采用md5算法计算hash,作为文件唯一标识。
如果文件太大,md5算法会比较耗时,所以对文件进行抽样,然后再计算。
md5算法插件:spark-md5

async calculateHashSimple () {
	const file = this.file
	return new Promise(async (resolve) => {
	  const offset = 0.1 * 1024 * 1024
	  const size = file.size
	  let cur = offset
	  // 取第一段内容
	  let chunks = [file.slice(0, cur)]
	  while (cur <= size) {
	    if (cur + offset >= size) {
	    	// 取最后一段
	      chunks.push(file.slice(cur))
	    } else {
	      const middle = cur + offset/2
	      const end = cur + offset
	      const middleChunk = file.slice(middle, middle + 2)
	      // 中间每段内容都取头部、中间、尾部两个字节
	      chunks.push(file.slice(cur, cur+ 2), middleChunk, file.slice(end - 2, end))
	    }
	    cur += offset
	  }
	  let reader = new FileReader()
	  // ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。
	  // Blob 对象表示一个不可变、原始数据的类文件对象
	  reader.readAsArrayBuffer(new Blob(chunks))
	  reader.onload = async (e) => {
	    const result = e.target.result
	    const spark = new sparkMD5.ArrayBuffer()
	    spark.append(result)
	    resolve(spark.end())
	  }
	})
}

3.调用接口,获取文件是否已经上传

async uploadFile () {
  // 文件切片上传
  this.chunks = this.createFileChunk(this.file)
  // 计算文件hash
  this.hash = await this.calculateHashSimple()
  // 文件是否上传过
  const {data: {uploaded, uploadedList}} = await this.$http.post('/checkfile', {
    hash: this.hash,
    ext: this.file.name.split('.').pop()
  })
  
  ......
}

4.文件秒传

若文件之前已经上传成功,则直接提示“文件秒传成功”。
这里根据’/checkfile’接口返回的uploaded值来判断是否已经上传过。

async uploadFile () {
	......
	
	if (uploaded) {
	  this.chunks = this.chunks.map((chunk, index) => {
	    return {
	      name: this.hash + '-' + index,
	      progress: 100
	    }
	  })
	  return this.$message.success('秒传成功')
	}
	
	......
}

5.为文件切片添加属性值

map遍历this.chunks,为文件切片添加切片名称、hash、progress(上传进度,100-成功,0-未上传,视图的上传进度需要此值)等属性值。

async uploadFile () {
	......
	
	this.chunks = this.chunks
      .map((chunk, index) => {
        return {
          name: this.hash + '-' + index,
          index: index,
          hash: this.hash,
          chunk: chunk.file,
          progress: uploadedList.indexOf(`${this.hash}-${index}`) < 0 ? 0 : 100
        }
      })
    // 上传文件切片
	this.uploadChunks(uploadedList)
	......
}

6.上传文件切片(之前未上传过的)

async uploadChunks (uploadedList) {
  const requests = this.chunks
    .filter((chunk => uploadedList.indexOf(chunk.name) < 0))
    .map((chunk) => {
        let form = new FormData()
        form.append('name', chunk.name)
        form.append('chunk', chunk.chunk)
        form.append('hash', chunk.hash)
        return {form, index: chunk.index, error: 0}
    })
    // 并发量控制
    try {
      await this.requestLimit(requests, 3)
      await this.mergeRequest()
    } catch (err) {
      this.$message.error('当前网络不好,已中断上传')
    }
}

7.并发量控制

如果文件切片较多,同时发起请求,会造成浏览器卡顿甚至崩溃,所以需要控制请求并发量。
控制请求并发量为limit,如果某个文件切片连续3次都上传失败的话,则立即停止文件上传,并抛出网络异常提示。

async requestLimit (chunkRequests, limit) {
  let stopFlag = false
  return new Promise((resolve, reject) => {
    const start = async () => {
      if (stopFlag) {
        return
      }
      let task = chunkRequests.shift()
      try {
        await this.$http.post('/uploadfile', task.form, {
            onUploadProgress: (progress) => {
                this.chunks[task.index].progress = Number(((progress.loaded/progress.total)*100).toFixed(2))
            }
        })
        if (chunkRequests.length > 0) {
          start()
        } else {
          resolve()
        }
      } catch (err) {
        this.chunks[task.index].progress = -1
        task.error++
        if (task.error > 2) {
          stopFlag = true
          reject()
        } else {
          chunkRequests.unshift(task)
          start()
        }
      }
      
    }
    while(limit > 0) {
      // setTimeout(() => {
        start()
      // }, (Math.random) * 2000)
      
      limit--
    } 
  })
}

8.合并文件

告诉后台,文件切片已经全部上传,后台合并文件碎片并存储。

async mergeRequest () {
	this.$http.post('/mergefile', {
	    ext: this.file.name.split('.').pop(),
	    size: CHUNK_SIZE,
	    hash: this.hash
	})
}

9.html代码

<div>
  <!-- chunk.progress
  progress < 0 error 红色
  process == 100 success
  别的数字 方块高度显示 -->
  <div class="cube-container" :style="{width: cubeWidth + 'px'}">
    <div class="cube" v-for="chunk in chunks" :key="chunk.name">
      <div
        :class="{
          'uploading': chunk.progress > 0 && chunk.progress < 100,
          'success': chunk.progress == 100,
          'error': chunk.progress < 0
        }"
        :style="{height: chunk.progress + '%'}"
      >
      <i class="el-icon-loading" style="color:#f56c6c" v-if="chunk.progress > 0 && chunk.progress < 100"></i>
      </div>
    </div>
  </div>
</div>  
<!-- 预加载loading,避免上传进度中loading图块刚开始显示不全 -->
<i class="el-icon-loading" style="visibility: hidden"></i>

10.css代码

// 使用less
.cube-container
    .cube
        width 16px
        height 16px
        line-height 12px
        border 1px solid black
        background #eee
        float left
        > .success 
            background-color green 
        > .uploading
            background-color blue
        > .error
            background-color red