项目难点,亮点《大文件上传 解析》

1,847 阅读7分钟

背景

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.

截屏2022-03-23 20.33.46.png

安装该库 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("请先选择上传文件");
    }
},

摘抄:gitee.com/front_clone…

blog.csdn.net/weixin_4310…