超详细的大文件上传实现方案
前端大文件上传往往会被大家作为工作中的难点亮点去阐述,在日常工作中也确实会遇到此类的需求,本篇文章将从0到1实现一个大文件上传的功能,内含文件分片、断点续传、失败重试、并发数控制等一些常用逻辑
# 需求分析
提示
首先分析一下大文件上传这个需求。为什么会上传一个大文件会滋生出这么多问题呢?
- 文件过大导致传输速度变慢,从而因为各种网络超时等问题
- 由于传输速度慢,传输过程中可能会出现网络中断,或者一些列其他不可预估的错误
- 由于文件过大,上传途中出现网络问题则需要重新上传整个文件,这样会浪费很多时间
总结一下就是因为慢!从而会导致很多问题,那么该如何解决这个慢呢?
联想一下,在日常构建项目的时候,项目过大我们一般会采取什么操作去进行优化?对了!就是分片,我们会通过构建工具把大文件分割成一个一个的chunk
,这样做不仅可以将原本一个请求拆分成多个请求并行发送而大大减小了每次请求的文件大小,加快响应时间
同时还可以有效的支持按需加载。
那么在上传大文件的时候我们同样可以采取把大文件切割成一个个小的文件块上传,然后让服务端把他们拼成一个大文件,这样就可以解决许多问题
下面就让我们开始动手吧!
# 文件切片
这里主要是利用了File
对象的slice
方法进行文件的切割
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>大文件上传</title>
</head>
<body>
<div class="app">
<input type="file" id="fileInput">
<button id="pauseButton">暂停上传</button>
<button id="resumeButton">继续上传</button>
<button id="retryButton">重试</button>
</div>
</body>
<script>
const fileInput = document.getElementById('fileInput');
const pauseButton = document.getElementById('pauseButton');
const resumeButton = document.getElementById('resumeButton');
const retryButton = document.getElementById('retryButton');
fileInput.onchange = async function (e) {
try {
const file = e.target.files[0];
//给文件分片并且计算出每个切片的hash值
const chunks = sliceFile(file)
console.log(chunks);
}catch (e) {
console.log(e)
}
}
/**
* 给文件分片
* @param file 原始文件
* @param chunkSize 分片的大小 默认50kb
*/
function sliceFile(file,chunkSize = 50*1024){
if(!file) return []
let chunks = []; // 存储分片的数组
let currentChunkSize = 0 // 当前已经分片的字节数
while(currentChunkSize<file.size){
const chunk = file.slice(currentChunkSize,currentChunkSize+chunkSize) // 给文件分片
chunks.push({
chunk,
uploaded:false
})
currentChunkSize+=chunkSize
}
return chunks
}
</script>
</html>
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
53
54
这里比较简单 主要就是通过slice
函数将文件分片之后暂存到一个数组里。分片的大小可以自行设置,我这里默认给了50kb
# 获取分片的hash值
为什么这里需要获取文件的hash
值呢?这是为了方便服务端在接受到切片后去校验文件的完整性。服务端将接收到的切片也去计算一次hash
值 和前端计算的对上了 那就说明这个切片的内容前后端一致
这里我们通过SparkMD5
这个库去计算
<script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
/**
* 计算分片的hash值
*/
function fileMd5(chunks){
// 遍历chunks 生成一个promise数组
const md5Promise = chunks.map(((chunk,index) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e){
const md5 = SparkMD5.ArrayBuffer.hash(e.target.result);
resolve({md5,index})
}
reader.error = function(e){
reject(`第${index+1}个分片读取失败`)
}
reader.readAsArrayBuffer(chunk.chunk);
})
}))
return new Promise((resolve,reject)=>{
Promise.all(md5Promise).then(res=>{
res.forEach(r=>{
chunks[r.index].hash = r.md5
chunks[r.index].idx = r.index
})
resolve(chunks)
}).catch(err=>{
reject(err)
})
})
}
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
这里主要是遍历
+FileReader
对象去读取文件 然后生成hash
值,最终将hash
值和索引都存储到文件对象上。
现在我们已经将大文件切片,并且计算好了每个切片的hash
值,接下来就是着手去上传了。
# 上传
在上传之前我们还需要考虑一个问题,到底是按顺序上传每一个切片还是直接一股脑上传呢?
首先我们要知道,文件切片之后,服务端也不是随意组合切片就可以还原出源文件的,他们需要根据前端切片的顺序,去合并这些切片。那是不是这就意味着我们要采取按顺序上传呢?
当然不是!如果按顺序上传的话就意味着下一个切片必须等待上一个切片上传完毕才可以上传,这样一来并没有将并发的优势发挥到最大,所以我们在计算hash
值的时候就给每个切片存下了他们自己的索引,
服务端可以根据这个索引去按顺序合并切片,前端也可以利用并发快速完成上传。
但是并发也有一个限制,谷歌浏览器http
请求的最大并发数上限为6
个,所以这里我们需要维护一个队列,保持最大并发数不会超出限制,下面来看一下代码
const fileInput = document.getElementById('fileInput');
const uploadQueue = [] // 等待上传的队列
const maxNums = 6 // 最大同时上传的分片数量
let activeChunkNum = 0 // 当前处于上传中的分片数量
fileInput.onchange = async function (e) {
try {
const file = e.target.files[0];
//给文件分片并且计算出每个切片的hash值
const chunks = await fileMd5(sliceFile(file))
chunks.forEach((chunk) => {
uploadQueue.push(chunk)
processQueue()
})
console.log(chunks);
}catch (e) {
console.log(e)
}
}
/**
* 维护一个队列,控制上传最大并发数
*/
function processQueue(){
// 如果当前处于上传中的数量小于最大数量并且等待上传的队列长度大于0
if(activeChunkNum<maxNums&&uploadQueue.length>0){
activeChunkNum++ // 将活动中的指标+1
const chunk = uploadQueue.shift() // 取出当前待上传数组中的第一项
uploadFile(chunk).finally(()=>{
activeChunkNum--
processQueue()
})
}
}
/**
* 上传分片
* @param file
*/
function uploadFile(file) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file.chunk);
formData.append('hash', file.hash);
formData.append('idx', file.idx);
axios.post('/upload', formData).then((res) => {
file.uploaded = true
resolve()
}).catch((err) => {
reject(err)
})
})
}
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
53
这里采用递归调用的方式去维护这个队列,当我们获取到所有的文件分片后,将这些分片依次推入uploadQueue
中,并且每次推入都会调用processQueue
方法进行上传。
processQueue
方法中有一个判断,如果当前活动数(正在处于上传中的切片)
小于maxNums(最大并发数)
,并且uploadQueue(待上传的切片)
长度大于0
则说明满足需求,可以进行上传。
activeChunkNum<maxNums
就是控制防止超出最大并发数的关键。在每次调用processQueue
的过程中都会进行判断,如果当前有6
个正在上传的切片了 那后续调用processQueue
就不会再执行上传的函数了。
同时在上传的回调中我们又递归的调用了processQueue
函数,然他接着去把剩下等待上传的切片进行上传,并且将活动数-1
。通过这个逻辑我们就可以实现一个最大并发数为6
的上传队列。
# 处理重试逻辑
那么如果有切片在上传的过程中出现了失败的情况怎么办呢?这个时候我们需要改造一下uploadFile
函数,让他支持失败重试的逻辑
let retryList = [] // 上传失败的数组
/**
* 上传分片
* @param file
* @param retries 重试次数
*/
function uploadFile(file,retries=3) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file.chunk);
formData.append('hash', file.hash);
formData.append('idx', file.idx);
axios.post('/upload', formData).then((res) => {
file.uploaded = true
resolve()
}).catch((err) => {
if(retries>0){
uploadFile(file,retries--)
}else{
retryList.push(file)
reject(err)
}
})
})
}
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
我们给uploadFile
新增了一个参数retries(重试次数)
,首先uploadFile
函数return
了一个promise
,只有这个promise
状态改变后才会触发processQueue
函数中调用uploadFile
时的finally
回调
所以只要我们不改变uploadFile
函数的promise
状态就可以一直去重试这个请求,直到他成功或满足失败条件为止,这样就不用担心我们在重试的过程中不满足最大并发数的问题了。重试的过程比较简单也是通过递归实现,
这里我们还新增了一个逻辑,失败三次的切片就会被存入retryList
数组中,等待所有切片上传完毕后再重新上传这些失败的切片。
到这里我们的大部分功能都已实现,现在让我们继续完善一下暂停和继续上传的功能
# 暂停、继续上传
这里我们需要新增一个变量isPaused
用于控制队列
let isPaused = false; // 控制上传是否暂停
const uploadQueue = [] // 等待上传的队列
const maxNums = 6 // 最大同时上传的分片数量
let activeChunkNum = 0 // 当前处于上传中的分片数量
let retryList = [] // 上传失败的数组
/**
* 维护一个队列,控制上传最大并发数
*/
function processQueue(){
if(isPaused) return
// 如果当前处于上传中的数量小于最大数量并且等待上传的队列长度大于0
if(activeChunkNum<maxNums&&uploadQueue.length>0){
activeChunkNum++ // 将活动中的指标+1
const chunk = uploadQueue.shift() // 取出当前待上传数组中的第一项
uploadFile(chunk).finally(()=>{
activeChunkNum--
processQueue()
})
}
}
/**
* 控制暂停
*/
pauseButton.addEventListener('click', () => {
isPaused = true;
});
/**
* 继续上传
*/
resumeButton.addEventListener('click', () => {
isPaused = false;
processQueue();
});
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
如果isPaused
为true
的时候就不继续执行processQueue
,反之则继续调用processQueue
就可以了。
最后等待所有切片上传完毕后再实现一下上传失败的切片重新上传的功能就可以啦
/**
* 重新上传那些失败的数据
*/
retryButton.addEventListener('click', () => {
if(uploadQueue.length>0) return
isPaused = false;
retryList.forEach((chunk) => {
uploadQueue.push(chunk)
processQueue()
})
retryList = []
});
2
3
4
5
6
7
8
9
10
11
12
# 完整代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>大文件上传</title>
<script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
</head>
<body>
<div class="app">
<input type="file" id="fileInput">
<button id="pauseButton">暂停上传</button>
<button id="resumeButton">继续上传</button>
<button id="retryButton">重试</button>
</div>
</body>
<script>
const fileInput = document.getElementById('fileInput');
const pauseButton = document.getElementById('pauseButton');
const resumeButton = document.getElementById('resumeButton');
const retryButton = document.getElementById('retryButton');
fileInput.onchange = async function (e) {
try {
const file = e.target.files[0];
//给文件分片并且计算出每个切片的hash值
const chunks = await fileMd5(sliceFile(file))
chunks.forEach((chunk) => {
uploadQueue.push(chunk)
processQueue()
})
console.log(chunks);
}catch (e) {
console.log(e)
}
}
let isPaused = false; // 控制上传是否暂停
const uploadQueue = [] // 等待上传的队列
const maxNums = 6 // 最大同时上传的分片数量
let activeChunkNum = 0 // 当前处于上传中的分片数量
let retryList = [] // 上传失败的数组
/**
* 维护一个队列,控制上传最大并发数
*/
function processQueue(){
if(isPaused) return
// 如果当前处于上传中的数量小于最大数量并且等待上传的队列长度大于0
if(activeChunkNum<maxNums&&uploadQueue.length>0){
activeChunkNum++ // 将活动中的指标+1
const chunk = uploadQueue.shift() // 取出当前待上传数组中的第一项
uploadFile(chunk).finally(()=>{
activeChunkNum--
processQueue()
})
}
}
/**
* 控制暂停
*/
pauseButton.addEventListener('click', () => {
isPaused = true;
});
/**
* 继续上传
*/
resumeButton.addEventListener('click', () => {
isPaused = false;
processQueue();
});
/**
* 重新上传那些失败的数据
*/
retryButton.addEventListener('click', () => {
if(uploadQueue.length>0) return
isPaused = false;
retryList.forEach((chunk) => {
uploadQueue.push(chunk)
processQueue()
})
retryList = []
});
/**
* 上传分片
* @param file
* @param retries 重试次数
*/
function uploadFile(file,retries=3) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', file.chunk);
formData.append('hash', file.hash);
formData.append('idx', file.idx);
axios.post('/upload', formData).then((res) => {
file.uploaded = true
resolve()
}).catch((err) => {
if(retries>0){
uploadFile(file,retries--)
}else{
retryList.push(file)
reject(err)
}
})
})
}
/**
* 给文件分片
* @param file 原始文件
* @param chunkSize 分片的大小 默认50kb
*/
function sliceFile(file,chunkSize = 50*1024){
if(!file) return []
let chunks = []; // 存储分片的数组
let currentChunkSize = 0 // 当前已经分片的字节数
while(currentChunkSize<file.size){
const chunk = file.slice(currentChunkSize,currentChunkSize+chunkSize) // 给文件分片
chunks.push({
chunk,
uploaded:false
})
currentChunkSize+=chunkSize
}
return chunks
}
/**
* 计算分片的hash值
*/
function fileMd5(chunks){
// 遍历chunks 生成一个promise数组
const md5Promise = chunks.map(((chunk,index) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e){
const md5 = SparkMD5.ArrayBuffer.hash(e.target.result);
resolve({md5,index})
}
reader.error = function(e){
reject(`第${index+1}个分片读取失败`)
}
reader.readAsArrayBuffer(chunk.chunk);
})
}))
return new Promise((resolve,reject)=>{
Promise.all(md5Promise).then(res=>{
res.forEach(r=>{
chunks[r.index].hash = r.md5
chunks[r.index].idx = r.index
})
resolve(chunks)
}).catch(err=>{
reject(err)
})
})
}
</script>
</html>
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# 总结
本文中我们实现了大文件的切片上传,断点续传,并发控制,失败重试等逻辑功能,在日后开发过程中面对大文件上传再也不用担心性能问题啦