为什么要分片上传
- 服务器配置:例如在PHP中默认的文件上传大小为8M【post_max_size = 8m】,若你在一个请求体中放入8M以上的内容时,便会出现异常
- 请求超时:当你设置了接口的超时时间为10s,那么上传大文件时,一个接口响应时间超过10s,那么便会被Faild掉。
- 网络波动:这个就属于不可控因素,也是较常见的问题。
秒传: 文件已存在, 直接给前端返回文件 url
- 记录文件的 hash 与元数据到数据库中
- 上传文件前先计算 hash 和获取文件元数据请求接口进行比对
- 若比对成功则说明文件已存在, 直接返回前端文件 url
断点续传: 上传过程意外中断, 下次上传时不需要从头上传整个文件
- 前端将文件分片上传, 后端接收分片然后进行合并
- 上传分片前先请求接口查询需要上传的分片即实现断点续传
1、文件分片
文件切片的核心就是文件对象的slice
方法,类似数组,我们可以调用这个方法获取到文件的某一段
// 生成文件切片
createFileChunk(file, size = SIZE) {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
// file.slice 返回一个 blob对象
fileChunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return fileChunkList;
},
2、文件唯一值
- 秒传,需要通过MD5值判断文件是否已存在。
- 续传:需要用到MD5作为key值,当唯一值使用。
生成hash值的方法我们是调用 spark-md5
这个库,在计算hash
的时候是非常消耗计算机的CPU
的会造成浏览器的卡顿,为了优化体验我们使用 web-worker
在 worker
线程计算 hash
2.1 Web Worker
Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。
基本用法
主线程:
// 1、新建worker对象
let worker = new Worker('worker.js', { name : 'myWorker' })
// 2、主线程调用worker.postMessage()方法,向 Worker 发消息
worker.postMessage({method: 'echo', args: ['Work']})
// 3、主线程通过worker.onmessage指定监听函数,接收子线程发回来的消息。通过 event.data 可以获取 Worker 子线程发过来的数据
worker.onmessage = function (event) {
doSomething(event.data);
}
function doSomething() {
...
}
// 4、Worker 完成任务以后,主线程就可以把它关掉。
worker.terminate()
// 5、主线程可以监听 Worker 是否发生错误。如果发生错误,Worker 会触发主线程的error事件。Worker 内部也可以监听error事件。
worker.addEventListener('error', function (event) {
console.log(
'ERROR: Line ', event.lineno, ' in ', event.filename, ': ', event.message
)
});
Worker 线程
// 1、Worker 线程内部需要有一个监听函数,监听message事件。通过 e.data 可以获取主线程发过来的数据。
self.addEventListener('message', function (e) {
doSomething(e.data)
}, false)
function doSomething() {
...
}
// 2、self.postMessage()方法用来向主线程发送消息。
self.postMessage(...)
// 3、Worker 也可以关闭自身
self.close()
生成hash值
spark-md5 文档中要求传入所有切片并算出 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash
// 生成文件 hash(web-worker)
calculateHash(fileChunkList) {
return new Promise(resolve => {
this.container.worker = new Worker('./hash.js');
this.container.worker.postMessage({ fileChunkList });
this.container.worker.onmessage = e => {
const { percentage, hash } = e.data;
if (this.tempFilesArr[fileIndex]) {
this.tempFilesArr[fileIndex].hashProgress = Number(
percentage.toFixed(0)
);
}
if (hash) {
resolve(hash);
}
};
});
}
//hash.js
self.importScripts("/spark-md5.min.js"); // 导入脚本
// 生成文件 hash
self.onmessage = e => {
const { fileChunkList } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
let percentage = 0;
let count = 0;
const loadNext = index => {
const reader = new FileReader();
reader.readAsArrayBuffer(fileChunkList[index].file);
reader.onload = e => {
count++;
spark.append(e.target.result);
if (count === fileChunkList.length) {
self.postMessage({
percentage: 100,
hash: spark.end()
});
self.close();
} else {
percentage += 100 / fileChunkList.length;
self.postMessage({
percentage
});
loadNext(count);
}
};
};
loadNext(0);
};
3、上传切片
- 上传切片,这个里需要考虑的问题较多,也算是核心吧,uploadChunks方法只负责构造传递给后端的数据,核心上传功能放到sendRequest方法中
async uploadChunks(data) {
var chunkData = data.chunkList;
const requestDataList = chunkData
.map(({ fileHash, chunk, fileName, index }) => {
const formData = new FormData();
formData.append('md5', fileHash);
formData.append('file', chunk);
formData.append('fileName', index); // 文件名使用切片的下标
return { formData, index, fileName };
});

try {
await this.sendRequest(requestDataList, chunkData);
} catch (error) {
// 上传有被reject的
this.$message.error('亲 上传失败了,考虑重试下呦' + error);
return;
}
// 合并切片
const isUpload = chunkData.some(item => item.uploaded === false);
console.log('created -> isUpload', isUpload);
if (isUpload) {
alert('存在失败的切片');
} else {
// 执行合并
await this.mergeRequest(data);
}
}
sendReques。上传这是最重要的地方,也是容易失败的地方,假设有10个分片,那我们若是直接发10个请求的话,很容易达到浏览器的瓶颈,所以需要对请求进行并发处理。
- 并发处理:这里我使用for循环控制并发的初始并发数,然后在 handler 函数里调用自己,这样就控制了并发。在handler中,通过数组API.shift模拟队列的效果,来上传切片。
- 重试: retryArr 数组存储每个切片文件请求的重试次数,做累加。比如[1,0,2],就是第0个文件切片报错1次,第2个报错2次。为保证能与文件做对应,const index = formInfo.index; 我们直接从数据中拿之前定义好的index。 若失败后,将失败的请求重新加入队列即可。
// 并发处理
sendRequest(forms, chunkData) {
var finished = 0;
const total = forms.length;
const that = this;
const retryArr = []; // 数组存储每个文件hash请求的重试次数,做累加 比如[1,0,2],就是第0个文件切片报错1次,第2个报错2次
return new Promise((resolve, reject) => {
const handler = () => {
if (forms.length) {
// 出栈
const formInfo = forms.shift();
const formData = formInfo.formData;
const index = formInfo.index;
http.post('fileChunk', formData, {
onUploadProgress: that.createProgresshandler(chunkData[index]),
cancelToken: new CancelToken(c => this.cancels.push(c)),
timeout: 0
}).then(res => {
console.log('handler -> res', res);
// 更改状态
chunkData[index].uploaded = true;
chunkData[index].status = 'success';
finished++;
handler();
})
.catch(e => {
// 若暂停,则禁止重试
if (this.status === Status.pause) return;
if (typeof retryArr[index] !== 'number') {
retryArr[index] = 0;
}
// 更新状态
chunkData[index].status = 'warning';
// 累加错误次数
retryArr[index]++;
// 重试3次
if (retryArr[index] >= this.chunkRetry) {
return reject('重试失败', retryArr);
}
this.tempThreads++; // 释放当前占用的通道
// 将失败的重新加入队列
forms.push(formInfo);
handler();
});
}
if (finished >= total) {
resolve('done');
}
};
// 控制并发
for (let i = 0; i < this.tempThreads; i++) {
handler();
}
});
}
切片上传速度
通过axios的onUploadProgress事件,结合createProgresshandler方法进行维护
// 切片上传进度
createProgresshandler(item) {
return p => {
item.progress = parseInt(String((p.loaded / p.total) * 100));
this.fileProgress();
};
}
暂停上传
upFileCancel = () => {
this.cancels.forEach((item) => {
item.xhr.abort();
});
};
4、文件合并
当我们的切片全部上传完毕后,就需要进行文件的合并,这里我们只需要请求接口即可
mergeRequest(data) {
const obj = {
md5: data.fileHash,
fileName: data.name,
fileChunkNum: data.chunkList.length
};
instance.post('fileChunk/merge', obj,
{
timeout: 0
})
.then((res) => {
this.$message.success('上传成功');
});
}
5、断点续传
顾名思义,就是从那断的就从那开始,明确思路就很简单了。一般有2种方式,一种为服务器端返回,告知我从那开始,还有一种是浏览器端自行处理。2种方案各有优缺点。本项目使用第二种。
思路:已文件HASH为key值,每个切片上传成功后,记录下来便可。若需要续传时,直接跳过记录中已存在的便可。本项目将使用Localstorage进行存储,这里我已提前封装好addChunkStorage、getChunkStorage方法。
存储在Stroage的数据
在切片上传的axios成功回调中,存储已上传成功的切片
instance.post('fileChunk', formData, )
.then(res => {
// 存储已上传的切片下标
+ this.addChunkStorage(chunkData[index].fileHash, index);
handler();
})
在切片上传前,先看下localstorage中是否存在已上传的切片,将已经上传的过滤掉
秒传
原理:计算整个文件的HASH,在执行上传操作前,向服务端发送请求,传递MD5值,后端进行文件检索。若服务器中已存在该文件,便不进行后续的任何操作,上传也便直接结束。大家一看就明白