前端 大文件分片下載上傳
背景介紹:
當前項目是給投行部門做系統,業務方需要有專門的文檔中心去管理文件,包括但是不限于文件的上傳和下載等等。筆者本來就是采用的瀏覽器表單上傳的方式進行文件上傳,但是誰曾想在進行稍微大一點的文件上傳時,因為正式環境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();}},
};
};...