大文件上传(切片上传)
- 将大文件
转换成二进制流的格式
- 利用流可以切割的属性,将二进制流切割成多份
- 组装和分割快同等数量的请求块,并行或串行的形式发出请求
- 监听到所有请求都成功发出去后,在给服务端发出一个合并的信号 ⚠️:文件相关操作正常都是要搭建个文件服务器的,比如使用fastdfs、hdfs等。或者是使用阿里oss服务器
思路
分片上传,且每个分片都需要有个编号,以便后端去校验。因此,每一个分片上传,都需要上传该片段的chunk,以及chunkIndex和chunkTotal,和整个文件的fileHash.前后端采用arraybuff的blob格式进行文件传输。等后端收到所有切片后,前端再去发起合并请求。
- 第一步:利用Bolb对象,这个对象原型上有一个slice方法。可以将大文件切片处理
<div>
<input type="file" @change="handleFileChange"/>
<el-button @click="handleUpload">上传</el-button>
</div>
data(){
return {
fileObj:{
file:null
},
}
}
handleFileChange(e){
const [file] = e.target.files
if(!file) return
this.fileObj.file = file
}
const SIZE = 10 * 1024 * 1024; // 切片大小
// 生成文件切片
createFileChunk(file, size = SIZE) {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
// 利用slice方法切片
fileChunkList.push({ file: file.slice(cur, cur + size) });
cur += size;
}
return fileChunkList;
},
- 第二步:上传切片,并发上传
handleUpload() {
const fileObj = this.fileObj;
if (!fileObj.file) return;
const fileChunkList =
this.createFileChunk(fileObj.file);
this.fileObj.chunkList =
fileChunkList.map(({ file },index) => ({
file,
size: file.size,
percent: 0,
chunkName: `${fileObj.file.name}+${index}`,
fileName: fileObj.file.name,
index,
}));
await this.uploadChunks(); // 执行上传切片的操作
},
// 上传切片
async uploadChunks() {
const requestList = this.fileObj.chunkList
.map(({ file, fileName, index, chunkName }) => {
const formData = new FormData();
formData.append("file", file);
formData.append("fileName", fileName);
formData.append("chunkName", chunkName);
return { formData, index };
})
.map(({ formData, index }) =>
this.axiosRequest({
url: "http://localhost:3000/upload",
data: formData,
onUploadProgress:
this.createProgressHandler(
this.fileObj.chunkList[index] ), // 传入监听上传进度回调
})
);
await Promise.all(requestList); // 使用Promise.all进行请求
await this.mergeRequest() // 合并切片
},
// 切片上传进度
createProgressHandler(item) {
return (e) => { // 设置每一个切片的进度百分比
item.percent =
parseInt(String((e.loaded / e.total) * 100));
};
},
// 合并切片
async mergeRequest() {
await this.request({
url: "http://localhost:3000/merge",
headers: { "content-type": "application/json" },
data: JSON.stringify({
filename: this.fileObj.file.name
})
});
},
}
总结
- 点击上传按钮,调用
createFileChunk
将文件切片,切片的数量通过文件大小控制 createFileChunk
内使用while循环和slice方法将切片放入fileChunkList
数组中返回- 生成文件切片时,需要给每个切片一个标识作为hash,暂时使用文件名+下标的形式
- 调用
uploadChunks
上传所有的文件切片,将文件切片,切片hash,以及文件名放入FormData中,再调用上一步的request函数返回一个promise,最后调用promise.all并发上传所有的切片 - 前端调用
mergeRequest
主动通知服务端进行合并,服务端接收到这个请求时主动合并切片
断点续传
断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分。 上传过程中将文件在服务器写为临时文件,等全部写完了(文件上传完),将此临时文件重命名为正式文件即可。 如果中途上传中断,下次上传时根据当前临时文件大小,作为在客户端读取文件的偏移量,从此位置继续读取文件数据块,上传到服务器。 后端主要做的工作:根据前端传给后台的md5值,到服务器磁盘查找是否有之前未完成的文件合并信息,取到之后根据上传切片的数量,返回数据。告诉前端开始从第几节上传。
思路
- 1:生成hash(根据文件内容生成hash,用spark-md5库)
上传一个超大文件,读取文件内容计算hash是非常耗费时间的,避免引起UI的阻塞,使用web-worker在worker线程计算hash。
由于实例化web-worker时,参数是一个js文件路径且不能跨域,所以需要单独创建一个hash.js文件放在public目录下,另外在worker中也是不允许访问dom的,但它提供了importScripts函数用于导入外部脚本,通过它导入spark-md5
// public/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);
};
在worker线程中,接受文件切片fileChunkList,利用FileReader读取每个切片的ArrayBuffer并不断传入spark-md5中,每计算完一个切片通过postMessage向主线程发送一个进度事件,全部完成后将最终的hash发送给主线程。 spark-md5需要根据所有切片才能算出一个hash值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的hash.
- 2:主线程与worker线程通讯逻辑
calculateHash(fileChunkList) {
return new Promise(resolve =>{
// 添加worker属性
this.container.worker = new Worker('/hash.js')
this.container.worker.postMessage({fileChunkList});
this.container.worker.onmessage = e => {
const {percentage,hash} = e.data
this.hashPercentage = percentage;
if(hash){
resolve(hash)
}
}
})
},
async handleUpload(){
const fileObj = this.fileObj;
if (!fileObj.file) return;
const fileChunkList = this.createFileChunk(fileObj.file);
this.fileObj.hash = await this.calculateHash(fileChunkList);
this.fileObj.chunkList = fileChunkList.map(({ file },index) => ({
fileHash:this.fileObj.hash,
chunk:file,
percentage: 0,
hash:this.fileObj.file.name + '-' +index
}));
await this.uploadChunks()
}
主线程使用postMessage给worker线程传入所有切片fileChunkList,并监听worker线程发出的postMessage事件拿到文件hash.
- 暂停上传(实现断点) 原理是使用XMLHttpRequest的abort方法,可以取消一个xhr请求的发送,需要将上传每个切片的xhr对象保存起来。每当一个切片上传成功时,将对应的xhr从requestList中删除,所以requestList只保存正在上传切片的xhr。
- 恢复上传 由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,就实现了续传的效果
秒传
定义:即在服务端已经存在了上传的资源,所以当用户再次上传
时会直接提示上传成功
思路
文件秒传需要依赖上次生成的hash,即在上传前,先计算出文件hash,并把hash发送给服务端进行验证,如果服务端找到hash相同的文件,直接返回上传成功的信息
async handleUpload(){
if(!this.fileObj.file) return;
const fileChunkList = this.createFileChunk(this.fileObj.file);
}
额外 Web-worker
JS是单线程模型,一次只能做一件事。web-worker的作用就是为JS创造多线程环境,允许主线程创建Worker线程,将一些任务分配给后者运行。在主线程运行的同时,Worker线程在后台运行,两者互不干扰。
Worker线程
Worker线程内部需要有一个监听函数,监听message事件。
self.addEventListener('message',function(e){
self.postMessage('hello' + e.data)
},false)
self代表子线程自身,即子线程的全局对象。 self.addEventListener()指定监听函数,也可以使用self.onmessage指定。监听函数的参数是一个事件对象,它的data属性包含主线程发来的数据。 self.postMessage()方法用来向主线程发送消息。 self.close()用于Worker内部关闭自身。
总结
- 大文件上传
- 前端上传大文件时使用Blob.prototype.slice将文件切片,并发上传多个切片,最后发送一个合并的请求通知服务端合并切片。
- 服务端接收切片并存储,收到合并请求后使用流将切片合并到最终文件
- 原生XMLHtttpRequest的upload.onprogress对切片上传进度的监听
- 使用vue计算属性根据每个切片的进度算出整个文件的上传进度
- 断点续传
- 使用spark-md5根据文件内容算出文件hash
- 通过hash可以判断服务端是否已经上传该文件,从而直接提示用户上传成功
- 通过XMLHttpRequest的abort方法暂停切片的上传
- 上传前服务端返回已经上传的其切片名,前端跳过这些切片的上传。