前端 大文件分片下载上传
前端 大文件分片下载上传
背景介绍:
当前项目是给投行部门做系统,业务方需要有专门的文档中心去管理文件,包括但是不限于文件的上传和下载等等。笔者本来就是采用的浏览器表单上传的方式进行文件上传,但是谁曾想在进行稍微大一点的文件上传时,因为正式环境nginx 限制了请求体大小导致文件传不上去,所以不得不重新调整文件上传的策略。
解决方案:
采用分片上传
设计思路:
1. 文件选择与校验
- 用户通过前端界面选择文件,触发
fileChange
方法 - 校验文件合法性:
检查文件大小是否超过配置阈值(例如默认限制 500MB),超限则提示警告
验证文件扩展名是否在黑名单(如 exe
、sh
等),禁止上传危险类型文件
- 若校验失败,终止流程并提示用户。
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%,触发最终进度更新事件。
重置任务状态(清空 flag
、errorList
)。
执行用户定义的 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();}},
};
};...