背景
最近有一个上传文件方面的需求,上传图片,用户可以选择文件夹上传。
文件夹里的图片可能很多,而且由于特殊的项目背景,用户选择的图片可能会比较大,10MB左右。
这里就需要做一些方案上的设计了,确保整个过程的流畅和容错。
方案设计
这个过程中需要考虑到的细节主要是以下几点:
- 重复图片的认定
- 图片上传任务的控制
- 上传失败和上传中断处理
图片去重
hash
图片去重这一块,我们可以在前端做hash,但是对于10MB左右的图片,走hash的话,每一张大概要花费30-60ms不等的时间,如果每张图片都先算hash,1000张这样大小的图片就要等待30s~60s,然后才能开始进行上传任务,我们觉得这个等待时间有点久了。
文件路径 + 文件名 + 文件大小
由于我们的项目支持文件夹选择上传,再递归遍历文件夹的过程中,我们是可以拿到图片的相对路径的,再加上图片的文件名,以及文件的size,我们决定就将这三者条件的联合作为图片是否上传过的对比凭证。
那么这样一来,在选择了文件之后,就可以立马得到这些信息,那么此时就可以把整个文件列表传给后端,后端返回哪些文件重复,哪些文件不重复。
这样就能快速拿到我们需要进行上传的图片列表了。
对文件做浏览器持久化缓存
为什么要在前端做持久化的缓存?
多文件的上传任务是一个耗时的任务,这个过程可能会出现不确定的情况,比如网页关了,或者浏览器关了,而前端无法记住文件在客户机器上的位置,如果没有持久化的存储,那么用户就必须手动再去选择一下文件或者文件夹,用户体验没那么友好。
那么我们就需要一个像localStorage
那样的存储,浏览器关了也不会消失的存储。
既然这样说了,那localStorage
肯定是不行的,因为它无法保存file类型的数据,也就是无法保存文件,就算能保存图片,大小也不够,5M左右的大小,都装不下一张图片。
我们用indexDB
来做文件的存储。
上传过程的并发控制
前面两步设计好了,下面就该发上传的请求了,1000张图片,不可能走一个请求的,我们让每个图片走一个上传请求,也就是发1000个请求来上传这些图片。
如果用的是HTTP1.1,就要考虑并发控制的问题了,因为你不可能1000个请求一次性无脑发出去,那么这样,用户就必须等1000个上传任务完成,才能操作页面,因为接下来的网络请求都要排队,要等1000个图片上传完。
我们的设计是,上传过程中,用户不用干等,可以比较流畅的进行其他操作,留一个任务列表展示当前的任务状态。
如果用的是HTTP2.0, 虽然可以不用做并发控制,浏览器会把其他的请求提前,但是也要等当前的某个请求结束,因为并发数也是有限的,实测每个可能会等待个3s以内。
而且因为这个任务的主要瓶颈是1000个10MB图片的上传,是带宽。所以HTTP2.0的优势,多路复用,头部压缩,并不能很好的体现出来,也就是说总任务时长差不多。所以我们最终没有升级成HTTP2.0,还是用的HTTP1.1。
对HTTP1.1,chrome浏览器对同一个域名的并发数最大是6,所以并发数可以设置为5,留一点网络资源给前台任务。
失败和中断处理
失败和中断的任务主要还是依赖了本地持久化存储。
我们在第一次拿到后端返回的需要上传的文件列表的时候,就应该把这个文件列表加上文件本身存进indexDB中,然后做上传,成功上传一个,就从indexDB中删除一条,那么最后留下来的,就是没有成功的。
当然也可以在上传任务中加入catch,这样可以对失败的任务做自动重传,这个不是必须的。
部分实现
对indexDB操作的封装
classFileStorage{
private db: IDBDatabase | null = null
private dbName = 'fileStore'
private storeName = 'files'
private openCallback: () =>void
constructor(open = () => {
}) {
this.openCallback = open
this.openDatabase()
}
// 打开或创建数据库
private openDatabase(): void {
const request = indexedDB.open(this.dbName, 1)
request.onupgradeneeded = (event) => {
this.db = (event.target as IDBOpenDBRequest).result
if (!this.db.objectStoreNames.contains(this.storeName)) {
this.db.createObjectStore(this.storeName, {
keyPath: 'id',
autoIncrement: true
})
}
}
request.onsuccess = (event) => {
this.db = (event.target as IDBOpenDBRequest).result
this.openCallback()
}
request.onerror = (event) => {
console.error('Error opening database:', event)
}
}
// 插入整个 fileList 数组
public async insertFileList(fileList: fileInfoWithId[]): Promise<void> {
const fileContents = awaitPromise.all(
fileList.map((file) =>this.readFileAsArrayBuffer(file.raw)