前言
Element Plus 中已提供了一个 upload 组件来实现普通文件上传的需求,但是遇到大文件上传的时候,就有点显得力不从心了:
由于文件大,上传一般都需要数分钟时间,用户一般都无法忍受干等这么长时间
若中途断网或浏览器崩溃,只能重新从头开始再上传一次,导致用户心态崩溃
本文主要就基于 Element plus 的 upload 组件来封装实现一个支持分片上传的上传控件。
原理解析
分片上传
其原理其实就是在客户端将文件分割成多个小的分片,然后再将这些分片一片一片的上传给服务端,服务端拿到所有分片后再将这些分片合并起来还原成原来的文件。
那服务端怎么知道我合并出来的文件是否和服务端上传的文件完全一样呢?这就需要用到文件的MD5值了。文件的MD5值就相当于是这个文件的“数字指纹”,只有当两个文件内容完全一样时,他们的MD5值才会一样。所以在上传文件前,客户端需要先计算出文件的MD5值,并且把这MD5值传递给服务端。服务端在合并出文件后,在计算合并出的文件的MD5值,与客户端传递过来的进行比较,如果一致,则说明上传成功,若不一致,则说明上传过程中可能出现了丢包,上传失败。
断点续传
断点续传其实是利用分片上传的特性,上次上传中断时,已经有部分分片已上传到服务端,这部分就可以不用重复上传了。
文件秒传
文件秒传其实是利用文件的MD5值作为文件的身份标识,服务端发现要上传的文件的MD5与附件库中的某个文件的MD5值完全一样,则要上传的文件已在附件库中,不用再重复上传。
代码实现
计算文件 MD5
计算文件的 MD5 值,我们直接用现成的三方插件 SparkMD5 即可。
由于计算 MD5 是一个比较耗时的操作,我们用 Web Workers 把它放到后台去运行。
// file-md5-worker.ts import { UploadRawFile } from 'element-plus'; import SparkMD5 from 'spark-md5'; type MD5MessageType = { file?: UploadRawFile; uid: number; cancel?: boolean; }; // 正在处理的文件记录,当值为 false 时,表示被终止了。 const progressingFilesMap = new Map<number, boolean>(); // 用 web worker 来处理文件 MD5 的计算 self.addEventListener('message', (e: MessageEvent) => { const { file, uid, cancel } = e.data as MD5MessageType; if (cancel && progressingFilesMap.has(uid)) { // 将正在处理的文件标识设置成 false,以备在 getFileMd5 方法中进行终止 progressingFilesMap.set(uid, false); } else if (file) { // 开始计算 DM5 getFileMd5(file, uid) .then((md5) => { // 计算完成,发送通知 self.postMessage({ status: 'success', uid, md5, }); }) .catch((error) => { self.postMessage({ status: 'failed', uid, error, }); }); } }); /** * 获取文件MD5,采用分片的模式读取文件,最后合并生成 MD5 * @param file * @returns {Promise<unknown>} */ const getFileMd5 = (file: UploadRawFile, uid: number) => { const fileReader = new FileReader(); const chunkSize = 1024 * 1024; // 分片大小 const chunks = Math.ceil(file.size / chunkSize); // 总分片数 const updateProgress = getProgress(chunks, uid); let currentChunk = 0; const spark = new SparkMD5(); progressingFilesMap.set(uid, true); return new Promise((resolve, reject) => { fileReader.onload = (e) => { spark.appendBinary(e.target!.result as string); // append binary string updateProgress(++currentChunk); if (progressingFilesMap.get(uid) === false) { // 被终止,则移除 progressingFilesMap.delete(uid); reject('Be cancelled'); } else if (currentChunk < chunks) { // 未结束,则处理下一个分片 loadNext(); } else { // 处理完成 progressingFilesMap.delete(uid); resolve(spark.end()); } }; fileReader.onerror = (e) => { reject(e); }; const loadNext = () => { const start = currentChunk * chunkSize; const end = start + chunkSize >= file.size ? file.size : start + chunkSize; fileReader.readAsBinaryString(file.slice(start, end)); }; loadNext(); }); }; /** * 生成进度 * @param totalChunk 总片数 * @param uid 文件的 uid * @returns 更新进度,参数 processedChunk 为已处理的分片数 */ const getProgress = (totalChunk: number, uid: number) => { let preTime = 0, percent = 0; return (processedChunk: number) => { const now = Date.now(); // 控制触发间隔大于 500 毫秒 // 当已经结束,就马上触发 if (now - preTime > 500 || processedChunk >= totalChunk) { percent = Math.min(1, processedChunk / totalChunk) * 100; percent = parseFloat(percent.toFixed(1)); // 发送进度通知 self.postMessage({ status: 'progress', uid, percent, }); preTime = now; } }; };
在主程序中,我们使用方式如下:
const worker = new Worker( new URL('./file-md5.worker', import.meta.url), { type: 'module' } ); // 监听 worker 的消息 worker.addEventListener('message', async (e: MessageEvent) => { const data = e.data; if (data.status === 'success') { // 计算成功,开始上传 } else if (data.status === 'progress') { // 计算过程中,更新进度 } }); // 开始计算 worker.postMessage({ file, uid: file.uid, }); // 终止计算 worker.postMessage({ uid, cancel: true, });
分片上传
通过分析 Element Plus 的 upload 组件,我们可以通过个性化它的上传请求属性 http-request
,在里面来实现整个分片的请求过程。
根据上面的原理分析,整个分片上传的过程应该是:
计算文件 MD5
发送查询文件上传状态请求
根据响应,上传还未上传的分片
所有分片上传结束后,发送一个结束上传的请求,告知服务端
upload 控件的配置如下:
<template> <el-upload :action="action" v-model:file-list="fileList" :http-request="handleRequest" > </el-upload> </template>
handleRequest
方法的实现如下:
const doUploadMap = new Map<number, (md5: string) => void>(); // 记录具体某个文件的上传执行方法 const md5Progress = ref<Record<number, number>>({}); // 记录文件的上传进度 const md5Map = new Map<number, string>(); // 记录文件的 MD5 值 const handleRequest = (options: UploadRequestOptions) => { const file = options.file as UploadRawFile; // 上传文件方法 const doUploadFn = async (md5: string) => { // 将 MD5 值放到请求参数里 options.data.fileMD5 = md5; const uploadFile = fileList.value.find((f) => f.uid === file.uid)!; // 查询文件上状态 const { fileFinished, existFileParts, attachGuid, uploadDate, downloadUrl, } = ( await queryFileStatus(options, { fileMD5: md5, fileName: file.name, fileSize: file.size, lastModifiedDate: new Date(file.lastModified).toISOString(), uploadGuid: file.uid, }) ).result; if (fileFinished) { // 已上传过,直接结束(秒传) uploadFile.percentage = 100; updateFileStatusToSuccess(uploadFile, { attachGuid, downloadUrl, uploadDate, }); } else { // 上传分片 await uploadChunks(options, existFileParts.split(',')); // 发送结束上传请求 const { uploadFailed, failedMsg, result } = await finishedChunkUpload( options, { fileMD5: md5, fileName: file.name, fileSize: file.size, lastModifiedDate: new Date(file.lastModified).toISOString(), uploadGuid: file.uid, contentType: file.type, } ); const { attachGuid, uploadDate, downloadUrl } = result; if (uploadFailed) { uploadFile.status = 'fail'; } else { updateFileStatusToSuccess(uploadFile, { attachGuid, downloadUrl, uploadDate, }); } } }; // 获取文件 MD5 const md5 = md5Map.get(file.uid); if (md5) { // MD5 已计算过,直接用 md5Progress.value[file.uid] = 100; doUploadFn(md5); } else { // 通知 worker 计算 MD5 worker.postMessage({ file, uid: file.uid, }); // 将上传方法缓存起来,以备 MD5 计算完成后调用 doUploadMap.set(file.uid, doUploadFn); } }; // worker 的监听 worker.addEventListener('message', async (e: MessageEvent) => { const data = e.data; if (data.status === 'success') { // MD5 计算完成, 开始上传 md5Map.set(data.uid, data.md5); await doUploadMap.get(data.uid)?.(data.md5); doUploadMap.delete(data.uid); } else if (data.status === 'progress') { // 计算中,更新进度 md5Progress.value[data.uid] = data.percent; } });
其中查询文件上传状态方法 queryFileStatus
定义如下:
const queryFileStatus = ( options: UploadRequestOptions, params: Record<string, any> ) => { const clonedOptions = cloneDeep(options); Object.assign(clonedOptions.data, params); clonedOptions.data.action = 'queryFileStatus'; // 发送请求 return ajax<{ result: { fileFinished: boolean; existFileParts: string; } }>(clonedOptions); };
结束上传方法 finishedChunkUpload
的定义如下:
const finishedChunkUpload = ( options: UploadRequestOptions, params: Record<string, any> ) => { const clonedOptions = cloneDeep(options); Object.assign(clonedOptions.data, params); clonedOptions.data.action = 'finishUpload'; // 发送请求 return ajax<{ uploadFailed: boolean; failedMsg?: string; result: { attachGuid: string; uploadDate: string; downloadUrl: string; }; }>(clonedOptions); };
上传分片方法 uploadChunks
定义如下:
const uploadChunks = ( options: UploadRequestOptions, uploadedList: string[] = [] ) => { const rawFile = options.file as UploadRawFile; // 创建分片列表 const chunkList = createFileChunk(rawFile, realChunkSize.value); const totalChunkNum = chunkList.length; const uploadFile = fileList.value.find((f) => f.uid === file.uid)!; uploadFile.status = 'uploading'; uploadFile.percentage = (uploadedList.length / totalChunkNum) * 100; return new Promise((resolve) => { // 构建分片的请求列表 const requestList = chunkList .filter(({ index }) => !uploadedList.includes(index)) // 过滤掉已上传的分片(断点续传) .map(({ file, index }) => { const clonedOptions = cloneDeep(options); // 重写上传进度的计算实现 clonedOptions.onProgress = (evt: UploadProgressEvent) => { const percent = evt.percent; const prePercent = chunkProgressMap.get(Number.parseInt(index, 10)) ?? 0; chunkProgressMap.set(Number.parseInt(index, 10), percent); uploadFile.percentage = Math.min( 100, Number.parseFloat( ( (percent - prePercent) / totalChunkNum + uploadFile.percentage! ).toFixed(1) ) ); }; clonedOptions.onSuccess = () => {}; clonedOptions.file = file as File; clonedOptions.data.chunk = index; clonedOptions.data.chunks = String(totalChunkNum); clonedOptions.data.chunkSize = String(realChunkSize.value); clonedOptions.data.action = 'chunk'; // 返回请求处理方法 return (index: number) => ((ajax(clonedOptions) as XMLHttpRequest).onloadend = () => { notFinishedChunkSet.delete(index); chunkProgressMap.set(index, 100); // 完成后继续下一片的请求 uploadNext(); }); }); let curIndex = 0; // 控制最多同时只能上传 3 个分片 const l = Math.min(requestList.length, 3); const notFinishedChunkSet = new Set<number>(); const chunkProgressMap = new Map<number, number>(); for (let i = 0; i < requestList.length; i++) { notFinishedChunkSet.add(i); } // 继续下一片的请求 const uploadNext = () => { if (notFinishedChunkSet.size === 0) { resolve('finished'); } else if (curIndex < requestList.length) { requestList[curIndex](curIndex); curIndex++; } }; // 开始发送请求,可最多同时发送 3 个请求 for (; curIndex < l; curIndex++) { requestList[curIndex](curIndex); } }); }; /** * 生成文件切片 * @param {*} file 上传的文件 * @param {*} size 分片大小 * @return {*} */ const createFileChunk = (file: UploadRawFile, size: number) => { const fileChunkList: { file: Blob; index: string; }[] = []; let cur = 0, index = 0; while (cur < file.size) { fileChunkList.push({ file: Object.assign(file.slice(cur, cur + size), { name: file.name, type: file.type, lastModified: file.lastModified, }), index: String(index), }); cur += size; index++; } return fileChunkList; };
总结
本文以 Element Plus 的 upload 组件为基础,介绍了大文件的分片上传、断点继续、秒传设计思路和实现代码。