上传文件详细设计

设计背景

  • 老文件上传为jquery编写的,需要引入jquery文件以及核心js文件
  • 文件上传依赖的库很多年没有更新维护,可能会遇到难以维护的bug
  • 重构基于vue3,使用组件化开发模式,使得代码更加模块化和可维护,避免引入jquery等额外库和依赖
  • 使用TypeScript能够更好地进行代码提示和静态类型检查
  • 增加上传的可定制化能力和可扩展能力
  • 使上传文件模块更加轻量化,保留核心必要逻辑

需求背景

  • 支持多线程进行文件上传,文件上传等候排队
  • 支持分片上传,可通过配置开启关闭
  • 支持文件秒传
  • 定制化文件上传url,以及文件上传完成通知接口url
  • 支持文件上传进度实时回调,文件总进度分进度
  • 支持文件错误重传
  • 支持文件自定义校验
  • 支持上传文件个性化区域定制
  • 兼容旧版文件文件上传服务,可以实现无感替换

整体设计框架

外部交互流程
image-20231205152319989

上传文件系统对外主要就是初始化配置以及文件合规校验:

  1. 用户token,用于调起上传接口
  2. 可接受的文件类型,array类型有几种类型”Image”, “Video”,”Audio”, “PDF”,”Word”, “Excel”, “PPT”;image-20231205153302740
  3. 根据传入的配置进行类型比对,获取到文件类型后缀,mimeTypes用于读取文件前在文件目录中展示可选择的文件,其他类型的进行隐藏,extensions用于读取文件后对文件类型的二次校验
  4. 验证文件大小是否符合
  5. 都校验通过后进行记录文件基本信息(文件名,读取进度,上传进度),丢入文件池准备上传
在读文件的方法中获取到当前文件的index

由于读取文件是使用的回调函数的形式,添加额外的参数就需要使用闭包

1
2
3
4
5
6
7
8
9
10
11
12
13
<el-upload
:before-upload="beforeUpload(index)"
multiple
class="flexCenter"
:accept="acceptFileType"
:http-request="httpRequest"
v-else
>
<div class="upload-placeholder">
<img src="@/assets/uploadImg/icon_import.png" alt="" />
<el-button type="primary">点击选择文件</el-button>
</div>
</el-upload>

比如我希望在:before-upoad这个回调函数中,传一个自己的参数index

1
2
3
4
5
const beforeUpload = (index) => {
return (file) => {
// 处理file
}
}

另一种方式就是

1
:before-upload="(file)=> beforeUpload(file, index)"

这种方式更加简洁

内部交互流程

image-20231205164446207

image-20231205170706522

上传内部主要分为获取文件md5、文件池处理、文件分片处理、文件分片上传

具体设计
文件读取进度回调

组件需要实现实时进度展示,所以需要通过js将目前的文件读取进度告诉调用方

首先需要在upload类中对回调函数进行声明,之后在外部对该回调函数进行重写

1
2
3
4
5
6
// 文件扫描进度回调(默认为undeined,需要外部调用进行注册重写)
public onScanProgressUpdate: Function | undefined = undefined;
// 文件上传进度回调(默认为undeined,需要外部调用进行注册重写)
public onUploadProgressUpdate: Function | undefined = undefined;
// 文件上传完成回调(默认为undeined,需要外部调用进行注册重写)
public onUploadComplete: Function | undefined = undefined;

在文件读取fileReader的回调函数onprogress触发后,会拿到必要的数据,判断组件调用方是否编写了对应的回调函数,如果已经编写了对应函数,则执行回调

1
2
3
4
5
6
7
8
9
10
11
12
fileReader.onprogress = (e) => {
const progress = (e.loaded / file.size) * 100;

if (
this.onScanProgressUpdate &&
Object.prototype.toString.call(this.onScanProgressUpdate) ===
"[object Function]"
) {
// 取整
this.onScanProgressUpdate(Math.trunc(progress), fileIndex);
}
};
文件池处理

整体思路,因为upload 会被调用多次,每次被调用就会向fileList中加入新的文件

此时仅需要判断目前新增的这个文件是否需要立即上传,如果不需要,则排队等待上传对比文件池currnetUploadFileIndex与当前fileList的长度,如果+1 = 长度,则需要立即上传

1
2
3
4
5
6
7
8
9
10
public async addFile(file: File) {
this.fileList.push(file);
if (this.currentUploadFileIndex + 1 === this.fileList.length) {
// 上传操作
this.upload();
} else {
// 等待排队
return;
}
}

因为外部可能会通过addFile添加很多待上传文件,此时通过currentUploadFileIndex与文件池长度比较,保证每个文件只会被上传一次

只上传当前index的文件,首先获取md5,之后进行分片上传,上传完成之后递归调用上传下一个文件,如果没有文件了就会停止递归

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async upload() {
// 上传currentUploadFileIndex的文件
if (this.fileList[this.currentUploadFileIndex]) {
const md5 = await this.getHashByFile(
this.fileList[this.currentUploadFileIndex],
this.currentUploadFileIndex
);
await this.getChunks(this.fileList[this.currentUploadFileIndex], md5);
console.log(`上传完成这是第${this.currentUploadFileIndex + 1}个文件`);

this.currentUploadFileIndex++;
// 递归将所有文件都上传完
this.upload();
} else {
return;
}
}
控制同时上传的数量

将每一个分片上传的promise放入request队列中,当并发请求数量超过阈值时,将通过promise.race()等待最先完成的请求,每完成上传一次,就从requests数组中将该请求移除,这样就可以保证同时上传的分片数量在期望数量范围

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 自定义函数,控制并发上传的数量
async function controlConcurrency(uploadPromise: Promise<void>) {
requests.push(uploadPromise);
if (requests.length >= maxConcurrentRequests) {
// 如果当前并发请求数量超过阈值,等待最先完成的请求
await Promise.race(requests);
}
}

for (let i = 0; i < totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const chunk = file.slice(start, end);
const uploadPromise = this.uploadChunk(chunk, uploads[i])
.then(() => {
// 当上传请求完成时,从requests数组中移除该请求
const index = requests.indexOf(uploadPromise);
if (index !== -1) {
requests.splice(index, 1);
}
uploadedChunksNumber++;
// 更新上传进度
if (
this.onUploadProgressUpdate &&
Object.prototype.toString.call(this.onUploadProgressUpdate) ===
"[object Function]"
) {
// 取整
this.onUploadProgressUpdate(
Math.trunc((uploadedChunksNumber / totalChunks) * 100),
this.currentUploadFileIndex
);
}
})
.catch((error) => {
console.error("上传失败:", error);
// 在此处处理上传失败的情况
});

await controlConcurrency(uploadPromise);
}

最后通过promise.all()等候所有上传请求完成,进行上传完成后的逻辑