vue实现大文件切片上传、断点续传、并发数控制等

发布时间 2023-09-25 11:08:17作者: Xproer-松鼠

 

一、上传按钮和进度条等
<div>
  <h2>上传文件</h2>
  <div ref="drag" class="drag">
    <input class="file" type="file" @change="handlerChange" />
  </div>
  <el-progress style="width: 500px;" :percentage="progress"></el-progress>
  <div style="margin-top: 16px;">
    <el-button type="primary" @click="upload">上传</el-button>
  </div>
  <div>
    <p>hash进度条</p>
    <el-progress style="width: 500px;" :percentage="hashProgress"></el-progress>
  </div>
  <div>
    <p>网格进度条</p>
    <ul class="grid" :style="{'width': gridWidth + 'px'}">
      <li class="grid-block" 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 < 100 && chunk.progress > 0"></i>
        </div>
      </li>
    </ul>
  </div>
</div>
二、选择文件
//点击按钮上传
handlerChange (e) {
  const [file] = e.target.files
  if (!file) return
  this.fileData = file
}
//拖拽上传
dragRelevant () {
  const dragDom = this.$refs.drag
  //进入区域
  dragDom.addEventListener('dragover', e => {
    dragDom.style.borderColor = '#f00'
    e.preventDefault()
  })
  //离开区域
  dragDom.addEventListener('dragleave', e => {
    dragDom.style.borderColor = '#41B883'
    e.preventDefault()
  })
  //放下文件
  dragDom.addEventListener('drop', e => {
    dragDom.style.borderColor = '#41B883'
    const [file] = e.dataTransfer.files
    if (!file) return
    this.fileData = file
    e.preventDefault()
  })
}
三、利用文件内容计算hash

为了防止文件上传重复,我们可以使用将每个文件都用hash作为文件名来上传,这里用的是spark-md5来计算hash值。
首先定一个分块的大小

const CHUNK_SIZE = 1 * 1024 * 1024 //每次分片大小

因为大文件用整个内容来计算hash肯定是很慢的,我们不能阻塞页面执行其他任务,所以我通过下面三种方式来计算:

  • 使用WebWorker来计算
//使用webWorker来计算文件的md5值
calculateHashByWebWorker (chunks) {
  this.hashProgress = 0 //hash进度条
  return new Promise(resolve => {
    const worker = new Worker('/hash.js')
    worker.postMessage(chunks)
    worker.onmessage = e => {
      const { hash, progress } = e.data
      this.hashProgress = progress
      if (hash) {
        resolve(hash)
      }
    }
  })
}
  • 使用requestIdleCallbck来计算
//使用requestIdleCallbck来计算文件的md5值  这个方法会在浏览器空闲时调用
calculateHashByRequestIdleCallback (chunks) {
  return new Promise(resolve => {
    const spark = new Spark.ArrayBuffer()
    let count = 0
    const appendToSpark = file => {
      return new Promise(resolve => {
        const reader = new FileReader()
        reader.readAsArrayBuffer(file)
        reader.onload = data => {
          spark.append(data.target.result)
          resolve()
        }
      })
    }
    const workLoop = async deadLine => {
      while (count < chunks.length && deadLine.timeRemaining() > 1) {
        await appendToSpark(chunks[count].file)
        count++
        if (count < chunks.length) {
          this.hashProgress = (count * 100 / chunks.length).toFixed(2) - 0
        } else {
          this.hashProgress = 100
          resolve(spark.end())
        }
      }
      window.requestIdleCallback(workLoop)
    }
    window.requestIdleCallback(workLoop)
  })
}
    • 实现抽样hash,降低精度,提高效率
      大文件每次都全量计算md5的话,效率很低,如果我们每次取每个分片的一部分用来计算,这样会大大提高计算的效率
//抽样hash 取前两个和后一个  中间每兆取前中后三个点
calulateSamplingHash (chunks) {
  return new Promise(resolve => {
    const spark = new Spark.ArrayBuffer()
    const head = chunks.slice(0, 2)
    const tail = chunks[chunks.length - 1]
    const middle = chunks.slice(2, chunks.length - 1)
    const files = []
    files.push(head[0].file, head[1].file)
    middle.forEach(item => {
      const head = item.file.slice(0, 1)
      const tail = item.file.slice(-1, item.file.length)
      const center = Math.floor(item.file.length - 1) / 2
      const middle = item.file.slice(center, center + 1)
      files.push(head, tail, middle)
    })
    files.push(tail.file)
    //追加计算hash
    const reader = new FileReader()
    reader.readAsArrayBuffer(new Blob(files))
    reader.onload = data => {
      spark.append(data.target.result)
      this.hashProgress = 100
      resolve(spark.end())
    }
  })
}
四、上传

将上传的诸多分片都放在对应hash值得目录下面,每次上传前检查下是否有这个文件了
如果有就提示秒传成功
如果没有就读取下这个目录,将这个目录下面的所有文件名都返回给前端

    • 检查文件是否已上传
//检查文件是否已上传
const fileExt = this.fileData.name.split('.').pop()
// uploaded:文件是否已上传,uploadedList:上传的分片列表
const { data: { uploaded, uploadedList } } = await this.$axios.get('/checkFile', {
  params: {
    hash,
    ext: fileExt
  }
})
if (uploaded) {
  this.$message.success('秒传成功')
  return
}
//断点续传  根据之前上传的文件
this.chunks = chunks.map((chunk, index) => {
  const fileName = `${hash}-${index}`
  return {
    file: new File([chunk.file], fileName + '.' + fileExt, { type: 'image/mp4' }),
    name: fileName,
    hash,
    progress: uploadedList.includes(fileName) ? 100 : 0 //如果当前分片已经上传,进度直接设置为100
  }
})
  • 上传请求(断点续传)
//上传请求
async uploadRequest (hash) {
  //如果已经上传过了 就不用上传了  用filter过滤掉(断点续传)
  const requests = this.chunks.map((chunk, index) => {
    if (chunk.progress === 100) {
      return null
    } else {
      const form = new FormData()
      form.append('chunk', chunk.file)
      form.append('hash', chunk.hash)
      form.append('name', chunk.name)
      return { form, index, error: 0 }
    }
  }).filter(val => val)
  //实现并发数控制
  await this.sendRequest(requests)
  //合并上传的分片
  this.mergeFile(hash)
}
  • 并发数控制+错误重试
//请求并发数控制
sendRequest (requests, limit = 3) {
  return new Promise((resolve, reject) => {
    const len = requests.length
    let counter = 0
    let isStop = false //如果一个片段失败超过三次 认为当前网洛有问题 停止全部上传
    const startRequest = async () => {
      if (isStop) return
      const task = requests.shift()
      if (task) {
        //利用try...catch捕获错误
        try {
          //具体的接口  抽离出去了
          await this.launchRequest(task)
          if (counter === len - 1) { //最后一个任务
            resolve()
          } else { //否则接着执行
            counter++
            startRequest() //启动下一个任务
          }
        } catch (error) {
          this.$set(this.chunks[task.index], 'progress', -1)
          //接口报错重试,限制为3次
          if (task.error < 3) {
            task.error++
            requests.unshift(task)
            startRequest()
          } else {
            isStop = true
            reject(error)
          }
        }
      }
    }
    //启动任务
    while (limit > 0) {
      //模拟不同大小启动
      setTimeout(() => {
        startRequest()
      }, Math.random() * 2000)
      limit--
    }
  })
}

 

参考文章:http://blog.ncmem.com/wordpress/2023/09/25/vue%e5%ae%9e%e7%8e%b0%e5%a4%a7%e6%96%87%e4%bb%b6%e5%88%87%e7%89%87%e4%b8%8a%e4%bc%a0%e3%80%81%e6%96%ad%e7%82%b9%e7%bb%ad%e4%bc%a0%e3%80%81%e5%b9%b6%e5%8f%91%e6%95%b0%e6%8e%a7%e5%88%b6%e7%ad%89/

欢迎入群一起讨论