1.问题:
使用a-upload组件上传文件时,超过大小200m的文件无法上传
2.解决办法:
文件分片上传
3.代码部分
//spark-md5 是一个用于计算 MD5 值的库,支持对字符串和数组缓冲区(ArrayBuffer)进行 MD5 计算。
import SparkMD5 from 'spark-md5'
const DEFAULT_SIZE = 20 * 1024 * 1024 //设置每次读取文件时的块大小。20 * 1024 * 1024 表示 20MB
// fileMd5用于计算文件的 MD5 值。它接受两个参数:
// file:要计算 MD5 值的文件对象。
// chunkSize:每个分块的大小,默认值为 DEFAULT_SIZE(20MB)。
const fileMd5 = (file, chunkSize = DEFAULT_SIZE) => {
return new Promise((resolve, reject) => {
const startMs = new Date().getTime(); //记录开始计算 MD5 的时间
//blobSlice获取文件对象的 slice 方法。不同浏览器可能有不同的实现方式,这里做了兼容性处理,确保在所有浏览器中都能正确分块文件。
let blobSlice =
File.prototype.slice ||
File.prototype.mozSlice ||
File.prototype.webkitSlice;
//计算文件总共需要分成多少块
let chunks = Math.ceil(file.size / chunkSize);
//初始化当前正在处理的块编号,从 0 开始
let currentChunk = 0;
let spark = new SparkMD5.ArrayBuffer(); //追加数组缓冲区。
let fileReader = new FileReader(); //读取文件
//onload函数在文件块读取完成时触发
fileReader.onload = function (e) {
//将当前块的数组缓冲区内容追加到 SparkMD5 实例中,用于计算 MD5 值。
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
//文件块未处理完成继续函数
loadNext();
} else {
const md5 = spark.end(); //完成md5的计算,返回十六进制结果。
console.log('文件md5计算结束,总耗时:', (new Date().getTime() - startMs) / 1000, 's')
//调用 resolve 将计算结果返回给调用者
resolve(md5);
}
};
//当读取文件块时发生错误时,调用 reject 将错误传递给调用者
fileReader.onerror = function (e) {
reject(e);
};
//继续处理函数
function loadNext() {
console.log('当前part number:', currentChunk, '总块数:', chunks);
let start = currentChunk * chunkSize;
let end = start + chunkSize;
(end > file.size) && (end = file.size);
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
});
}
// 解释:定义一个函数 md5,用于计算字符串的 MD5 值。它接受两个参数:
// fileName:文件名。
// fileSize:文件大小。
const md5 = (fileName, fileSize) => {
let md5Hash = '';
const combinedString = fileName + '' + fileSize;
// 使用 SparkMD5 计算字符串的 MD5 值
const spark = new SparkMD5();
spark.append(combinedString);
md5Hash = spark.end();
return md5Hash;
}
export default md5
这段代码提供了两种计算 MD5 值的方法:
- 文件的 MD5 值计算:通过分块读取文件并逐块计算 MD5,适用于大文件,避免一次性加载整个文件到内存中。
- 字符串的 MD5 值计算:直接对文件名和文件大小的组合字符串进行 MD5 计算,适用于快速生成文件的唯一标识。
import { defHttp } from '@/utils/http/axios';
const basicApi = '/file/minio/chuncked';
//定义一个枚举 Api,包含所有文件分片上传相关的接口路径。
// TASK_INFO:初始化任务或获取任务信息的接口。
// SELECT_TASK:获取任务列表的接口。
// INIT_TASK:初始化分片上传任务的接口。
// MERGE:合并分片的接口。
enum Api {
TASK_INFO = basicApi + '/init',
SELECT_TASK = basicApi + '/minioList',
INIT_TASK = basicApi,
MERGE = basicApi + '/merge',
}
export interface UploadPageIM {
identifier: string;
fileName: string;
totalSize: any;
chunkSize: number;
isPublic: string;
groupId: string;
}
export interface PreSignUrlIM {
identifier: string;
partNumber: string;
}
/**
* 根据文件的md5获取未上传完的任务
* @param identifier 文件md5
* @returns {Promise<AxiosResponse<any>>}
*/
export const taskInfo = (identifier) =>
defHttp.get<any>({ url: Api.INIT_TASK + '/' + identifier });
/**
* 获取所有的url地址信息用于展示
*/
export const selectTask = (groupId) =>
defHttp.get<any>({ url: Api.SELECT_TASK + '/' + groupId });
/**
* 初始化一个分片上传任务
* @param identifier 文件md5
* @param fileName 文件名称
* @param totalSize 文件大小
* @param chunkSize 分块大小
* @returns {Promise<AxiosResponse<any>>}
*/
export const initTask = (params: UploadPageIM) =>
defHttp.post({ url: Api.TASK_INFO, params });
/**
* 获取预签名分片上传地址
* @param fileId 文件md5
* @param partNumber 分片编号
* @returns {Promise<AxiosResponse<any>>}
*/
export const preSignUrl = (fileId, partNumber) =>
defHttp.get<any>({ url: Api.INIT_TASK + '/' + fileId + '/' + partNumber });
/**
* 合并分片
* @param fileId
* @returns {Promise<AxiosResponse<any>>}
*/
export const merge = (fileId) =>
defHttp.post({ url: Api.MERGE + '/' + fileId });逐行解释
<template>
<div class="upload-page-style w-full h-full">
<a-upload //ant design vue 上传组件
v-model:file-list="fileList" //绑定文件列表,用于显示上传的文件状态
name="file" //上传文件的字段名。
:headers="headers" //上传时携带的 HTTP 头。
:progress="progress"//自定义上传进度条的样式
@change="handleChange"
:custom-request="handleHttpRequest" //自定义上传事件
:on-remove="handleRemoveFile" //文件被移除时的回调函数
drag
>
<upload-outlined></upload-outlined>
<span class="pl-4 cursor-pointer">请点击此处上传</span>
</a-upload>
</div>
</template>
<script lang="ts" setup>
import axios from 'axios';
import { Card, message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { ref, onMounted } from 'vue';
import Queue from 'promise-queue-plus';
import type { UploadChangeParam, UploadProps } from 'ant-design-vue';
import md5 from '../../api/uploadpage/md5';
import { taskInfo, initTask, preSignUrl, merge, selectTask } from '../../api/uploadpage/uploadpage.api';
import { buildShortUUID } from '@/utils/uuid';
const fileUploadChunkQueue = ref({}).value //存储每个文件的上传队列
const fileList = ref([]);
const minioList = ref([]);
const progress: UploadProps['progress'] = {
strokeColor: {
'0%': '#108ee9',
'100%': '#87d068',
},
strokeWidth: 3,
format: percent => `${parseFloat(percent)}%`,
class: 'test',
};
const headers = { authorization: 'authorization-text' };
// 更新文件列表中的上传进度
const updateFileListProgress = (uid: string, percent: number) => {
const file = fileList.value.find((item) => item.uid === uid);
if (file) {
file.percent = percent;
if(file.percent == 100) {
file.status ='done';
}
}
};
/**
* 自定义上传方法入口
*/
const handleHttpRequest = async (options) => {
const file = options.file; //当前上传的文件对象
try {
// 获取或初始化上传任务
const task = await getTaskInfo(file); //调用 getTaskInfo 函数,获取或初始化上传任务
console.log('task',task);
if (!task) {
throw new Error('任务初始化失败');
}
const { finished, taskRecord, path } = task; //解构
// 如果任务已完成,直接返回成功
if (finished) {
options.onSuccess(path, file); //调用 options.onSuccess 回调函数
return;
}
// // 执行分片上传
await handleUpload(file, taskRecord, options);
// // 合并分片
await merge(taskRecord.fileId).then(() => {
message.success(`${file.name} 文件上传成功`);
options.onSuccess('上传成功', file);
file.status = 'done';
}).catch(() => {
message.error(`${file.name} 文件上传失败`);
options.onError(new Error('文件上传失败'));
});
} catch (error) {
message.error(`${file.name} 上传失败: ${error.message}`);
options.onError(error);
}
};
/**
* 获取一个上传任务,没有则初始化一个
*/
const getTaskInfo = async (file) => {
const identifier = await md5(file.name, file.size); //计算文件的 MD5 值
//const identifier = (file.name + '' + file.size).substring(1,32);
const msg = await taskInfo(identifier);
if(msg) {
return msg;
}
// 初始化新任务
const initTaskData = {
md5: identifier,
fileName: file.name,
totalSize: file.size,
chunkSize: 10 * 1024 * 1024, // 每个分片大小
isPublic: '0',
// groupId: buildShortUUID(),
groupId: '_66771548521732506784121',
};
try{
const initResult = await initTask(initTaskData);
console.log('initResult',initResult);
return initResult;
}catch(error) {
message.error('文件上传错误');
}
throw new Error('文件上传错误');
};
/**
* 上传逻辑处理,如果文件已经上传完成(完成分块合并操作),则不会进入到此方法中
*/
const handleUpload = async (file, taskRecord, options) => {
const { chunkSize, chunkNum, fileId, exitPartList = [] } = taskRecord;
let lastUploadedSize = 0; // 上次断点续传时上传的总大小
let uploadedSize = 0; // 已上传的大小
const totalSize = file.size || 0; // 文件总大小
const startMs = new Date().getTime(); // 上传开始时间
// 获取从开始上传到现在的平均速度(byte/s)
const getSpeed = () => {
// 已上传的总大小 - 上次上传的总大小(断点续传)= 本次上传的总大小(byte)
const intervalSize = uploadedSize - lastUploadedSize;
const nowMs = new Date().getTime();
// 时间间隔(s)
const intervalTime = (nowMs - startMs) / 1000;
return intervalSize / intervalTime; // 返回速度 (byte/s)
};
const uploadNext = async (partNumber) => {
//TODO 不知道为什么chunkSize类型是字符型
const start = chunkSize * (partNumber - 1);
const end = (start - 0) + (chunkSize - 0);
const blob = file.slice(start, end); //获取当前分片的内容
console.log('start:'+start+' end:'+end)
const signalData = await preSignUrl(fileId,partNumber);//调用 preSignUrl 函数,获取预签名的上传地址
//获取预签名的上传地址
let signalUrl = signalData?.url;
//如果获取到了上传地址
if (signalUrl) {
await axios.request({
url: signalUrl,
method: 'PUT',
data: blob,
headers: {'Content-Type': 'application/octet-stream'}
})
return Promise.resolve({ partNumber: partNumber, uploadedSize: blob.size })
} else {
return Promise.reject(`分片${partNumber}, 获取上传地址失败`)
}
};
/**
* 更新上传进度
* @param increment 为已上传的进度增加的字节量,increment:当前分片的大小
*/
const updateProcess = (increment) => {
const { onProgress } = options
let factor = 1000; // 每次增加1000 byte
let from = 0;
// 通过循环一点一点的增加进度
while (from <= increment) {
from += factor
uploadedSize += factor
const percent = Math.round(uploadedSize / totalSize * 100).toFixed(2);
onProgress({percent: percent})
updateFileListProgress(file.uid, percent)
}
const speed = getSpeed();
const remainingTime = speed != 0 ? Math.ceil((totalSize - uploadedSize) / speed) + 's' : '未知'
console.log('全部大小', totalSize)
console.log('已上传大小', uploadedSize)
console.log('剩余大小:', (totalSize - uploadedSize) / 1024 / 1024, 'mb');
console.log('当前速度:', (speed / 1024 / 1024).toFixed(2), 'mbps');
console.log('预计完成:', remainingTime);
}
return new Promise(resolve => {
const failArr = []; //存储失败的分片
const queue = Queue(5, { //初始化一个上传队列,最大并发数为 5
"retry": 3, //每个分片的最大重试次数
"retryIsJump": false, //是否立即重试
"workReject": function(reason,queue){ //处理失败的分片
failArr.push(reason)
},
"queueEnd": function(queue){ //队列结束时的回调函数
resolve(failArr);
}
})
//将队列存储到 fileUploadChunkQueue
fileUploadChunkQueue[file.uid] = queue
for (let partNumber = 1; partNumber <= chunkNum; partNumber++) {
const exitPart = (exitPartList || []).find(exitPart => exitPart.partNumber == partNumber)
if (exitPart) {
// 分片已上传完成,累计到上传完成的总额中,同时记录一下上次断点上传的大小,用于计算上传速度
lastUploadedSize += exitPart.size
updateProcess(exitPart.size)
} else {
queue.push(() => uploadNext(partNumber).then(res => {
// 单片文件上传完成再更新上传进度
updateProcess(res.uploadedSize)
}))
}
}
if (queue.getLength() == 0) {
// 所有分片都上传完,但未合并,直接return出去,进行合并操作
resolve(failArr);
return;
}
queue.start()
})
};
/**
* 移除文件列表中的文件
* 如果文件存在上传队列任务对象,则停止该队列的任务
*/
const handleRemoveFile = (file) => {
// 如果有对应上传队列,停止其上传
if (fileUploadChunkQueue[file.uid]) {
fileUploadChunkQueue[file.uid].stop();
delete fileUploadChunkQueue[file.uid];
}
message.info(`已移除文件 ${file.name}`);
};
</script>
这段代码实现了一个完整的文件分片上传功能,包括:
- 文件列表管理:通过
fileList
管理上传的文件列表。 - 自定义上传逻辑:通过
handleHttpRequest
自定义上传逻辑。 - 任务初始化:通过
getTaskInfo
获取或初始化上传任务。 - 分片上传:通过
handleUpload
处理分片上传。 - 进度更新:通过
updateFileListProgress
更新文件列表中的上传进度。 - 文件移除:通过
handleRemoveFile
移除文件列表中的文件。
总结:
涵盖了文件分片的基本做法,具体接口要看前后端商议,这里的后端接口的要求基本也在注释标注,个人理解,文件分片是切割+合并,md5方法可以拿过去直接用,文件唯一性标识也可以根据项目而定。