难点:大文件上传、断点续传

3,352 阅读6分钟

大文件上传(切片上传)

  • 将大文件转换成二进制流的格式
  • 利用流可以切割的属性,将二进制流切割成多份
  • 组装和分割快同等数量的请求块,并行或串行的形式发出请求
  • 监听到所有请求都成功发出去后,在给服务端发出一个合并的信号 ⚠️:文件相关操作正常都是要搭建个文件服务器的,比如使用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方法暂停切片的上传
    • 上传前服务端返回已经上传的其切片名,前端跳过这些切片的上传。