最近开发过程中,有一个大文件分片上传的功能,借鉴于网上的思路,结合自己后端的逻辑,完成了这个功能,在此记录如下:
界面展示:
一、将大文件分片上传写为一个组件,可以全局注册该组件,也可以在使用的页面注册该组件,使用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(); } }); },
通知服务端合并切片,设置总的文件进度,并设置上传结果,回传给页面展示新增文件,进行下一步业务操作
在此大文件上传的整个思路就完成了。