之前发现自己对大文件上传吃的并不是很透彻,所以写一篇文章总结一下。
为什么?
直接将大文件上传,耗时太久,传输超时,无法知道进度,无法暂停。
分片上传
思路:将大文件分成一个个小文件,也叫做切片,将切片进行上传,等到后端接收到所有的切片之后,在进行复原。
- 读取本地文件,得到文件对象,使用slice进行切割,
arduino
function createChunk(file,size=5*1024*1024){
// 存储切片
const chunkList=[];
let cur=0;
while(cur < file.size){
chunkList.push({
file:file.slice(cur,cur+size), // 起始字节, 终止字节
chunkIndex:cur,
chunkSize:size,
fileName:file.name
})
cur+=size
}
return chunkList
}
- 给每个切片设置一个唯一标识 hash,因为是并发请求,传输到服务端之后顺序可能发生变化。后端可以根据这个hash值判断这个切片的位置。
javascript
function handleUpload() {
+ if (!chunkList) return;
+ const fileChunkList = createChunk(chunkList);
+ const data = fileChunkList.map(({ file },index) => ({
+ chunk: file,
+ // 文件名 + 数组下标
+ hash: this.container.file.name + "-" + index
+ }));
+ return data
+ }
- 将表单数据并发发送给后端。
javascript
function uploadChunks(chunks){
//这个数组中的元素是对象,每个对象中有blob类型的文件对象
const formChunks=chunks.map(({file,chunkName,fileName,index})=>{
//对象需要转成二进制数据流传输
const formData=new FormData()
formData.append('file',file)
formData.append('chunkName',chunkName)
formData.append('fileName',fileName)
return {formData,index}
});
// 调接口,发送请求。
const results = await Promise.all(
formChunks.map(item => {
//返回一个请求的promise对象
...
})
);
}
- 后端什么时候合并切片,有两种方式,但是建议组合使用:
- 前端给每个切片中携带切片最大数量的信息,当服务端接收到这格数量的切片自动合并。
- 额外发送一个请求,通知后端进行切片合并。
至此大文件就上传完了。但是这其中还有很多细节需要注意。
大文件上传进度
首先需要明确上传进度有两种,一种是单个文件上传的进度,还有整个文件上传的进度。 整个文件的上传的进度是基于每个切片上传的进度计算而来。
先看单个切片如何看进度
- 如果使用XHR进行上传,直接使用onprogress 事件就可以
ini
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
//...
}
};
- 如果使用axios,使用onUploadProgress事件。也可以使用第三方库axios-progress-bar - 为axios添加进度条
ini
axios.post('/upload', formData, {
onUploadProgress: progressEvent => {
if (progressEvent.lengthComputable) {
// 更新当前切片的进度
const percent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100
);
}
}
})
总进度条就好办了,每个切片上传完后累加,再除以整个文件的大小。
断点续传
先断点再续传。
断点
断点可能是网络中断,也可能是有暂停上传的功能:
将之前存储每一个切片的请求对象的数组,如果使用xhr,调用abort方法
ini
//点击暂停:
requestList.forEach(xhr => xhr?.abort());
requestList = [];
如果使用axios,可以使用 AbortController (推荐),也可以使用CancelToken(已弃用)
javascript
let controllers = []; // 存储所有分片的controller
// 给每一个切片的请求中,创建取消控制器
const controller = new AbortController();
controllers.push(controller);
// 取消所有分片上传
function cancelAllChunks() {
requestList.forEach((controller, index) => {
controller.abort(`分片 ${index} 被取消`);
});
requestList = []
}
续传
- 服务端存储
服务端保存已上传的文件hash以及对应的切片,前端每次上传服务器之前,向服务端获取已经上传完的切片,只上传未完成的切片。
javascript
// 1. 计算文件hash
const fileHash = await calculateFileHash(file);
// 2. 查询服务器已上传的切片
const { uploadedChunks } = await axios.get(`/check-upload?hash=${fileHash}`);
// 3. 文件分片
const chunkSize = 5 * 1024 * 1024; // 5MB一个切片
const chunks = Math.ceil(file.size / chunkSize);
// 4.过滤掉已经上传的切片,再调接口。
- 本地存储
javascript
// 上传前检查本地存储
function getLocalUploadProgress(hash) {
const progress = localStorage.getItem(`upload-${hash}`);
return progress ? JSON.parse(progress) : null;
}
// 更新本地存储的上传进度
function updateLocalProgress(hash, uploadedChunks) {
localStorage.setItem(
`upload-${hash}`,
JSON.stringify({ time: Date.now(), chunks: uploadedChunks })
);
}
// 上面存储了时间戳,这个主要是考虑到优化问题, 比如可以定期清理过期的数据,也可能遇到冲突的时候
// 选择时间较近的一个,也可以优化用户体验,显示上次上传的时间。
注意: 这种方式因为记录在了本地,如果用户换了浏览器,或者将存储删除,就不起作用了。
失败重试
1.单个切片上传失败后重试
为每一个请求,添加一个重试逻辑
javascript
// 最大重试次数
const MAX_RETRY = 3;
// 封装带重试的上传函数
const uploadWithRetry = async ({formData, index, chunkName, retryCount}) => {
try {
// 请求逻辑 ...
} catch (error) {
if (retryCount < MAX_RETRY) {
console.warn(`切片 ${chunkName} 上传失败,第 ${retryCount + 1} 次重试...`);
return uploadWithRetry({
formData,
index,
chunkName,
retryCount: retryCount + 1
});
} else {
console.error(`切片 ${chunkName} 上传失败,已达最大重试次数`);
return {success: false, index, error};
}
}
};
// 执行所有上传(并行)
const results = await Promise.all(
formChunks.map(item => uploadWithRetry(item))
);
2.整体失败后重新上传失败的切片
ini
// 检查失败的切片
let failedIndices = results
.filter(result => !result.value.success)
.map(result => result.value.index);
//failedIndices.length > 0 重新调用请求接口。
优化
计算hash
- 计算hash的方式是通过文件名+下标作为切片的hash,这样的问题是如果文件被重命名,hash就没有用了,正确的做法应该是文件内容不变,hash就不变。这里使用 spark-md5 的库可以实现
- 上面计算hash是同步的方式,如果一个超大的文件,进行读取文件切片并计算hash耗费很长时间,会将页面卡死,可以使用web worker,另外开启一个线程计算hash,不阻塞主线程加护。 也可以使用requestIdleCallback 来利用浏览器每帧的空闲时间进行任务。
并发
上文中使用Promise.all()直接全部发送了请求,如果文件特别多的情况下,TCP建立链接可能会将浏览器卡死。所以需要并发控制限制同时上传的切片数量,我们可以实现一个队列:
kotlin
class UploadQueue {
constructor(maxConcurrent = 3) {
this.maxConcurrent = maxConcurrent; // 并发数量 通常3-6个并发是比较安全的选择
this.pending = []; // 等待上传的任务队列
this.inProgress = 0; //正在上传的任务数
}
add(chunk) {
return new Promise((resolve, reject) => {
this.pending.push({ chunk, resolve, reject });
this._next();
});
}
_next() {
// 如果已达最大并发数或没有等待任务,则返回
if (this.inProgress >= this.maxConcurrent || !this.pending.length) return;
this.inProgress++;// 增加进行中任务计数
// 从队列中取出第一个任务
const { chunk, resolve, reject } = this.pending.shift();
uploadChunk(chunk)
.then(resolve)
.catch(reject)
.finally(() => {
this.inProgress--; // 无论成功失败,都减少计数
this._next(); // 尝试启动下一个任务
});
}
}
// 使用方式
async function uploadAllChunks(chunks) {
const queue = new UploadQueue(3); // 最大并发3
return Promise.all(chunks.map(chunk => queue.add(chunk)));
}
并发数量也可以根据浏览器自适应:
javascript
function getBrowserConcurrencyLimit() {
// 根据不同浏览器特性设置不同的默认并发数
const isChrome = /Chrome/.test(navigator.userAgent);
const isFirefox = /Firefox/.test(navigator.userAgent);
if (isChrome) return 6; // Chrome通常允许6个并发
if (isFirefox) return 8; // Firefox通常允许8个并发
return 4; // 其他浏览器保守值
}