当前位置: 首页 > news >正文

前端 大文件分片下载上传

前端 大文件分片下载上传

背景介绍:

当前项目是给投行部门做系统,业务方需要有专门的文档中心去管理文件,包括但是不限于文件的上传和下载等等。笔者本来就是采用的浏览器表单上传的方式进行文件上传,但是谁曾想在进行稍微大一点的文件上传时,因为正式环境nginx 限制了请求体大小导致文件传不上去,所以不得不重新调整文件上传的策略。

解决方案:

采用分片上传

设计思路:

1. 文件选择与校验

  1. 用户通过前端界面选择文件,触发 fileChange方法
  2. 校验文件合法性:

​ 检查文件大小是否超过配置阈值(例如默认限制 500MB),超限则提示警告

​ 验证文件扩展名是否在黑名单(如 exesh等),禁止上传危险类型文件

  1. 若校验失败,终止流程并提示用户。
this.fileChange = (options: any) => {const file = options.file;// 1.1 校验文件大小if (file.size > Number(this.constants.UPLOAD_SIZE) * 1024 * 1024) {ElMessage.warning(`${`文件大小限制${this.constants.UPLOAD_SIZE}`}m`)return}// 1.2 校验文件类型const fileName = file.name.split('.')const fileSuffix = fileName[fileName.length - 1]if (this.prohibitArr.indexOf(fileSuffix.toLowerCase()) !== -1) {ElMessage.warning('文件类型限制')return}
}

2. 任务初始化与切片生成

创建唯一任务标识

通过文件名称、大小生成 MD5 哈希值作为 `taskId`,确保任务唯一性

文件切片

调用 `sliceFile`方法,按配置的切片大小(如默认 1MB)将文件分割为多个 `Blob`对象记录切片总数、文件总大小等元数据。

初始化任务对象

存储切片列表(`todoList`)、任务状态(`isPause`)、进度值(`progressValue`)等
// 2.1 创建任务对象
this.addTask = (data) => {const taskId = `${hex_md5(`${data.fileName}_${data.fileSize}`)}`const task = {taskId, // 文件哈希IDfileName: data.fileName,fileSize: data.fileSize,todoList: [],   // 待上传分片errorList: [],  // 失败分片flag: 0,        // 当前分片指针progressValue: 0}this.tasksList.push(task)
}// 2.2 文件切片处理
this.sliceFile = (file) => {const piece = 1024 * 1000 * Number(this.constants.UPLOAD_CHUNK_SIZE)const chunks = []let start = 0while (start < file.size) {const blob = file.slice(start, start + piece)chunks.push(blob)start += piece}return chunks
}

3. 切片上传与进度管理

构建分片请求:

​ 为每个切片创建 FormData,包含切片内容、文件名、索引、任务 ID 等元数据

​ 设置请求头(如 Authorization携带用户令牌)。

并发控制与断点续传

​ 通过递归调用 handleUpload逐个上传切片(非并行),但支持从断点索引(task.flag)继续上传

​ 上传前检查 isPause状态,支持用户暂停/继续操作

实时进度计算

​ 基于切片索引和当前切片上传进度,综合计算整体进度(避免进度条回退)

​ 通过 $emit('changeProgressValue')通知前端更新进度条

this.handleChunksUpload = (chunks, taskId) => {const task = this.getTask(taskId)chunks.forEach((chunk, index) => {const fd = new FormData()fd.append('file', chunk)             // 分片二进制fd.append('fileName', task.fileName) // 原文件名fd.append('chunk', index.toString()) // 分片序号fd.append('taskId', taskId)          // 任务IDtask.todoList.push({index,fd,config: {headers: { Authorization: `Bearer ${token}` },onUploadProgress: (progress) => { /* 进度处理 */ }}})})
}

4. 错误处理与重试机制

失败切片处理:

​ 若某切片上传失败,将其加入 errorList并继续上传后续切片

自动重试:

​ 当所有切片上传完成但 errorList非空时,触发重试(最多 UPLOAD_TRY_TIME次)

​ 超过重试次数则触发 handleUploadError事件,通知上传失败

this.handleUpload = (taskId) => {const task = this.getTask(taskId)const item = task.todoList[task.flag]axios.post(this.chunkUploadUrl, item.fd, item.config).then(() => {// 4.1 上传成功处理task.flag++ // 移动分片指针task.progressValue = Math.round((task.flag / task.todoList.length) * 100)this.$emit('changeProgressValue', { task })// 4.2 递归上传下一分片if (task.flag < task.todoList.length) {this.handleUpload(taskId)}}).catch((err) => {// 4.3 失败分片处理task.errorList.push(item)  // 加入失败列表task.flag++ // 继续后续分片// 4.4 自动重试机制if (task.flag >= task.todoList.length) {if (task.errorList.length > 0) {task.todoList = [...task.errorList] // 准备重试task.errorList = []task.flag = 0this.handleUpload(taskId) // 重启失败分片}} else {this.handleUpload(taskId) // 继续后续分片}})
}

5.合并切片与完成回调

后端自动合并

​ 所有切片成功上传后,前端无需显式请求合并(与部分方案不同),由后端根据 taskId自动合并切片

前端完成逻辑

​ 设置进度为 100%,触发最终进度更新事件。

​ 重置任务状态(清空 flagerrorList)。

​ 执行用户定义的 onSuccess回调,传递文件及响应数据。

​ 触发 handleUploadSuccess事件,通知上传完成

onUploadProgress: (progress) => {// 5.1 当前分片进度比const currentChunkProgress = progress.loaded / progress.total// 5.2 已完成分片的比例const completedRatio = task.flag / totalChunks// 5.3 整体进度合成(避免进度回退)const overallProgress = (completedRatio + (1 / totalChunks) * currentChunkProgress) * 100// 5.4 进度更新(前端显示)task.progressValue = Math.min(Math.max(task.progressValue, Math.round(overallProgress)), 99)
}

6.任务清理

​ 用户可通过 removeTask主动移除任务,释放内存资源。

​ 任务队列(tasksList)管理所有进行中的任务,支持多文件并行上传

// 6.1 全部分片成功上传
if (task.flag > task.todoList.length - 1 && task.errorList.length === 0) {task.progressValue = 100 // 最终进度this.$emit('changeProgressValue', { task })// 6.2 执行成功回调if (this.onSuccess) {this.onSuccess(response, task.file) }// 6.3 触发完成事件this.$emit('handleUploadSuccess', { task })// 6.4 重置任务状态task.flag = 0task.lastFileLoaded = 0
}

完整的代码如下:

useChunkUpload.ts

import { ElMessage } from 'element-plus'
import { hex_md5 } from './md5'const useChunkUpload = function (props: any, emit: any, instance: any) {this.isSendErrorMessage = falsethis.tryTime = 0this.flag = 0this.fileLoaded = 0this.lastFileLoaded = 0 // 上一次加载大小this.progressValue = 0 // 进度值this.persistentFailureTime = 0 // 连续失败次数this.isPause = falsethis.taskType = 'upload'this.taskId = ''this.tasksList = []this.prohibitArr = ['exe', 'c', 'java', 'jar', 'py', 'php', 'sh', 'bat']this.uploadInputDisabled = falsethis.$emit = emitthis.maxPersistentFailureTime = 8 // 最大连续失败次数this.onUploadProgress = nullthis.chunkUploadUrl = 'file/resumeUpload'this.chunkUploadProgress = ''this.constants = {UPLOAD_SIZE: 500,UPLOAD_CHUNK_SIZE: 1,DOWNLOAD_CHUNK_SIZE: 1,UPLOAD_TRY_TIME: 3,DOWNLOAD_TRY_TIME: 3,TIMEOUT: 60000,}this.onSuccess = null // 添加成功回调属性if (props.chunkUploadProps) {this.chunkUploadUrl =props.chunkUploadProps.chunkUploadUrl ?? this.chunkUploadUrlthis.chunkUploadProgress =props.chunkUploadProps.chunkUploadProgress ?? this.chunkUploadProgressif (props.chunkUploadProps.constants) {this.constants = {...this.constants,...props.chunkUploadProps.constants,}}if (props.chunkUploadProps.prohibitArr) {this.prohibitArr = [...this.prohibitArr,...props.chunkUploadProps.prohibitArr,]}if (props.onSuccess) {this.onSuccess = props.onSuccess}}this.getTasksList = () => {return (this.tasksList = [])}this.initTasksList = () => {this.tasksList = []}this.clearTasksList = () => {this.tasksList = []}this.addTask = (data) => {const taskId = `${hex_md5(`${data.fileName}_${data.fileSize}`)}`const task = {taskId, // 任务idtaskType: this.taskType, // 任务类型md5: data.md5 || '',blobs: [],todoList: [], // 待办分片列表errorList: [], // 失败列表tryTime: 0, // 失败尝试次数flag: 0, // 指针isSendErrorMessage: false, // 是否已发送错误信息isPause: false, // 暂停状态handleUpload: null, // 下载方法fileName: data.fileName, // 文件名fileSize: data.fileSize, // 文件大小file: data.file,fileLoaded: 0, // 当前加载大小lastFileLoaded: 0, // 上一次加载大小progressValue: 0, // 进度值persistentFailureTime: 0, // 连续失败次数}// 任务去重const sameTaskIndex = this.tasksList.findIndex((item) => {return item.taskId === taskId})if (sameTaskIndex === -1) {this.tasksList.push(task)} else {this.tasksList[sameTaskIndex] = task}const taskIndex = this.tasksList.length - 1this.tasksList[taskIndex]['taskIndex'] = taskIndexthis.$emit('changeCurrentTaskIndex', taskIndex, this)this.$emit('changeCurrentTaskId', task.taskId, this)this.$emit('changeCurrentTask', task, this)return task}this.getTask = (taskId) => {return this.tasksList.find((item) => item.taskId === taskId)}this.getTaskIndex = (taskId) => {return this.tasksList.findIndex((task) => task.taskId === taskId)}this.pauseTask = (taskId) => {const task = this.getTask(taskId)const taskIndex = this.getTaskIndex(taskId)if (task) {this.tasksList[taskIndex]['isPause'] = true}}this.continueTask = (taskId) => {const task = this.getTask(taskId)const taskIndex = this.getTaskIndex(taskId)if (task) {this.tasksList[taskIndex]['isPause'] = falsethis.handleUpload(taskId)}}this.removeTask = (taskId) => {const task = this.getTask(taskId)const taskIndex = this.getTaskIndex(taskId)if (task) {this.tasksList[taskIndex]['isPause'] = truethis.tasksList.splice(taskIndex, 1)}}this.handleChunksUpload = (chunks, taskId) => {const task = this.getTask(taskId)if (chunks.length === 0) {ElMessage.warning('文件太小')return null}// 计算每个分片的大小const chunkSize = chunks[0].sizeconst totalChunks = chunks.lengthconsole.log(`文件分片信息: 总大小=${task.fileSize}, 分片数=${totalChunks}, 每片大小=${chunkSize}`)const config = {headers: {contentType: false,processData: false,'X-Chunk-Upload': 'true', // 标记这是分片上传请求},config: {timeout: this.constants.TIMEOUT,// 指示该请求的错误将由组件内部处理,不需要全局错误拦截器显示错误skipGlobalErrorHandler: true,onUploadProgress: (progress) => {if (this.onUploadProgress) {this.onUploadProgress(progress, taskId)} else {// 修复进度计算逻辑console.log(`当前分片进度: loaded=${progress.loaded}, total=${progress.total}, 当前分片=${task.flag}, 总分片数=${totalChunks}`)// 当前分片的进度百分比 (0-1之间)const currentChunkProgress = progress.loaded / progress.total// 计算已完成部分的比例 (当前分片之前的所有完成分片)// 注意: 分片索引是从0开始,所以已完成的分片是task.flagconst completedChunksProgress = task.flag / totalChunks// 当前分片贡献的总体进度比例const currentChunkContribution = (1 / totalChunks) * currentChunkProgress// 总体进度 = 已完成分片的进度 + 当前分片的贡献const overallProgress = (completedChunksProgress + currentChunkContribution) * 100// 确保进度只会增加不会减少const previousProgress = task.progressValue || 0const newProgress = Math.min(Math.max(previousProgress, Math.round(overallProgress)), 99) // 保留最后1%给最终合并操作// 只有当进度有实质性变化时才更新if (newProgress > previousProgress) {task.progressValue = newProgress}console.log(`计算进度: 已完成分片=${task.flag}, 总分片数=${totalChunks}, 当前分片进度=${Math.round(currentChunkProgress * 100)}%, 总体进度=${task.progressValue}%`)// 触发进度更新事件this.$emit('changeProgressValue',{task,progressValue: task.progressValue,},this)// 如果有外部的进度回调函数,也调用它if (task.onProgress && typeof task.onProgress === 'function') {task.onProgress({loaded: task.flag * chunkSize + progress.loaded,total: task.fileSize}, task.file)}}},},}if (localStorage.getItem('token') !== null &&localStorage.getItem('token') !== '') {const Authorization = `Bearer ${localStorage.getItem('token') || ''}`config.headers['Authorization'] = Authorization}chunks.forEach((chunk, index) => {const fd = new FormData()fd.append('file', chunk)fd.append('fileName', task.fileName)fd.append('chunk', index.toString())fd.append('taskId', taskId)fd.append('size', chunk.size)fd.append('totalSize', task.fileSize)fd.append('chunkTotal', chunks.length)const item = {index: index.toString(),fd,config,}task.todoList.push(item)})this.$emit('beforeHandleUpload',{task,},this)if (this.chunkUploadProgress) {instance.appContext.config.globalProperties?.$http.post(this.chunkUploadProgress, {taskId,}).then((res) => {if (res && res.data) {const startChunkIndex = res.data.chunkconst task = this.getTask(taskId)if (startChunkIndex) {task.flag = Number(startChunkIndex)// 更新已完成的进度task.progressValue = Math.round((task.flag / totalChunks) * 100)}this.handleUpload(taskId)}})} else {this.handleUpload(taskId)}}this.handleUpload = (taskId) => {const task = this.getTask(taskId)this.$emit('handleUpload', { task }, this)const item = task.todoList[task.flag]if (task.isPause) {return null}console.log(`开始上传分片 ${task.flag + 1}/${task.todoList.length}`)instance.appContext.config.globalProperties?.$http.post(this.chunkUploadUrl, item.fd, item.config).then((res) => {const token = res.response.headers['authorization']if (token) {localStorage.setItem('token', token)}task.persistentFailureTime = 0task.flag += 1console.log(`分片 ${task.flag}/${task.todoList.length} 上传完成`)// 更新进度:当前分片完成// 确保进度只会增加不会减少,避免UI跳动const newProgress = Math.round((task.flag / task.todoList.length) * 100)task.progressValue = Math.max(task.progressValue || 0, newProgress)// 立即更新UI进度this.$emit('changeProgressValue',{task,progressValue: task.progressValue,},this)// 任务暂停if (task.isPause) returnif (task.flag > task.todoList.length - 1) {// 所有分片上传完成if (task.errorList.length === 0) {// 错误列表为空,上传成功task.progressValue = 100 // 确保最终进度为100%// 最后一次更新进度this.$emit('changeProgressValue',{task,progressValue: task.progressValue,},this)console.log('所有分片上传完成,调用成功回调')// 重置任务状态task.flag = 0task.lastFileLoaded = 0task.fileLoaded = 0// 调用成功回调if (this.onSuccess && typeof this.onSuccess === 'function') {this.onSuccess(res, task.file)}// 发出成功事件this.$emit('handleUploadSuccess', { task, response: res }, this)} else {// 有错误,需要重试if (task.tryTime >= this.constants.UPLOAD_TRY_TIME) {// 超过重试次数,上传失败if (!task.isSendErrorMessage) {this.isSendErrorMessage = truethis.$emit('handleUploadError', { task }, this)}return null} else {// 重试task.tryTime = task.tryTime + 1task.todoList = task.errorListtask.errorList = []task.flag = 0this.$emit('handleBeforeUploadNextChunk', { task }, this)this.handleUpload(task.taskId)}}} else {// 继续上传下一个分片this.$emit('handleBeforeUploadNextChunk', { task }, this)this.handleUpload(task.taskId)}}).catch((err) => {console.error(`分片 ${task.flag + 1} 上传失败:`, err)// 将失败的分片添加到错误列表task.errorList.push(item)task.flag += 1// 继续处理下一个分片或重试if (task.flag > task.todoList.length - 1) {if (task.tryTime >= this.constants.UPLOAD_TRY_TIME) {this.$emit('handleUploadError', { task }, this)} else {task.tryTime += 1task.todoList = task.errorListtask.errorList = []task.flag = 0this.handleUpload(task.taskId)}} else {this.handleUpload(task.taskId)}})}this.fileChange = (options: any) => {this.$emit('beforeUploadFile', options, this)// 如果options中传入了onSuccess,则使用它if (options.onSuccess && typeof options.onSuccess === 'function') {this.onSuccess = options.onSuccess}// 保存进度回调函数if (options.onProgress && typeof options.onProgress === 'function') {this.onProgress = options.onProgress}const file = options.fileif (file) {if (file.size > Number(this.constants.UPLOAD_SIZE) * 1024 * 1024) {ElMessage.warning(`${`文件大小限制${this.constants.UPLOAD_SIZE}`}m`)return}const fileName = file.name.split('.')if (fileName.length) {const fileSuffix = fileName[fileName.length - 1]if (this.prohibitArr.indexOf(fileSuffix.toLowerCase()) !== -1) {ElMessage.warning('文件类型限制')return}}const data = {fileName: file.name,fileSize: file.size,file,}const task = this.addTask(data)// 将回调函数保存到任务中if (options.onProgress) {task.onProgress = options.onProgress}const chunks = this.sliceFile(file)this.handleChunksUpload(chunks, task.taskId)}}this.sliceFile = (file,piece = 1024 * 1000 * Number(this.constants.UPLOAD_CHUNK_SIZE)) => {const totalSize = file.size // 文件总大小let start = 0 // 每次上传的开始字节let end = start + piece // 每次上传的结尾字节const chunks = []while (start < totalSize) {// 根据长度截取每次需要上传的数据// File对象继承自Blob对象,因此包含slice方法const blob = file.slice(start, end)chunks.push(blob)start = endend = start + piece}return chunks}this.clearInputFile = (f) => {// f = f || this.$refs.uploadif (f.value) {try {f.value = '' // for IE11, latest Chrome/Firefox/Opera...// eslint-disable-next-line no-empty} catch (err) {}if (f.value) {// for IE5 ~ IE10const form = document.createElement('form')const ref = f.nextSiblingform.appendChild(f)form.reset()ref.parentNode.insertBefore(f, ref)}}}this.createTaskId = (file, time) => {return `${time}-${file.size}-${file.name.length > 100? file.name.substring(file.name.length - 100, file.name.length): file.name}`}this.randomString = (len) => {len = len || 32const $chars ='ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678' /** **默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/const maxPos = $chars.lengthlet pwd = ''for (let i = 0; i < len; i++) {pwd += $chars.charAt(Math.floor(Math.random() * maxPos))}return pwd}
}export { useChunkUpload }

使用方式如下:

import { useChunkUpload } from "@/utils/useChunkUpload";...
// 初始化分片上传
const chunkUpload = new useChunkUpload(
{headers: { Authorization: token },data: { blockName: "uploadTest" },
},
emit,
instance,
);
......
// 大文件分片上传处理
const handleChunkUpload = (options) => {
const file = options.file;// 调用分片上传
const uploadPromise = chunkUpload.fileChange(options);// 存储上传任务,用于暂停/恢复
uploadTasks.set(file.uid, uploadPromise);// 返回 abort 对象,符合 el-upload 的要求
return {abort() {console.log("分片上传已中止");// 中止上传逻辑const task = uploadTasks.get(file.uid);if (task && task.abort) {task.abort();}},
};
};...
http://www.dtcms.com/a/337998.html

相关文章:

  • 宝塔面板多Python版本管理与项目部署
  • excel表格 Vue3(非插件)
  • day25|学习前端js
  • Linux: RAID(磁盘冗余阵列)配置全指南
  • 损失函数与反向传播 小土堆pytorch记录
  • FPGA-Vivado2017.4-建立AXI4用于单片机与FPGA之间数据互通
  • 计算机组成原理(9) - 整数的乘除法运算
  • js计算两个经纬度之间的角度
  • Python字符串连接与合并工程实践:从基础到高性能解决方案
  • 【笔记】位错的定义和分类
  • B站 韩顺平 笔记 (Day 22)
  • 【人工智能】2025年AI代理失控危机:构建安全壁垒,守护智能未来
  • 规避(EDR)安全检测--避免二进制文件落地
  • 面向对象爬虫进阶:类封装实现高复用爬虫框架​
  • DP-v2.1-mem-clean学习(3.6.9-3.6.12)
  • Python 爬虫实战:玩转 Playwright 跨浏览器自动化(Chromium/Firefox/WebKit 全支持)
  • 嵌入式第三十二课!!线程间的同步机制与进程间的通信(IPC机制)
  • PotPlayer使用AI生成字幕和API实时翻译
  • Redis中LRU与LFU的底层实现:字节级的精巧设计
  • 树莓派安装python第三方库如keras,tensorflow
  • day35-负载均衡
  • 智能化合同处理与知识应用平台:让合同从 “管得住” 到 “用得好”
  • C15T3
  • openssl加密里面的pem格式和rsa有什么区别?
  • 财务分析师如何提升自身专业能力:突破职业瓶颈的五年进阶规划
  • nestjs配置@nestjs/config 入门教程
  • 股票常见K线
  • 群晖nas中 打开PHP连接MariaDB 功能扩展
  • JavaSE——高级篇
  • 处理手表步数和分析用户步数数据