实现思路:使用 Blob.slice
方法对大文件按照指定的大小进行切片,然后并发上传分片,等所
有分片都成功上传后,再通知服务端进行分片合并。
切片操作主要使用 file.slice()
实现
// 1. 大文件分片
let start = 0
let end = 0
let chunkSize = 0
while (1) {
end += this.chunkSize
const chunk = file.slice(start, end)
chunkSize += chunk.size
start = end
if (!chunk.size) break
this.fileChunks.push(chunk)
}
例如一个文件的大小是100kb,我们设置的分片大小是10kb,那么自然就可以分成10片,把分片都存入数组 fileChunks 中,在后面上传时使用。 对文件分片完成后,我们需要想一个问题,服务器接收到若干分片后,如何知道哪些分片是属于同一个文件的呢?因此每次上传分片时我们都需要给属于同一文件的分片带上一个唯一标识,最保险的方式就是生成这个文件的 md5 当做唯一标识。
这里主要是使用了 spark-md5
这个库来生成文件的 MD5
// 计算文件的md5, 这种方式对于大体积的文件计算更加稳定, 还可以获得计算进度
function calcFileMD5(file) {
return new Promise((resolve, reject) => {
let chunkSize = this.chunkSize,
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader()
fileReader.onload = (e) => {
spark.append(e.target.result)
currentChunk++
// 获取计算进度
this.md5Percent = Math.floor((currentChunk / chunks) * 100).toFixed(2)
// console.log('md5Percent: ', this.md5Percent + '%')
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
fileReader.onerror = (e) => {
reject(fileReader.error)
fileReader.abort()
}
function loadNext() {
let start = currentChunk * chunkSize,
end = start + chunkSize >= file.size ? file.size : start + chunkSize
fileReader.readAsArrayBuffer(file.slice(start, end))
}
loadNext()
})
}
当文件较大,读取文件计算 md5 也会比较耗时,因此也可以采用分片的方式读取文件并计算。
现在我们有了文件的分片和 md5,可以准备上传了。
但是在上传之前我们需要先发个请求,确认一下这个文件是否已经存在于服务器上了。
如果已经存在了,我们就可以立马告诉用户上传成功,等于秒传了。
这一步比较简单,主要就是发起一个get请求,参数就是文件的 md5 和文件名,查询一下服务器上是否已经存在该文件
// 检查文件是否已存在服务器上
function checkFileExist(token, fileName) {
return request
.get('/exist', {
params: {
token,
fileName,
},
})
.then((result) => {
return result.data
})
.catch((err) => {
console.log(err)
this.onError(err)
})
}
这个接口不仅可以查询该文件是否存在,还可以查询该文件的分片是否存在,以及已经上传了哪些分片,我们实现断点续传的关键就在于此。现在,我们就可以真正开始上传文件分片了。
// 循环上传所有分片
function upload(token, fileName, fileStatus) {
const { chunkIds } = fileStatus.data
const poolLimit = 4 // 并发数限制
console.log('array: ', [...new Array(this.fileChunks.length).keys()])
return asyncPool(poolLimit, [...new Array(this.fileChunks.length).keys()], (i) => {
// console.log(chunkIds, i, chunkIds.includes(i))
if (chunkIds.includes(i)) {
console.log('分片已存在', chunkIds, i)
// 已上传分片数+1
this.alreadySendCount += 1
this.updateProgress()
if (this.alreadySendCount === this.fileChunks.length) {
this.mergeFileRequest(fileName, token)
}
// 该分片已存在, 直接跳过
return Promise.resolve()
}
return this.uploadChunk(token, fileName, i)
})
}
我们在上面检查文件是否存在的接口中,可以获取到已经上传了哪些分片的信息,实现断点续传的关键就是在上传时跳过这些已经上传的分片。
在这里我们使用了一个函数 asyncPool
,主要是用来控制并发数的,因为如果我们的分片过多,就会在瞬间发出大量的 http 请求(导致TCP连接数不足可能造成等待),或者堆积无数调用栈导致内存溢出。
这个函数有三个参数:
- 第一个是并发限制数
- 第二个是一个数组,每一个元素代表一个任务
- 第三个参数是一个迭代器函数,接受两个参数(数组项和数组本身),前面数组中的每一项都会由该函数做一些处理
上面代码中的含义就是:先根据文件分片数定义一个数组,比如有6个分片,该数组就为 [0,1,2,3,4,5],然后遍历数组,针对数组的每一项都执行第三个回调函数,判断当前分片是否已经存在于服务器,如果存在就直接跳过,否则才上传该分片
// 上传单个分片
function uploadChunk(token, fileName, i) {
const fd = new FormData() //构造FormData对象
fd.append('file', this.fileChunks[i], `${token + '-' + i}`)
return request
.post('/bigFile', fd)
.then((result) => {
this.alreadySendCount += 1
this.updateProgress()
if (this.alreadySendCount === this.fileChunks.length) {
message.success('分片全部上传完成, 开始合并文件')
// 全部分片上传完成后,再发请求通知后端合并文件
this.mergeFileRequest(fileName, token)
}
})
.catch((err) => {
console.log(err)
this.onError(err)
})
}
上传分片的逻辑也非常简单,就是发起了一个post请求,参数为一个 FormData 对象,添加了一个属性。
其中 FormData.append() 的第三个参数指定了该分片所属文件的MD5值和该分片的索引,服务器在保存分片时需要用到这两个字段。
mergeFileRequest(fileName, token) {
request
.get('/mergeChunks', {
params: {
fileName,
token: token
}
})
.then((result) => {
message.success('文件上传完成')
this.onSuccess(result.data)
console.log(result.data)
const { url } = result.data ? result.data.data : { url: '' }
console.log('文件地址: ', url)
})
.catch((err) => {
console.log(err)
this.onError(err)
})
}
在上面的代码中,我们每次上传完成一个分片,就会对计数器 alreadySendCount + 1,在 alreadySendCount 等于分片数
的时候,就上传完了所有分片,此时需要发起请求通知服务器进行文件合并,合并请求的参数也是文件名和文件的MD5。
基本上整个大文件分片上传的流程就如上所述了。