【实践真知】大文件分片上传及相关优化

需求背景

关于分片上传,是个老生常谈的话题,但并不是每个项目中都会采用,为什么不用,以及为什么用,都是需要考虑清楚的。

上传文件往往需要走这么几个步骤:

  • 判断类型
  • 判断大小
  • 拿到上传地址
  • 往地址推送文件

当文件符合要求,且比较小的时候,成功率是比较高的,但如果文件大,上传的路径又复杂,再加上网络问题,失败率就会陡增,这时候该怎么改善体验呢?

很典型的一个场景,迅雷、百度云盘,这些都是允许用户主动暂停,或者允许进行一半失败了接着进行。

即使不是这样的场景,我们也希望达到两个目的:

一、大文件不因中途出了一点点问题就彻底失败。

二、上传过程可以获知当前进度,让用户知道传了多少,还要等待多久。

所以,提高上传成功率,让用户对较长的上传过程有所感知,就是我们优化的目标。

文件切割

想要分片,首先要有切割的方法,有这样一个的方法——slice()

不如我们顺便看看他提供了多少东西。

通常情况下, File 对象是来自用户在一个<input>元素上选择文件后返回的FileList对象,也可以是来自由拖放操作生成的 DataTransfer对象。

File包含:

  • name
  • size
  • type

File本身没有提供方法,但它从 Blob接口继承了slice()方法。

文件的话题还蛮大的,先介绍到这,后面可以单独再聊。接下来看切割文件。

示例:

1
file.slice(0, 2 * 1024 * 1024)

就是将文件从开头切割至2M大小的地方。

接下来只需要确定两点:

  • 定标准:不是所有文件都分片,分片其实增加了传输的过程,如果文件比较小,就不必要分片,超过某个大小的文件才采取分片处理,比如:50MB。

  • 分片大小:定一个合理的分片大小(SIZE),保证片数不过多的情况下,单片的传输效率。

代码示例:

1
2
3
4
5
6
7
let index = 0,// 记录当前已经切割的分片大小
tasks = []; // 请求参数列表
while (index < file.size) {
// 这里向参数列表添加参数项,具体可由你们前后端协定
tasks.push({ filename: index / SIZE, fileChunk: file.slice(index, index + SIZE), 'content-length': file.size % SIZE });
index += SIZE;
}

构造好要发请求的参数集合后,就可以发请求了。

发起请求

分割之后就是向后端发请求,传输文件片。这里涉及的要点:

并行/阻塞:拿到列表后遍历执行,是并行还是阻塞。如果需要一片接一片的传,就阻塞;如果不需要,可以并行,但要注意并行数量的控制,在文件大的情况下,分片数可能达到数百甚至上千,并行数过高可能引起浏览器崩溃。

实现:并行就直接发请求,阻塞则使用async/await。

手动取消:上传过程可能很长,允许用户中途手动取消,取消后,尚未进行的请求需要中断,而不是继续在浏览器中运行。

实现:设置一个状态位,当状态为暂停,中断循环。

断点续传:是否可中断,即断点续传。断点续传的场景包括:手动暂停、网络异常停止、手动刷新页面等。

断点续传可使用户在上传大文件时不必因为中途的失败而完全从头开始,这可能是个漫长的过程,体验很不好。

实现:每次成功的请求记录当前索引,因某种原因中断重新启动时,从索引位的下一位开始。

失败重试:常见于大文件,由于请求数过多,中途难免因为网络情况、请求超时、后端报错等情况而出错,如果没有重试机制,一旦某一次请求失败,整个就失败了,失败率明显增高,失败重试则会大大改善这一情况。

重试也需要一个更加合理的机制,如:间隔一段时间再重试,而不是瞬间重试,重试不是无止境,试几次之后可以提示用户上传异常,让用户自己选择是继续重试,还是放弃。

实现:封装一个重试的方法,把请求、重试次数、重试间隔、参数、失败回调等传进去。

捕获错误,间隔一定时长(如2s)进行重试,同时进行计数,当达到计数次数停止重试,抛出错误。

如果非极端情况,重试的请求都会发送成功,这就大大改善了用户体验。

当然,还有两个很小的点。

  • 一般重复的请求我们是会有取消机制的,否则用户多次发起请求,结果都会返回,会造成数据错误,但在这里,多个分片的请求可能会命中你的重复请求取消,要做特殊处理。
  • 请求报错我们往往会在响应拦截器中给予相应message的提示,但在请求失败重试的过程中,这个提示就不需要了,因为这个动作是不需要用户有感知的,后台默默执行就好。

下面看代码示例:

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
42
43
44
45
46
47
48
49
50
51
52
// 请求存放地址的接口封装
function asyncApiRequest(params: any): Promise<any> {
let formdata = params[0]
return getFileUrl(formdata)
}

// 重试请求的间隔
const sleep = (timeout: number) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve('');
}, timeout);
});
};

// 重试方法
const requestRetry = async (
asyncRequest: Function, // 重试请求方法
time: number, // 重试次数上限
delay: number, // 重试时间间隔
errorHanndler: Function, // 达到上限后执行的动作
...params: any // 需要往asyncRequest传的参数
): Promise<any> => {
try {
let result = await asyncRequest(params);
if (result) return result;
throw new Error('超时请求抛出'); // 超时的请求无法自动捕获,需要手动抛出
} catch (err) {
if (time--) {
await sleep(delay);
return await requestRetry(asyncRequest, time, delay, errorHanndler, ...params);
} else {
errorHanndler();
throw new Error('请求失败');
}
}
};

function errortips(){
alert('文件上传失败!')
}

// 调用的时候大体是这样
async function uploadFileUrl(){
let uploadStart; // 请求成功数
for(let i = uploadStart + 1;i < task.length;i++){
// 重试5次,间隔200ms,若失败,调用errortips
let result = await requestRetry(asyncApiRequest,5,200,errortips,task[i])
// ...
uploadStart = i
}
}

注意点:

如果是单个固定请求的重试,不必传参数,写到封装的方法中即可,但分片上传时每次的参数都不同,就需要传入参数。

重试的是一个新的请求,不是上次请求的结果,所以需要封装为一个函数,每次调用重新发起,而不是将上次请求的结果再次传入。

小结

这次分享的案例是有一定场景的,如果需求本身对文件大小有限制,比如几十MB的文档或图片,就没必要这么做了。它更适合在“重文件”型项目中使用,如上传下载工具、网盘、音视频编辑等,我们的项目就是做音视频处理,几百MB甚至上G的文件就会出现了,理解和掌握分片上传,并投入使用来改善用户体验,就成为必要的。

好了,先记录到这里,希望你有所收获,另外,相信部分读者一定有更复杂的使用场景或者更好的方案,如果看到这里,不妨分享给大家?