Vue3 + Element Plus realizes large file slice upload and progress bar display

1 Introduction

Recently, I am doing a refactoring of a project, which has the function of uploading large files. Since the project was a few years ago, the code is not separated from the front and back. It is done with the jQuery + webuploader library, but in fact it only realizes the upload of large file slices. There is no slice concurrency, instant transmission and breakpoint resume function, and the backend does not support it, and the webuploader library is no longer maintained, so I decided to implement the simplest large file slice upload function by myself.

2 function realization

2.1 Add Element Plus upload code and progress bar display code

<template>
  <el-upload
    accept=".mp3, .m4a, .aac, .mp4, .m4v"
    :before-upload="beforeUpload"
    :http-request="upload"
    :show-file-list="false"
    :disabled="disabled"
    style="display: inline-block"
  >
    <el-tooltip placement="bottom">
      <template #content>
        可上传本地录音录像,支持上传的
        <br />音频格式为:mp3、m4a、aac
        <br />
        视频格式为:mp4、m4v
      </template>
      <el-button type="primary" :disabled="disabled">
        上传录音录像
      </el-button>
    </el-tooltip>
  </el-upload>
  <el-dialog
    v-model="dialogVisible"
    :fullscreen="true"
    :show-close="false"
    custom-class="dispute-upload-dialog"
  >
    <div class="center">
      <div class="fz-18 ellipsis">正在上传:{
   
   { fileData.name }}</div>
      <el-progress :text-inside="true" :stroke-width="16" :percentage="percentage" />
      <el-button @click="cancel">取消上传</el-button>
    </div>
  </el-dialog>
</template>

2.2 Limit file upload type and size

const beforeUpload = (file: File) => {
    
    
  const mimeTypes = ['audio/mpeg', 'audio/x-m4a', 'audio/aac', 'video/mp4', 'video/x-m4v']
  if (!mimeTypes.includes(file.type)) {
    
    
    ElMessage({
    
    
      type: 'error',
      message: '只能上传 MP3、M4A、AAC、MP4、M4V 格式的文件',
      duration: 6000
    })
    return false
  }
  if (file.size / 1024 / 1024 / 1024 > 1.5) {
    
    
    ElMessage.error('文件大小不能超过 1.5G')
    ElMessage({
    
    
      type: 'error',
      message: '文件大小不能超过 1.5G',
      duration: 6000
    })
    return false
  }
  return true
}

2.3 Judging the file size, upload small files directly

const chunkSize = 1 * 1024 * 1024 // 切片大小
const upload = async (file: {
     
      file: File }) => {
    
    
  const fileObj = file.file
  const nameList = fileObj.name.split('.')
  fileData.value.name = fileObj.name
  fileData.value.size = fileObj.size
  fileData.value.type = fileObj.type
  fileData.value.suffix = nameList[nameList.length - 1]
  if (chunkSize > fileData.value.size) {
    
     // 文件大小小于切片大小,直接上传
    disabled.value = true
    axios
      .post('upload', fileObj) // 调用后端上传文件接口
      .then((res) => {
    
    
        ElMessageBox({
    
     message: `${
      
      fileData.value.name}上传成功`, title: '提示' })
        updateUrl(res.data) // 调用后端保存上传文件路径接口
      })
      .catch(() => ElMessageBox({
    
     message: `${
      
      fileData.value.name}上传失败`, title: '提示' })) // 上传失败弹框
      .finally(() => (disabled.value = false))
    return
  }
  batchUpload(fileObj) // 大文件切片上传
}

2.4 Obtain unique file identifiers for large files

// 重构项目没有断点续传等功能,故不需要做hash计算,只需要保证唯一即可,后端会拿这个值新建文件夹保存切片
let counter = 0
const getFileMd5 = () => {
    
    
  let guid = (+new Date()).toString(32)
  for (let i = 0; i < 5; i++) {
    
    
    guid += Math.floor(Math.random() * 65535).toString(32)
  }
  return 'wu_' + guid + (counter++).toString(32)
}

2.5 Calculate the number of slices

const percentage = ref(0)
const dialogVisible = ref(false)
const cancelUpload = ref(false)
const batchUpload = async (fileObj: File) => {
    
    
  percentage.value = 0 // 每次上传文件前清空进度条
  dialogVisible.value = true // 显示上传进度
  cancelUpload.value = false // 每次上传文件前将取消上传标识置为 false
  const chunkCount = Math.ceil(fileData.value.size / chunkSize) // 切片数量
  fileData.value.md5 = getFileMd5() // 文件唯一标识
  for (let i = 0; i < chunkCount; i++) {
    
    
    if (cancelUpload.value) return // 若已经取消上传,则不再上传切片
    const res = await uploadChunkFile(i, fileObj) // 上传切片
    if (res.code !== 0) {
    
     // 切片上传失败
      dialogVisible.value = false
      ElMessageBox({
    
     message: `${
      
      fileData.value.name}上传失败`, title: '提示' })
      return
    }
    if (i === chunkCount - 1) {
    
     // 最后一片切片上传成功
      setTimeout(() => {
    
     // 延迟关闭上传进度框用户体验会更好
        dialogVisible.value = false
        ElMessageBox({
    
     message: `${
      
      fileData.value.name}上传成功`, title: '提示' })
        axios.post('mergeUpload', {
    
     folder: fileData.value.md5 }) // 调用后端合并切片接口,参数需要与后端对齐
          .then((res) => updateUrl(res.data)) // 调用后端保存上传文件路径接口
      }, 500)
    }
  }
}

2.6 Upload slices

let controller: AbortController | null = null // 当前切片上传 AbortController
const uploadChunkFile = async (i: number, fileObj: File) => {
    
    
  const start = i * chunkSize // 切片开始位置
  const end = Math.min(fileData.value.size, start + chunkSize) // 切片结束位置
  const chunkFile = fileObj.slice(start, end) // 切片文件
  const formData = new FormData() // formData 参数需要与后端对齐
  formData.append('fileName', fileData.value.name)
  formData.append('folder', fileData.value.md5)
  formData.append('file', chunkFile, String(i + 1)) // 必传字段;若第三个参数不传,切片 filename 默认是 blob ,如果后端是以切片名称来做合并的,则第三个参数一定要传
  controller = new AbortController() // 每一次上传切片都要新生成一个 AbortController ,否则重新上传会失败
  return await axios
    .post('mergeUpload', formData, {
    
     // 调用后端上传切片接口
      onUploadProgress: (data) => {
    
     // 进度条展示
        percentage.value = Number(
          (
            (Math.min(fileData.value.size, start + data.loaded) / fileData.value.size) *
            100
          ).toFixed(2)
        )
      },
      signal: controller.signal // 取消上传
    })
    .then((res) => updateUrl(res.data))
}

2.7 Cancel upload

const cancel = () => {
    
    
  dialogVisible.value = false
  cancelUpload.value = true
  controller?.abort()
  axios.post('cancelUpload', {
    
     folder: fileData.value.md5 }) // 调用后端接口,删除已上传的切片
}

3 complete code

<template>
  <el-upload
    accept=".mp3, .m4a, .aac, .mp4, .m4v"
    :before-upload="beforeUpload"
    :http-request="upload"
    :show-file-list="false"
    :disabled="disabled"
    style="display: inline-block"
    class="m-x-12"
  >
    <el-tooltip placement="bottom">
      <template #content>
        可上传本地录音录像,支持上传的
        <br />音频格式为:mp3、m4a、aac
        <br />
        视频格式为:mp4、m4v
      </template>
      <el-button type="primary" style="font-size: 12px" :disabled="disabled">
        上传录音录像
      </el-button>
    </el-tooltip>
  </el-upload>
  <el-dialog
    v-model="dialogVisible"
    :fullscreen="true"
    :show-close="false"
    custom-class="dispute-upload-dialog"
  >
    <div class="center">
      <div class="fz-18 ellipsis">正在上传:{
   
   { fileData.name }}</div>
      <el-progress :text-inside="true" :stroke-width="16" :percentage="percentage" />
      <el-button @click="cancel">取消上传</el-button>
    </div>
  </el-dialog>
</template>

<script setup lang="ts">
import axios from 'axios'
const beforeUpload = (file: File) => {
  const mimeTypes = ['audio/mpeg', 'audio/x-m4a', 'audio/aac', 'video/mp4', 'video/x-m4v']
  if (!mimeTypes.includes(file.type)) {
    ElMessage({
      type: 'error',
      message: '只能上传 MP3、M4A、AAC、MP4、M4V 格式的文件',
      duration: 6000
    })
    return false
  }
  if (file.size / 1024 / 1024 / 1024 > 1.5) {
    ElMessage.error('文件大小不能超过 1.5G')
    ElMessage({
      type: 'error',
      message: '文件大小不能超过 1.5G',
      duration: 6000
    })
    return false
  }
  return true
}

const dialogVisible = ref(false)
const cancelUpload = ref(false)
let controller: AbortController | null = null
const chunkSize = 1 * 1024 * 1024 // 切片大小
const percentage = ref(0)
const fileData = ref({
  name: '',
  size: 0,
  type: '',
  suffix: '',
  md5: ''
})
const cancel = () => {
  dialogVisible.value = false
  cancelUpload.value = true
  controller?.abort()
  axios.post('cancelUpload', { folder: fileData.value.md5 })
}

let counter = 0
const getFileMd5 = () => {
  let guid = (+new Date()).toString(32)
  for (let i = 0; i < 5; i++) {
    guid += Math.floor(Math.random() * 65535).toString(32)
  }
  return 'wu_' + guid + (counter++).toString(32)
}

const updateUrl = (fileUrl: string) => {
  axios.post('saveUrl', {
    fileName: fileData.value.name,
    fileUrl
  })
}

const uploadChunkFile = async (i: number, fileObj: File) => {
  const start = i * chunkSize
  const end = Math.min(fileData.value.size, start + chunkSize)
  const chunkFile = fileObj.slice(start, end)
  const formData = new FormData()
  formData.append('encrypt', 'true')
  formData.append('fileName', fileData.value.name)
  formData.append('folder', fileData.value.md5)
  formData.append('file', chunkFile, String(i + 1))
  controller = new AbortController()
  return await axios
    .post('mergeUpload', formData, {
      onUploadProgress: (data) => {
        percentage.value = Number(
          (
            (Math.min(fileData.value.size, start + data.loaded) / fileData.value.size) *
            100
          ).toFixed(2)
        )
      },
      signal: controller.signal
    })
    .then((res) => updateUrl(res.data))
}

const batchUpload = async (fileObj: File) => {
  percentage.value = 0
  dialogVisible.value = true
  cancelUpload.value = false
  const chunkCount = Math.ceil(fileData.value.size / chunkSize) // 切片数量
  fileData.value.md5 = getFileMd5() // 文件唯一标识
  for (let i = 0; i < chunkCount; i++) {
    if (cancelUpload.value) return
    const res = await uploadChunkFile(i, fileObj)
    if (res.code !== 0) {
      dialogVisible.value = false
      ElMessageBox({ message: `${fileData.value.name}上传失败`, title: '提示' })
      return
    }
    if (i === chunkCount - 1) {
      setTimeout(() => {
        dialogVisible.value = false
        ElMessageBox({ message: `${fileData.value.name}上传成功`, title: '提示' })
        axios.post('mergeUpload', { folder: fileData.value.md5 }).then((res) => updateUrl(res.data))
      }, 500)
    }
  }
}

const disabled = ref(false)
const upload = async (file: { file: File }) => {
  const fileObj = file.file
  const nameList = fileObj.name.split('.')
  fileData.value.name = fileObj.name
  fileData.value.size = fileObj.size
  fileData.value.type = fileObj.type
  fileData.value.suffix = nameList[nameList.length - 1]
  if (chunkSize > fileData.value.size) {
    disabled.value = true
    axios
      .post('upload', fileObj)
      .then((res) => {
        ElMessageBox({ message: `${fileData.value.name}上传成功`, title: '提示' })
        updateUrl(res.data)
      })
      .catch(() => ElMessageBox({ message: `${fileData.value.name}上传失败`, title: '提示' }))
      .finally(() => (disabled.value = false))
    return
  }
  batchUpload(fileObj)
}
</script>

<style lang="scss">
.dispute-upload-dialog {
  background: none;
}
</style>

<style lang="scss" scoped>
.center {
  color: #fff;
  width: 50%;
  text-align: center;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
</style>

4 expansion

  • The hash value is used as the unique identifier of the file
    . Introduce the js-spark-md5 library for file hash calculation
  • Concurrent uploading of slices
    needs to control the number of concurrency
  • Instant transmission
    Request the backend interface before uploading a file, and judge whether the file has already been uploaded by the file hash value. If it exists, there is no need to upload the file again, and it will directly return the upload success to realize instant transmission.
  • Breakpoint resume
    upload Request the backend interface before uploading a slice, judge whether the slice has already been uploaded by the hash value of the slice, if it exists, there is no need to upload the slice again, and start uploading from the next slice to realize breakpoint resume upload

Guess you like

Origin blog.csdn.net/weixin_36757282/article/details/127927342