Vue3 + Element Plus 实现大文件切片上传及进度条展示

1 前言

最近在做一个项目的重构,其中有大文件上传的功能,由于项目是几年前,代码没有前后分离,用的是 jQuery + webuploader 库做的,但实际上只是实现了大文件切片上传,并没有切片并发、秒传及断点续传功能,后端也不支持,且 webuploader 库已经不再维护了,故决定自己实现一个最简单的大文件切片上传功能。

2 功能实现

2.1 添加 Element Plus 上传代码及进度条展示代码

<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 限制文件上传类型和大小

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 判断文件大小,小文件直接上传

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 大文件获取文件唯一标识

// 重构项目没有断点续传等功能,故不需要做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 计算切片数量

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 上传切片

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 取消上传

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

3 完整代码

<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 拓展

  • hash 值作为文件唯一标识
    引入 js-spark-md5 库做文件 hash 计算
  • 切片并发上传
    需要控制好并发数量
  • 秒传
    上传文件前请求后端接口,通过文件 hash 值判断否存已经上传过该文件,存在则无需再上传该文件,直接返回上传成功,实现秒传
  • 断点续传
    上传切片前请求后端接口,通过切片 hash 值判断是否已经上传过该切片,存在则无需再上传该切片,从下一个切片开始上传,实现断点续传

猜你喜欢

转载自blog.csdn.net/weixin_36757282/article/details/127927342