背景
form表单中,有文件上传,如果上传的文件过大,上传中会花费很长时间,且失败后需要重新上传。
解决思路
- 将大文件转换成二进制流的格式
- 利用流的可以切割的属性,将二进制流切割成多份
- 组装和分割块同等数量的请求块,并行或串行的形式发出请求
- 监听到所有请求都成功发出去后,再给服务端发出一个合并的请求
详细思路
- 第一步:
首先,先拿到上传的文件file
<input type="file" @change="handleFileChange"/>
data:(){
container:{
file:null
}
}
handleFileChange(e){
const [file] = e.target.files
if(!file)return;
this.container.file = file
}
- 第二步:
对file文件按照固定大小切片并进行hash处理
⚠️:可以按照固定大小或固定数量切片,但是为了避免由于JS使用的二进制浮点数算术标准导致的误差,用固定大小的方式对文件进行切割。
import sparkMD5 from 'spark-md5'
// 将文件按固定大小(2M)进行切片
const file = this.container.file
const chunkSize = 2 * 1024 * 1024
chunkList = []
chunkListLength = Math.ceil(file.size/chunkSize) // 计算总共多少个切片
suffix = /\.([0-9A-z]+)$/.exec(file.name)[1] //文件后缀名
// 根据文件内容生成hash值
const spark = new SparkMD5.ArrayBuffer()
spark.append(buffer)
const hash = spark.end()
// 生成切片
let curChunk = 0;//切片时的初开始位置
for(let i = 0;i<chunkListLength.length;i++){
const item = {
chunk:file.slice(curChunk,curChunk + chunkSize)
fileName:`${hash}_${i}.${suffix}`
}
curChunk += chunkSize
chunkList.push(item)
}
// 此时的for循环中得item改变了原来的file文件的内部属性,只有chunk 和fileName两个属性
***为了在切片时,不改变file文件的原始类型,可以使用new File()****
/**
* 额外的知识点,但是很有用
* new File()传递两个参数的含义
- 第一个参数是一个字符串数组。数组中的每一个元素对应着文件中一行的内容。
- 第二个参数就是文件名字符串
*/
var objFile=new File([file.slice(curChunk,curChunk + chunkSize)],FileName);
- 第三步:
发送请求,以串行发送
sendRequest(){
const requestList = [] //请求集合
this.chunkList.forEach(item=>{
const fn=()=>{
const formData = new FormData()
formData.append('chunk',item.chunk)
formData.append('filename',item.fileName)
return axios({
utl:'',
method:'post',
headers:{'Content-Type':'multipart/form-data'},
data:formData
}).then(res =>{})
requestList.push(fn)
})
let i = 0 // 记录发送的请求个数
const send = async()=>{
if(i>=requestList.length){
// 发送完毕
return
}
await requestList[i]()
i++
send()
}
send() // 发送请求
}
- 第四步:
所有切片发送成功后,再发送一个请求把文件的hash值传给服务器。
优化
计算hash耗时,浏览器出现闪烁问题:为啥计算hash值时,会出现闪烁的问题???
因为JS线程一直在处理hash值,没有任务去做其他的事情。
解决方法:使用web-worker,作用:就是为JavaScript创造多线程环境,允许主线程创建worker线程,将一些任务分配给后者运行。
主线程使用postMessage给worker线程传入所有切片chunkList,并监听worker线程发出的postMessage事件,可以拿到文件hash.
安装该库 npm installl spark-md5
// file_hash.vue
<script>
export default {
methods:{
async uploadFile(){
const hash = await fileHash()
}
async fileHash(){
const chunks = []
let cur = 0
while(cur<this.file.size){
chunks.push({index:cur,file:this.file.slice(cur,cur+ 2*1024*1024)})
cur += size
}
return new Promise(resolve=>{
//开启一个外部进程
this.worker = new Worker('/hash.js')
// 给外部进程传递信息
this.worker.postMessage({chunks})
// 接受外部worker回传的信息
this.worker.onmessage = e =>{
const {progress,hash} = e.data
this.hashProgress = Number(progress.toFixed(2))
if(hash){
resolve(hash)//得到计算出来的hash
}
}
})
}
}
}
</script>
// hash.js
// 引入 spark-md5
self.importScripts('spark-md5.min.js')
self.onmessage = e => { // 接收主线程传递的参数
const { chunks } = e.data
const spark = new self.SparkMD5.ArrayBuffer()
let progress = 0, count = 0
const loadNext = index => {
if (index == 0) {
progress = 0
count = 0
}
const reader = new FileReader()
reader.readAsArrayBuffer(chunks[index].file)
reader.onload = e => {
count++
spark.append(e.target.result) // 将读取的内容添加入spark生成hash
if (count == chunks.length) {
self.postMessage({
progress: 100,
hash: spark.end()
})
} else {
progress += 100 / chunks.length
self.postMessage({ progress })
loadNext(count)
}
}
}
loadNext(0)
}
React的FFiber架构,优化hash值处理
通过requestIdleCallback来利用浏览器的空闲时间计算,不会卡死主线程。
其实就是time-slice概念,React中FFiber架构的核心理念,利用浏览器的空闲时间,计算大的diff过程,中途有任何的高优先级任务,比如动画或输入,都会中断diff任务。
window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。
控制异步请求并发数
由于大文件切片过多,一次发送很多个HTTP请求,会把浏览器卡死,所以控制异步请求的并发数来解决。
通过设置并发数max,发起一个请求max--,结束一个请求max++即可。
// 控制并发数量
async sendRequest(forms, max = 4) {
return new Promise((resolve) => {
const len = forms.length;
let idx = 0; // 下一个请求的下标
let counter = 0; // 当前请求完成的数量
const start = async () => {
// 有请求,有通道
while (idx < len && max > 0) {
max--; // 占用通道
console.log(idx, 'start');
let { formData, index } = forms[idx];
idx++;
await this.request({
url: 'http://localhost:8080/upload-chunk',
method: 'post',
data: formData,
onProgress: this.createProgressHandler(this.chunkList[index]),
requestList: this.requestList,
}).then(() => {
max++; // 释放通道
counter++;
if (counter === len) {
resolve();
} else {
start();
}
});
}
};
start();
});
},
// 上传文件切片
async uploadChunks(uploadedList = []) {
// 构造请求列表
const requestList = this.chunkList
.filter((chunk) => !uploadedList.includes(chunk.chunkHash))
.map(({ chunk, chunkHash, index, fileHash }) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunkHash', chunkHash);
formData.append('fileHash', fileHash);
return { formData, index };
});
// .map(async ({ formData, index }) =>
// this.request({
// url: 'http://localhost:8080/upload-chunk',
// method: 'post',
// data: formData,
// onProgress: this.createProgressHandler(this.chunkList[index]),
// requestList: this.requestList,
// })
// );
// 等待全部发送完成
// await Promise.all(requestList); // 并发切片
// 控制并发
await this.sendRequest(requestList, 4);
// chunk 全部发送完成了需要通知后台去合并切片
if (uploadedList.length + requestList.length === this.chunkList.length) {
await this.mergeRequest();
}
},
问题总结
上传过程中刷新页面怎么办?
解决方法:监听刷新(或者离开)事件:提醒用户是否执行刷新操作,是否停止上传选择权交给用户。
window.addEventListener("beforeunload",function(event){
event.returnValue = ""
})
上传中,网络中断了,服务端不知道上传到哪一个文件,怎么办?
解决方法:断点续传
-
前端使用localStorage记录已上传的切片hash(不推荐【更换浏览器失去记忆效果】)
-
服务端保存已上传的切片hash,前端每次上传前向服务端获取已上传的切片
切片上传失败的问题
解决方法;
定义四种状态,wait/error/success/fail
- 一开始所有的请求都是wait等待状态,发生错误时变成error状态,定义几次重传都失败了之后变成fail状态,请求成功变成success状态 定义一个数组保存请求的重试次数,发送失败时,针对状态进行查找,只要是error、wait状态的请求,就重新发送 -有可能发送几次都没有成功,这种定义一个标记重传次数,超过这个次数,标记为失败状态,重新上传。
切片上传失败问题
需求:
- 第一次发送错误之后需要有发送失败提示
- 第一次发送失败之后我们再进行3次的重传-->也就是一个请求最多发送4次
- 3次重传失败需要有提示
- 需要将所有的请求都经过上面做法之后才能只能下一步
扩展性问题
- 上传过程中刷新页面怎么办? 根据每一个文件的内容生成唯一的hash值。按照我们的稳重所述1-100个子文件,比如传到第30个,页面刷新,服务端就存了该hash的前30个文件。刷新完成后,继续上传该大文件,后端识别出该子文件已经上传,就不会在往服务端存。对用户的感知就是前三十个子文件秒传的效果。
对于切片,固定大小的的优化
- 慢启动
慢启动算法的思路:在主机刚开始发送数据报的时候,先探测一下网络的状况,如果网络良好,发送方每次发送一次文段都能正确的接受确认报文段。那么就从小到大的增加拥塞窗口的大小,即增加发送窗口的大小。
动态改变数据传输量,其实就是根据当前网络情况,动态调整切片的大小
// 比如我们理想时30秒传递一个
// 初始大小定为1M,如果上传花了10秒,那下一个区块大小变成3M
// 如果上传花了60S,那下一个区块大小变成500KB,以此类推
```js
async fnSlowStart() {
if (this.payload) {
const {
file,
name,
size
} = this.payload;
const fileChunkSlice = createFileChunk(file);
console.time("hash_accept");
const hash_accept = await hashSendFnCreate(fileChunkSlice);
this.payload.hash_name = `${hash_accept}_${name}`;
const fileSize = file.size;
let current = 0,
count = 0,
offset = 2 * 1024 * 1024;
while (current < fileSize) {
const endSend = current + offset;
// 计算最后一次传输数据
const chunk = file.slice(current, endSend > fileSize ? fileSize : endSend);
const payload = new FormData();
payload.append("file", chunk);
payload.append("hash", `${count}_${hash_accept}`);
payload.append("file_name", name);
payload.append("size", size);
payload.append("hash_accept", hash_accept);
let start = new Date().getTime();
await axios.post("/file", payload, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const now = new Date().getTime();
const time = ((now - start) / 1000).toFixed(4);
console.log(time);
let Standard = 30;
let rate = this.Standard / time;
// 速率有最大和最小 可以考虑更平滑的过滤 比如1/tan
if (rate <= 0.25) rate = 0.25;
if (rate >= 4) rate = 4;
// 新的切片大小等比变化
console.log(`切片${count}大小是${this.showFormat(offset)},耗时${time}秒,是30秒的${rate}倍`);
console.log(`修正大小为${this.showFormat(offset*rate)}`);
offset = parseInt(offset * rate);
current += offset;
count++;
}
} else {
this.$message.error("请先选择上传文件");
}
},