vue3 element-plus 大文件切片上传

1. 效果图:

图片上传不上去

可暂停、继续、取消上传,失败五次后停止上传

2. fileSlicingUpload.js

js 复制代码
![v2-6535bff6fb12eb865c55f826a53cf8c3_1440w.jpg](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/fe3a9d3ee65c47a79012da47020d28b0~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5p-Q5Lq655qE5bCP55y8552b:q75.awebp?rk3s=f64ab15b&x-expires=1750407150&x-signature=q%2FrQgSRxJbtX9a%2FDwd4T247FeI8%3D)
import { reactive, h, ref, computed, nextTick } from 'vue'
import { ElMessageBox, ElProgress, ElButton, ElMessage } from 'element-plus'
import axios from 'axios'
import { createTask, getPreSignedUrl, completeTask } from './upload.js'

/** worker 线程部分 开始*/

let worker = null
const workerStatus = ref('terminated')
const initWorker = (callback) => {
  if (workerStatus.value === 'terminated') {
    worker = new Worker(new URL('./fileWorker.js', import.meta.url))
    workerStatus.value = 'ready'
    console.log('worker-status', workerStatus.value)
  }

  callback && callback()

  worker.onerror = (e) => {
    console.log('worker-error : ', e)
  }

  console.log('worker-status', workerStatus.value)
}

/** worker 线程部分 结束 */

/** 开始上传文件 */
let messageBox = null
export const startUploadFile = async (file) => {
  try {
    // 初始话worker线程
    initWorker(() => {
      workerStatus.value = 'working'
    })

    const chunkSize = 1024 * 1024 * 5 // 5M
    const chunkCount = Math.ceil(file.size / chunkSize)

    messageBox = showProgressMsgDialog()
    const id = await startCreateTask(file, chunkSize)

    if (messageBox._isClosed) return

    const urls = await startGetPreSignedUrl(id, chunkCount)

    if (messageBox._isClosed) return

    const chunks = await startChunksFile(file, chunkSize, chunkCount)

    if (messageBox._isClosed) return

    const result = await startEncryptChunks(chunks)

    if (messageBox._isClosed) return

    // 等待所有上传任务完成
    await startUploadChunks(result, urls)

    // 确保所有任务完成后才执行合并
    return await startMergeChunks(id)
  } catch (e) {
    console.log('startUploadFile error: ', e)
    addProgressMsg('update', '文件上传失败...请重新上传', 100, '#f56c6c')

    resetAllStates('报错了')
  }
}

const statusStore = ref([])
const status = computed({
  get() {
    return [h('div', { style: { width: '100%' }, className: 'container' }, [...statusStore.value])]
  },
  set(val) {
    statusStore.value.push(val)
  },
})
const showProgressMsgDialog = () => {
  return ElMessageBox({
    title: '上传进度',
    closeOnClickModal: false,
    customStyle: { width: '100%' },
    customClass: 'message-box-upload',
    draggable: true,
    showClose: false,
    confirmButtonText: '确定',
    message: () => {
      return h(
        'div',
        {
          style: { display: 'flex', width: '100%' },
          class: 'message-box',
        },
        status.value,
      )
    },
    beforeClose(action, instance, done) {
      if ((uploadCurrent === uploadTotal && !uploadControl.isPaused) || uploadControl.isCancelled) {
        resetAllStates('用户主动关闭')

        nextTick(() => {
          messageBox._isClosed = true
          done()
        })
      } else {
        ElMessage.warning('当前阶段不可关闭')
      }
    },
  })
}

const addProgressMsg = (...params) => {
  const createEl = (...p) => {
    const [text, percentage, color, hEl = []] = Array.from(...p)

    return h('div', { style: { width: '100%', marginTop: '10px' } }, [
      h('p', {}, text),
      h(ElProgress, {
        type: 'line',
        percentage: percentage,
        color: color,
        style: { flex: 1, marginLeft: '10px' },
      }),
      ...hEl,
    ])
  }

  if (params[0] === 'add') {
    params.shift()
    statusStore.value.push(createEl(params))
    return
  }

  params.shift()
  const index = statusStore.value.length - 1
  statusStore.value[index] = createEl(params)
}

// 开始创建任务
const startCreateTask = async (file, chunkSize) => {
  const md5 = await fileEncryption(file, chunkSize)
  if (messageBox._isClosed) return

  try {
    const res = await createTask({
      fileMetadata: {
        name: file.name,
        size: file.size,
        md5: md5,
        open: true,
      },
      partSize: chunkSize,
    })

    if (res.code === 200) {
      addProgressMsg('update', '任务创建成功...', 100, '#67c23a')
      return res.data.id
    }
  } catch (e) {
    addProgressMsg('update', '任务创建失败...请重新上传', 100, '#f56c6c')
    throw new Error('任务创建失败...请重新上传')
  }
}

// 文件加密(对整个文件)
const fileEncryption = async (file, chunkSize) => {
  addProgressMsg('add', '文件哈希计算中...', 0, '#e6a23c')

  return new Promise((resolve, reject) => {
    let percentage = 0
    worker.onmessage = (e) => {
      switch (e.data.type) {
        case 'encryptFileProgress':
          requestAnimationFrame(() => {
            if (messageBox._isClosed) {
              return console.log('文件哈希计算...停止')
            }

            percentage = e.data.percentage
            addProgressMsg('update', '文件哈希计算中...', e.data.percentage, '#e6a23c')
          })
          break

        case 'allEncryptFileComplete':
          if (percentage === 100) {
            console.log('文件哈希计算完成')
            resolve(e.data.md5)
          } else {
            let time = setInterval(() => {
              if (percentage === 100) {
                clearInterval(time)
                time = null
                resolve(e.data.md5)
                console.log('文件哈希计算完成')
              }
            }, 500)
          }
          break
      }
    }

    worker.postMessage({
      type: 'allEncryptFile',
      file,
      chunkSize,
    })
  })
}

/** 开始获取地址 */
const startGetPreSignedUrl = async (id, chunkCount) => {
  try {
    addProgressMsg('update', '开始获取地址...', 0, '#e6a23c')
    const envType_url = 'presignedUrlLists'
    // const envType_url = 'presignedUrls';

    const res = await getPreSignedUrl({
      id,
      partNums: generateArray(chunkCount),
    })

    if (res.code === 200) {
      addProgressMsg('update', '成功获取地址...', 100, '#67c23a')

      return envType_url === 'presignedUrlLists'
        ? Object.values(res.data[envType_url]).map((item) => item[0])
        : res.data[envType_url]
    }
  } catch (e) {
    addProgressMsg('update', '获取地址失败...', 100, '#f56c6c')
    throw new Error('获取地址失败...请重新上传')
  }
}

/** 根据给定数字,生成一个数组,数组的元素是从1开始的数字 */
const generateArray = (num) => {
  const arr = []
  for (let i = 1; i <= num; i++) {
    arr.push(i)
  }
  return arr.join(',')
}

/** 开始文件切片 */
const startChunksFile = async (file, chunkSize, chunkCount) => {
  // 添加切片进度条
  addProgressMsg('add', '开始文件切片...', 0, '#409eff')

  return new Promise((resolve) => {
    let percentage = 0
    worker.onmessage = (e) => {
      switch (e.data.type) {
        case 'chunkProgress':
          requestAnimationFrame(() => {
            if (messageBox._isClosed) {
              return console.log('文件切片中...停止')
            }

            const text = `切片进度:${e.data.current}/${e.data.total}`
            percentage = Number(((e.data.current / e.data.total) * 100).toFixed(2))
            addProgressMsg('update', text, percentage, '#409eff')
          })
          break

        case 'chunkComplete':
          if (percentage === 100) {
            console.log('切片完成')
            resolve(e.data.chunks)
          } else {
            let time = setInterval(() => {
              if (percentage === 100) {
                clearInterval(time)
                time = null
                resolve(e.data.chunks)
                console.log('切片完成')
              }
            }, 500)
          }
          break
      }
    }

    worker.postMessage({
      type: 'chunk',
      file,
      chunkSize,
    })
  })
}

/** 开始对每一个切片进行加密 */
const startEncryptChunks = async (chunks) => {
  addProgressMsg('add', '准备加密...', 0, '#e6a23c')

  return new Promise((resolve) => {
    let percentage = 0
    worker.onmessage = (e) => {
      switch (e.data.type) {
        case 'encryptProgress':
          requestAnimationFrame(() => {
            if (messageBox._isClosed) {
              return console.log('文件加密中...停止')
            }
            const text = `加密进度:${e.data.current}/${e.data.total}`
            percentage = Number(((e.data.current / e.data.total) * 100).toFixed(2))
            addProgressMsg('update', text, percentage, '#e6a23c')
          })
          break

        case 'encryptComplete':
          if (percentage === 100) {
            console.log('加密完成')
            resolve(e.data.encryptedChunks)
          } else {
            let time = setInterval(() => {
              if (percentage === 100) {
                clearInterval(time)
                time = null
                resolve(e.data.encryptedChunks)
                console.log('加密完成')
              }
            }, 500)
          }
          break
      }
    }

    worker.postMessage({
      type: 'encrypt',
      chunks,
    })
  })
}

// 开始上传切片
const startUploadChunks = async (chunks, urls) => {
  const tasks = chunks.map((item, index) => {
    return {
      file: item.chunk,
      url: urls[index],
    }
  })

  return uploadFile(tasks, 3)
}

// 新增状态控制变量
const uploadControl = reactive({
  isPaused: false, // 是否暂停上传
  isCancelled: false, // 是否取消上传
  cancelTokens: new Map(), // 存储每个任务的取消令牌
  retryCounts: new Map(), // 存储每个任务的失败次数,
})

let uploadCurrent = 0
let uploadTotal = 0

// 接口发起请求时控制并发数,上传失败时需要记录失败的切片,重新上传失败的切片
const uploadFile = (tasks, maxConcurrency = 3) => {
  return new Promise((resolve) => {
    const results = []
    let queue = [...tasks]

    let activeCount = 0

    const btnEl = h(
      'div',
      {
        style: {
          marginTop: '10px',
          display: 'flex',
          gap: '10px',
        },
      },
      [
        h(
          ElButton,
          {
            type: uploadControl.isPaused ? 'success' : 'warning',
            onClick: () => {
              uploadControl.isPaused = !uploadControl.isPaused
              if (!uploadControl.isPaused) {
                run() // 恢复上传
              }
            },
          },
          {
            // 使用default插槽
            default: () => [uploadControl.isPaused ? '继续上传' : '暂停上传'],
          },
        ),

        h(
          ElButton,
          {
            type: 'danger',
            onClick: () => {
              queue = []
              uploadControl.isCancelled = true
              pauseAllUploads('用户取消上传') // 调用暂停所有上传的方法
              resetAllStates()
              ElMessageBox.alert('上传已取消', '提示')
            },
          },
          {
            default: () => ['取消上传'],
          },
        ),
      ],
    )

    addProgressMsg('add', '开始上传...', 0, '#67c23a', [btnEl])

    // 初始化上传状态
    uploadControl.isPaused = false
    uploadControl.cancelTokens.clear()

    uploadCurrent = 0
    uploadTotal = tasks.length

    let run = async () => {
      if (queue.length === 0 && activeCount === 0) {
        // 上传完成后更新状态,隐藏暂停按钮
        if (!uploadControl.isCancelled) {
          addProgressMsg('update', '上传完成...', 100, '#67c23a')
          resolve(results)
        }
        return
      }

      while (queue.length > 0 && activeCount < maxConcurrency && !uploadControl.isPaused) {
        const task = queue.shift()
        const { file, url } = task

        // 创建取消令牌
        const cancelToken = axios.CancelToken.source()
        uploadControl.cancelTokens.set(url, cancelToken)

        // 初始化重试次数
        if (!uploadControl.retryCounts.has(url)) {
          uploadControl.retryCounts.set(url, 0)
        }

        activeCount++

        try {
          const res = await axios.post(url, file, {
            headers: {
              'X-HTTP-Method-Override': 'PUT',
              'Content-Type': '',
            },
            maxContentLength: 1024 * 1024 * 10,
            maxBodyLength: 1024 * 1024 * 10,
            timeout: 0,
            cancelToken: cancelToken.token,
            onUploadProgress: (progressEvent) => {
              if (!uploadControl.isPaused) {
                const percentCompleted = Math.round(
                  (progressEvent.loaded * 100) / progressEvent.total,
                )
                addProgressMsg(
                  'update',
                  `上传进度:${uploadCurrent + 1}/${uploadTotal} (${percentCompleted}%)`,
                  parseInt((((uploadCurrent + 1) / uploadTotal) * 100).toFixed(0)),
                  '#67c23a',
                  [btnEl],
                )
              }
            },
          })

          results.push(res.data)
          uploadControl.cancelTokens.delete(url)

          uploadCurrent += 1
          addProgressMsg(
            'update',
            `上传进度:${uploadCurrent}/${uploadTotal} (100%)`,
            parseInt(((uploadCurrent / uploadTotal) * 100).toFixed(0)),
            '#67c23a',
            [btnEl],
          )

          activeCount--
          if (!uploadControl.isPaused) {
            run()
          }
        } catch (error) {
          if (!axios.isCancel(error)) {
            const retryCount = uploadControl.retryCounts.get(url) + 1
            uploadControl.retryCounts.set(url, retryCount)

            if (retryCount == 5) {
              // 超过5次重试,取消上传
              uploadControl.isCancelled = true
              ElMessageBox.alert('上传失败次数过多,已取消上传', '错误')
            } else {
              queue.push(task) // 重新加入队列重试
            }
          }

          activeCount--
          if (!uploadControl.isPaused && !uploadControl.isCancelled) {
            run()
          }
        }
      }
    }

    run()
  })
}

// 暂停所有上传的方法
const pauseAllUploads = (msg = '用户暂停上传') => {
  uploadControl.isPaused = true
  uploadControl.cancelTokens.forEach((cancelToken) => {
    cancelToken.cancel('用户暂停上传')
  })
  uploadControl.cancelTokens.clear()

  nextTick(() => {
    ElMessageBox.close()
  })
}

const resetAllStates = (msg) => {
  // 重置上传控制状态
  uploadControl.isPaused = false
  uploadControl.isCancelled = false
  uploadControl.cancelTokens.clear()
  uploadControl.retryCounts.clear()
  uploadCurrent = 0
  uploadTotal = 0

  statusStore.value = []
  workerStatus.value = 'terminated'
  worker.postMessage({ type: 'cancelAll' })
  console.log(msg)
}

// 所有任务上传完成后,进行合并
const startMergeChunks = async (id) => {
  const res = await completeTask(id)
  if (res.code === 200) {
    resetAllStates()
    ElMessageBox.close()
    ElMessageBox.alert('上传完成', '提示')
    return res.data
  }
}

3. worker.js

js 复制代码
// 导入SparkMD5库用于文件哈希计算
self.importScripts('https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js')

// 定义控制器对象,用于管理切片和加密过程的中断
const controllers = {
  chunk: new AbortController(), // 文件切片控制器
  encrypt: new AbortController(), // 加密过程控制器
  allEncryptFile: new AbortController(), // 整个文件加密控制器
}

/**
 * 带中断检查的异步处理函数
 * @param {string} type - 处理类型('chunk'或'encrypt')
 * @param {Function} processFn - 实际要执行的处理函数
 */
const processWithAbortCheck = async (type, processFn) => {
  const signal = controllers[type].signal // 获取中断信号

  try {
    await processFn(signal) // 执行处理函数
  } finally {
    // 如果操作被中断,则重置控制器
    if (signal.aborted) {
      controllers[type] = new AbortController()
    }
  }
}

/**
 * 文件切片函数
 * @param {File} file - 要切片的文件对象
 * @param {number} chunkSize - 每个切片的大小(字节)
 * @param {AbortSignal} signal - 中断信号
 * @returns {Array} 切片后的文件块数组
 */
const chunkFile = async (file, chunkSize, signal) => {
  const chunks = []
  // 计算需要的切片数量
  const chunkCount = Math.ceil(file.size / chunkSize)

  // 循环处理每个切片
  for (let i = 0; i < chunkCount; i++) {
    // 检查是否收到中断信号
    if (signal.aborted) return

    // 计算当前切片的起始和结束位置
    const start = i * chunkSize
    const end = Math.min(start + chunkSize, file.size)
    // 切片并存入数组
    chunks.push(file.slice(start, end))

    // 向主线程发送进度信息
    self.postMessage({
      type: 'chunkProgress', // 消息类型:切片进度
      current: i + 1, // 当前切片序号
      total: chunkCount, // 总切片数
    })
  }

  return chunks
}

/**
 * 文件切片加密函数
 * @param {Array} chunks - 文件切片数组
 * @param {AbortSignal} signal - 中断信号
 * @returns {Array} 加密后的切片数组
 */
const encryptChunks = async (chunks, signal) => {
  const encryptedChunks = []
  // 创建SparkMD5实例用于计算哈希
  const spark = new self.SparkMD5.ArrayBuffer()

  // 循环处理每个切片
  for (let i = 0; i < chunks.length; i++) {
    // 检查是否收到中断信号
    if (signal.aborted) return

    // 读取切片内容并计算哈希
    const buffer = await chunks[i].arrayBuffer()
    spark.append(buffer)

    // 存储加密后的切片信息
    encryptedChunks.push({
      chunk: chunks[i], // 原始切片
      hash: spark.end(), // 切片哈希值
    })

    // 向主线程发送进度信息
    self.postMessage({
      type: 'encryptProgress', // 消息类型:加密进度
      current: i + 1, // 当前加密序号
      total: chunks.length, // 总切片数
    })
  }

  return encryptedChunks
}

const allEncryptFile = async (file, chunkSize, signal) => {
  const spark = new self.SparkMD5.ArrayBuffer()
  let start = 0
  console.log('allEncryptFile - worker')
  while (start < file.size) {
    // 检查是否收到中断信号
    if (signal.aborted) return

    const chunk = file.slice(start, start + chunkSize)
    const buffer = await chunk.arrayBuffer()
    spark.append(buffer)
    start += chunkSize

    const percentage = parseInt(((start / file.size) * 100).toFixed(2))
    self.postMessage({
      type: 'encryptFileProgress', // 消息类型: 加密进度
      percentage: percentage >= 100 ? 100 : percentage,
    })
  }

  const md5 = spark.end()

  return md5
}

// 监听主线程发送的消息
self.onmessage = async (e) => {
  const { type, file, chunkSize, chunks } = e.data

  // 处理取消所有操作的请求
  if (type === 'cancelAll') {
    // 中断所有控制器
    Object.values(controllers).forEach((ctrl) => ctrl.abort())
    return
  }

  // 处理文件切片请求
  if (type === 'chunk') {
    await processWithAbortCheck('chunk', async (signal) => {
      // 执行文件切片
      const chunks = await chunkFile(file, chunkSize, signal)
      // 切片完成后发送完成消息
      self.postMessage({ type: 'chunkComplete', chunks })
    })
  }

  // 处理加密请求
  if (type === 'encrypt') {
    await processWithAbortCheck('encrypt', async (signal) => {
      // 执行切片加密
      const encryptedChunks = await encryptChunks(chunks, signal)
      // 加密完成后发送完成消息
      self.postMessage({ type: 'encryptComplete', encryptedChunks })
    })
  }

  // 对整个文件进行加密
  if (type === 'allEncryptFile') {
    await processWithAbortCheck('allEncryptFile', async (signal) => {
      const md5 = await allEncryptFile(file, chunkSize, signal)

      self.postMessage({ type: 'allEncryptFileComplete', md5 })
    })
  }
}

4. upload.js

js 复制代码
export const createTask = (data) => {
  return request({
    url: '请求地址',
    method: 'post',
    data,
  })
}


export const getPreSignedUrl = (data) => {
  return request({
    url: `请求地址`,
    method: 'post',
    data,
  })
}

// 分片上传任务完成
export const completeTask = (id) => {
  return request({
    url: `请求地址`,
    method: 'post',
    data: {
      id,
      type: 'person',
    },
  })
}
相关推荐
Thanks_ks1 分钟前
探索现代 Web 开发:从 HTML5 到 Vue.js 的全栈之旅
javascript·vue.js·css3·html5·前端开发·web 开发·全栈实战
BillKu3 分钟前
Vue3本地存储实现方案
vue.js
GIS之路5 分钟前
OpenLayers 获取地图状态
前端·javascript·html
FogLetter21 分钟前
深入理解Flex布局:grow、shrink和basis的计算艺术
前端·css
remember_me21 分钟前
前端打印实现-全网最简单实现方法
前端·javascript·react.js
前端小巷子24 分钟前
IndexedDB:浏览器端的强大数据库
前端·javascript·面试
Whbbit199924 分钟前
如何使用 Vue Router 的类型化路由
前端·vue.js
JYeontu29 分钟前
浏览器书签还能一键下载B站视频封面?
前端·javascript
陈随易29 分钟前
Bun v1.2.16发布,内存优化,兼容提升,体验增强
前端·后端·程序员
聪明的水跃鱼31 分钟前
Nextjs15 基础配置使用
前端·next.js