流程
- 获取文件MD5,请求后端文件是否已上传,返回已上传的分片列表;
- 根据分片参数(分片大小和片数)开始分片,并上传分片文件,更新上传进度;
- 上传完成,请求后端进行文件合并并返回文件URL。
实现
需要用到的插件有 axios、 element-plus、spark-md5
以下代码有些是伪代码,后续会更新补全更新到 github
vue Template
<template>
<el-upload
class="upload-wrap"
ref="uploadRef"
:auto-upload="false"
action="/"
:on-change="onFileChange"
:on-exceed="handleExceed"
:limit="1">
<el-button>
<i class="iconfont icon-upload" />
文件上传
</el-button>
<template #tip>
<div class="tip">仅允许上传1个文件,大小不超100M,多个附件请以压缩包上传</div>
</template>
<template #file="{ file }">
<div class="el-upload-list__item-info">
<i class="el-icon el-icon-document"></i>
<span>{{ file.name }}</span>
</div>
<i class="el-icon el-icon-close" @click="handleDelete"></i>
<label class="el-icon el-upload-list__item-status-label">
<i class="el-icon-upload-success el-icon-circle-check" v-if="currentFile.percentage >= 100"></i>
</label>
<el-progress :percentage="currentFile.percentage" :status="currentFile.status === 'fail' ? 'exception' : ''" />
</template>
</el-upload>
</template>
<style lang="less" scoped>
.upload-wrap {
:deep .el-upload-list {
width: 33%;
max-height: 32px;
overflow-y: hidden;
}
}
</style>
选择文件并上传
// 选择文件
const onFileChange = (file: any) => {
state.currentFile = file;
state.currentFile.chunkList = [];
if (file.size > maxSize) {
window.$message.error('文件大小不得超过100M');
state.uploadRef.clearFiles();
} else {
handleUpload();
}
};
const handleExceed = (files: any[]) => {
state.uploadRef.clearFiles();
const file = files[0];
state.uploadRef.handleStart(file);
};
// 分片上传文件
const handleUpload = async () => {
// 1. 计算MD5
const md5 = await getFileMd5(state.currentFile.raw) as string;
if (!md5) return;
// 2. 正在创建分片
const fileChunks = createFileChunk(state.currentFile.raw, chunkSize);
fileChunks.map((chunkItem, index) => {
state.currentFile.chunkList.push({
chunkNumber: index + 1,
chunkSize: chunkSize,
currentChunkSize: chunkItem.file.size,
totalChunks: state.totalChunks,
identifier: md5,
filename: state.currentFile.name,
totalSize: state.currentFile.size,
file: chunkItem.file,
progress: 0,
status: {}
});
});
// 3. 接口检查是否已上传,如已上传返回上传过的分片索引列表,若未上传过则返回null
// 过滤已经上传的分片
const uploadedResult = await checkFileUploadedByMd5({
chunkNumber: 0,
chunkSize: chunkSize,
currentChunkSize: 0,
totalChunks: state.totalChunks,
identifier: md5,
filename: state.currentFile.name,
totalSize: state.currentFile.size
});
if (uploadedResult) {
// 获取已上传分片列表
state.currentFile.chunkUploadedList = uploadedResult.uploaded;
}
state.currentFile.chunkList = filterUploadChunkList(state.currentFile.chunkList);
let uploadResult = true;
if (state.currentFile.chunkList.length === 0) { // 所有分片全部已上传,立即完成(秒传)
state.currentFile.status = FileStatusMap.success;
state.currentFile.percentage = 100;
} else {
// 4. 上传分片
state.currentFile.status = FileStatusMap.uploading;
uploadResult = await uploadChunkList(state.currentFile.chunkList);
}
if (!uploadResult) {
window.$message.error('文件上传失败');
state.currentFile.status = FileStatusMap.error;
return;
}
// 5. 接口获取文件路径
await getAttachmentUrl({
identifier: md5,
filename: state.currentFile.name,
totalChunks: state.totalChunks
}).then((res: BINet.Resp) => {
const mergeResult = res.code === 200;
if (!mergeResult) {
window.$message.error('获取文件路径失败');
state.currentFile.status = FileStatusMap.error;
} else {
state.currentFile.status = FileStatusMap.success;
state.attachedUrl = res.desc || '';
}
});
};
1. 获取文件MD5
npm install spark-md5 --save
import SparkMD5 from 'spark-md5';
/**
* 分片读取文件 MD5
*/
const getFileMd5 = (file: Blob) => {
const blobSlice = File.prototype.slice || (File.prototype as any).mozSlice || (File.prototype as any).webkitSlice;
const fileReader = new FileReader();
// 计算分片数
state.totalChunks = Math.ceil(file.size / chunkSize);
console.log('总分片数:' + state.totalChunks);
let currentChunk = 0;
const spark = new SparkMD5.ArrayBuffer();
function loadNext () {
const start = currentChunk * chunkSize;
const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
// 注意这里的 fileRaw
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
return new Promise((resolve: Function) => {
fileReader.onload = (e: ProgressEvent<FileReader>) => {
try {
spark.append((e.target as FileReader).result as ArrayBuffer);
} catch (error) {
console.log('获取Md5错误:' + currentChunk);
}
if (currentChunk < state.totalChunks) {
currentChunk++;
loadNext();
} else {
resolve(spark.end());
}
};
fileReader.onerror = () => {
window.$message.error('读取Md5失败,文件读取错误');
resolve('');
};
});
};
2. 创建分片
const createFileChunk = (file: Blob, size = chunkSize) => {
const fileChunkList = [];
let count = 0;
while (count < file.size) {
fileChunkList.push({
file: file.slice(count, count + size)
});
count += size;
}
return fileChunkList;
};
3. 过滤已上传分片
const filterUploadChunkList = (chunkList: any[]) => {
const chunkUploadedList = state.currentFile.chunkUploadedList;
if (chunkUploadedList === undefined || chunkUploadedList === null || chunkUploadedList.length === 0) {
return chunkList;
}
for (let i = chunkList.length - 1; i >= 0; i--) {
const chunkItem = chunkList[i];
for (let j = 0; j < chunkUploadedList.length; j++) {
if (chunkItem.chunkNumber === chunkUploadedList[j]) {
chunkList.splice(i, 1);
break;
}
}
}
return chunkList;
};
4. 上传分片并更新上传进度
const uploadChunkList = (chunkList: any[]): Promise<boolean> => {
let successCount = 0;
const retryArr = [] as number[]; // 数组存储每个文件hash请求的重试次数,做累加 比如[1,0,2],就是第0个文件切片报错1次,第2个报错2次
return new Promise((resolve: Function) => {
const handler = () => {
if (!chunkList.length) {
// 删除并取消上传
chunkList.length = 0;
resolve(true);
} else {
const chunkItem = chunkList.shift();
const formdata = new FormData();
[
'chunkNumber', 'chunkSize', 'currentChunkSize', 'totalChunks', 'identifier', 'filename', 'totalSize', 'file'
].forEach(key => {
formdata.append(key, chunkItem[key]);
});
uploadAttachment(
url: 'uploadAttachment',
methods: 'post',
headers: {
'Content-Type': ''
},
data: formdata,
{
payload: {
onUploadProgress: (p: any) => {
chunkItem.progress = parseInt(String((p.loaded / p.total) * 100));
updateChunkUploadStatus(chunkItem);
}
}
}
).then((uploadResult: any) => {
if (uploadResult) {
// 上传成功,继续上传下一个分片
successCount++;
handler();
} else { // 上传失败
if (typeof retryArr[chunkItem.chunkNumber] !== 'number') {
retryArr[chunkItem.chunkNumber] = 0;
}
// 累加错误次数
retryArr[chunkItem.chunkNumber]++;
// 最多重试 maxRetryCount 次
if (retryArr[chunkItem.chunkNumber] >= maxRetryCount) {
// 多次重试失败,取消上传
chunkList.length = 0;
state.currentFile.status = FileStatusMap.error;
resolve(false);
}
// 重新添加到队列中
chunkList.push(chunkItem);
handler();
}
});
}
if (successCount >= state.totalChunks) {
resolve(true);
}
};
// 并发
for (let i = 0; i < simultaneousUploads; i++) {
handler();
}
});
};
5. 更新进度和状态
const getCurrentFileProgress = () => {
if (!state.currentFile || !state.currentFile.chunkList) return;
const chunkList = state.currentFile.chunkList;
const uploadedSize = chunkList.map((item: any) => item.file.size * item.progress).reduce((acc: any, cur: any) => acc + cur);
// 计算方式:已上传大小 / 文件总大小
const progress = parseInt((uploadedSize / state.currentFile.size).toFixed(2));
state.currentFile.percentage = progress;
};
const updateChunkUploadStatus = (item: any) => {
let status = FileStatusMap.uploading;
let progressStatus = 'normal';
if (item.progress >= 100) {
status = FileStatusMap.success;
progressStatus = 'success';
}
const chunkIndex = item.chunkNumber - 1;
const currentChunk = state.currentFile.chunkList[chunkIndex];
// 修改状态
currentChunk.status = status;
currentChunk.progressStatus = progressStatus;
// 获取文件上传进度
getCurrentFileProgress();
};