最近開發過程中,有一個大文件分片上傳的功能,借鑒於網上的思路,結合自己后端的邏輯,完成了這個功能,在此記錄如下:
界面展示:

一、將大文件分片上傳寫為一個組件,可以全局注冊該組件,也可以在使用的頁面注冊該組件,使用vuex通訊進行組件間傳值
由於我有好幾個頁面需要使用大文件上傳,所以我是在App.vue注冊該組件
<template>
<a-config-provider :locale="locale">
<div id="app">
<router-view />
<!-- 將分片上傳組件全局注冊 -->
<big-uploader></big-uploader>
</div>
</a-config-provider>
</template>
<script>
...
import BigUploader from './components/bigUploader.vue';
export default {
data () {
return {
...
};
},
components: { BigUploader },
...
};
</script>
二、文件選擇上傳框通過文件上傳前的鈎子,會有項目的業務邏輯判斷,校驗文件空間是否還有,判斷文件是否為重復文件,提示是覆蓋還是重新上傳,校驗都通過后就去調起大文件上傳的組件
beforeUpload(file) { const self = this let size = file.size let params = { fileSize: size, projectCode: self.projectCode } if (size === 0) { this.$message.error('上傳文件不能為0KB') return false } const fileName = file.name let fileType = fileName.split('.') fileType = fileType[fileType.length - 1].toLowerCase()//轉小寫 if (this.accept.indexOf(fileType) == -1) { this.$message.error('請上傳指定文件類型') return false; } if (this.uploadFlag) return false; this.uploadFlag = true return new Promise(() => { try { // 一、校驗文件空間 request(xxx).then(res => { if (res.code === 200) { // 二、計算MD5 self.calculate(file, async function (md5) { const par = { fileName: file.name, md5Value: md5, parantCode: self.parentCode, projectCode: self.projectCode } // 三、文件覆蓋檢查 request(xxx).then(res => { console.log(res) if (res.code === 200 && res.data) { //同名同類型提示是否覆蓋 self.$confirm({ title: false, content: '該文件夾下存在同名同類型的文件,是否確認覆蓋?', onOk() { // 覆蓋 self.implementSetFile(file, md5) }, onCancel() { self.handelModelCancal(); }, }); } else { // 新文件 self.implementSetFile(file, md5) } return false }) }) } }).catch(() => { self.uploadFlag = false return false; }) } catch { self.uploadFlag = false return false; } }) }, implementSetFile(file, md5) { // 設置當前上傳文件 this.setFile({ page: this.curMenuName.name, bucket: 'privately', projectCode: this.projectCode, parentCode: this.parentCode, record: this.record, file: file, md5: md5 }) // 設置顯示上傳框 this.setShowBigUpload(true) // this.$emit('ok'); this.uploadFlag = false this.$emit('closeUpload') }, /** * 計算md5,實現斷點續傳及秒傳 * @param file */ calculate(file, callBack) { var fileReader = new FileReader(), blobSlice = File.prototype.mozSlice || File.prototype.webkitSlice || File.prototype.slice, chunkSize = 2097152, // read in chunks of 2MB chunks = Math.ceil(file.size / chunkSize), currentChunk = 0, spark = new SparkMD5(); fileReader.onload = function (e) { spark.appendBinary(e.target.result); // append binary string currentChunk++; if (currentChunk < chunks) { loadNext(); } else { callBack(spark.end()); } }; function loadNext() { var start = currentChunk * chunkSize, end = start + chunkSize >= file.size ? file.size : start + chunkSize; fileReader.readAsBinaryString(blobSlice.call(file, start, end)); } loadNext(); },
三、大文件組件
data中定義當前上傳下標,上傳並發數,允許每個分片最多允許重傳數,上傳文件列隊數組等值
通過mapGetters獲取vuex值
...mapGetters({
showUploadBox: `${NAMESPACE_UPLOAD}/${GET_SHOW_BIG_UPLOAD}`,
file: `${NAMESPACE_UPLOAD}/${GET_FILE}`,
chunkSize: `${NAMESPACE_UPLOAD}/${GET_CHUNKSIZE}`,
threads: `${NAMESPACE_UPLOAD}/${GET_THREADS}`,
getUplodRes: `${NAMESPACE_UPLOAD}/${GET_UPLOAD_RES}`,
}),
通過監聽file變化,觸發大文件上傳排隊
watch: { file: { immediate: true, handler: function (newVal) { if (newVal) { const tempMd5 = newVal.md5 // 判斷文件是否在排隊中 if (this.upFileObj[tempMd5]) { this.$message.warn('當前文件已經在上傳排隊中,請不要重復選擇') } else { newVal.fileIndex = this.uploadFiles.length this.upFileObj[tempMd5] = newVal this.uploadQueue(tempMd5) } } } } }
通過uploadQueue組件上傳排隊當前文件的分片,通過md5校驗資源是否已經存在,記錄資源是否存在和是否需要上傳
/* * 上傳排隊 * 計算當前文件的Md5和分片 */ uploadQueue(md5) { console.log('1.當前文件上傳排隊') const self = this; const tempFile = self.upFileObj[md5] const fileName = tempFile.file.name const curFileSize = tempFile.file.size self.handleInitRawFile(tempFile); // 檢驗資源是否存在 request(xxx).then(res => { if (res.code === 200) { var resData = res.data const fileIndex = tempFile.fileIndex const filesArr = self.uploadFiles; if (resData) { // 資源已經存在 console.log('3.上傳文件已經存在', resData) filesArr[fileIndex].Already = true filesArr[fileIndex].resData = resData self.$set(filesArr, fileIndex, filesArr[fileIndex]); } else { // 需要上傳 let needChunk = false // 不需要分片 if (curFileSize < self.chunkSize) { needChunk = false } else { needChunk = true } filesArr[fileIndex].needChunk = needChunk self.$set(filesArr, fileIndex, filesArr[fileIndex]); } // 判斷是否正在上傳中 if (self.uploadLock) { console.log('有文件正在上傳中...') } else { self.beforeUpload() } } }) }, /* ** 初始化部分自定義上傳屬性 */ handleInitRawFile(rawFile) { console.log('2.初始化部分自定義上傳屬性') rawFile.status = fileStatus.md5; rawFile.initFail = this.file rawFile.chunkList = []; rawFile.uploadProgress = 0; rawFile.fakeUploadProgress = 0; // 假進度條,處理恢復上傳后,進度條后移的問題 rawFile.hashProgress = 0; this.uploadFiles.push(rawFile); },
文件正式上傳前,判斷資源是否存在直接秒傳,是否需要上傳,上傳的話是否需要進行分片,秒傳和不需要分片都是簡單的業務邏輯,下面展示一下主要的分片代碼
需要分片,組件分片上傳數組
/* ** 開始組建上傳數組 */ async handleUpload() { console.log('6.開始組建上傳數組') if (!this.uploadLock) return; const filesArr = this.uploadFiles; const fileUpIdx = this.fileUpIdx const fileChunkList = this.createFileChunk(filesArr[fileUpIdx].file); if (filesArr[fileUpIdx].status !== 'resume') { this.status = Status.hash; // hash校驗,是否為秒傳 filesArr[fileUpIdx].hash = await this.calculateHash(fileChunkList); // 若清空或者狀態為等待,則跳出循環 if (this.status === Status.wait) { console.log('若清空或者狀態為等待,則跳出循環'); return } } this.status = Status.uploading; filesArr[fileUpIdx].status = fileStatus.uploading; filesArr[fileUpIdx].fileHash = filesArr[fileUpIdx].hash; // 文件的hash,合並時使用 filesArr[fileUpIdx].chunkList = fileChunkList.map(({ file }, index) => ({ fileHash: filesArr[fileUpIdx].hash, fileName: filesArr[fileUpIdx].file.name, index, hash: filesArr[fileUpIdx].hash + '-' + index, chunk: file, size: file.size, uploaded: false, // 標識:是否已完成上傳 progress: 0, status: 'wait' // 上傳狀態,用作進度狀態顯示 })); this.$set(filesArr, fileUpIdx, filesArr[fileUpIdx]); ... },
分片-初始化任務的時候,將分片數組及文件md5傳給后端,后端返回該文件上傳的任務id,及沒有上傳的片段數組及小片段id,已經上傳的片段,實現秒傳效果,這里就是斷點續傳的主要邏輯思路
initJob() { console.log('7.分片-初始化任務') const filesArr = this.uploadFiles; const fileUpIdx = this.fileUpIdx const uploadFileMd5 = filesArr[fileUpIdx].md5 let detailList = [] filesArr[fileUpIdx].chunkList.forEach((item, idx) => { detailList.push({ extInfo: '', // file: item.chunk, num: idx }) }) const params = { md5HashValue: uploadFileMd5, detailList: detailList } request(xxx).then(res => { console.log('分片-初始化任務', res) if (res.code === 200) { const resData = res.data if (resData.jobStatus === 1) { console.log('上傳文件已經存在', resData) } else { let sliceList = [] this.jobCode = resData.jobCode resData.sliceList.forEach(item => { const tt = { ...item, ...filesArr[fileUpIdx].chunkList[item.num] } sliceList.push(tt) }) // this.sliceList = sliceList filesArr[fileUpIdx].chunkList = sliceList this.$set(filesArr, fileUpIdx, filesArr[fileUpIdx]); //沒有上傳的分片 let noUpSlice = [], yesUpSlice = []; sliceList.filter((item, idx) => { item.num = idx if (item.uploadStatus === 0) { noUpSlice.push({ ...item }); } else { yesUpSlice.push({ ...item }); } }) // const noUpSlice = sliceList.filter(({ uploadStatus }) => uploadStatus === 0) console.log('沒有上傳的分片', noUpSlice) if (noUpSlice.length === 0) { this.mergeRequest(); } else { if (yesUpSlice.length > 0) { yesUpSlice.forEach(item => { this.createProgresshandler(100, item.num) }) } this.uploadChunks(noUpSlice, sliceList); } } } }) },
將切片傳輸給服務端,進行並發上傳處理,所有的分片上傳完成,向服務端進行合並請求
async uploadChunks(data, allData) { console.log('8.將切片傳輸給服務端') return new Promise(async (resolve, reject) => { const requestDataList = data.map(({ sliceCode, num, chunk }) => { const formData = new FormData(); formData.append('sliceCode', sliceCode); formData.append('file', chunk); return { formData, num }; }) try { const ret = this.sendRequest(requestDataList, data, allData); } catch (error) { // 上傳有被reject的 this.$message.error('親 上傳失敗了,考慮重試下呦' + error); return; } // 合並切片 const isUpload = data.some((item) => item.uploadStatus === 0); if (!isUpload) { // 執行合並 try { await this.mergeRequest(); resolve(); } catch (error) { reject(); } } }); }, sendRequest(forms, chunkData, allData) { console.log('9.分片-並發上傳處理') var finished = 0; const total = forms.length; const that = this; const retryArr = []; // 數組存儲每個文件hash請求的重試次數,做累加 比如[1,0,2],就是第0個文件切片報錯1次,第2個報錯2次 // const md5Hash = allData[0].fileHash const filesArr = this.uploadFiles; const fileUpIdx = this.fileUpIdx const md5Hash = filesArr[fileUpIdx].md5 return new Promise((resolve, reject) => { const handler = () => { if (forms.length) { // 出棧 const formInfo = forms.shift(); const formData = formInfo.formData; const index = formInfo.num; /* *** 開始分片上傳 */ // console.log('當前分片上傳', allData[index]) if (allData[index]) { request(xxx) .then(res => { // 更改狀態 allData[index].uploaded = true; allData[index].uploadStatus = 1; allData[index].status = 'success'; finished++; if (finished === chunkData.length) { // 執行合並 this.mergeRequest(); } handler(); }).catch((e) => { // 若狀態為暫停或等待,則禁止重試 if ([Status.pause, Status.wait].includes(this.status)) return; console.warn('出現錯誤', e); console.log('當前分片上傳報錯', retryArr); if (typeof retryArr[index] !== 'number') { retryArr[index] = 0; } // 更新狀態 allData[index].status = 'warning'; // 累加錯誤次數 retryArr[index]++; // 重試3次 if (retryArr[index] >= this.chunkRetry) { console.warn(' 重試失敗--- > handler -> retryArr', retryArr, allData[index].hash); return reject('重試失敗', retryArr); } // console.log('handler -> retryArr[finished]', `${allData[index].hash}--進行第 ${retryArr[index]} '次重試'`); // console.log(retryArr); this.tempThreads++; // 釋放當前占用的通道 // 將失敗的重新加入隊列 forms.push(formInfo); handler(); }) } } if (finished >= total) { resolve('done'); } }; // 控制並發 for (let i = 0; i < this.tempThreads; i++) { handler(); } }); },
通知服務端合並切片,設置總的文件進度,並設置上傳結果,回傳給頁面展示新增文件,進行下一步業務操作
在此大文件上傳的整個思路就完成了。
