node.js大文件上传 
    
      
         
        2020.04.29 
       
      
        
           
          陈晓勉 
         
      
      
  
     
    node 
   
      
      
        
        
            热度 
            ℃
         
      
      
      
     
   
  
    
      <form  method ="post"  action ="http://localhost:8100"  enctype ="multipart/form-data" >   选择文件: <input  type ="file"  name ="f1" />  input 必须设置 name 属性,否则数据无法发送<br /> <br />    标题:<input  type ="text"  name ="title" /> <br /> <br /> <br />    <button  type ="submit"  id ="btn-0" > 上 传</button >  </form > 
 
服务端文件的保存基于现有的库koa-body结合 koa2实现服务端文件的保存和数据的返回。
var  http = require ('http' );var  koaStatic = require ('koa-static' );var  path = require ('path' );var  koaBody = require ('koa-body' );var  fs = require ('fs' );var  Koa = require ('koa2' );var  app = new  Koa();var  port = process.env.PORT || '8100' ;var  uploadHost= `http://localhost:${port} /uploads/` ;app.use(koaBody({     formidable: {                  uploadDir: path.resolve(__dirname, '../static/uploads' )     },     multipart: true   })); app.use(koaStatic(     path.resolve(__dirname, '../static' )  )); app.use((ctx ) =>  {     var  file = ctx.request.files.f1;     var  path = file.path;     var  fname = file.name;     var  nextPath = path+fname;     if (file.size>0  && path){                  var  extArr = fname.split('.' );         var  ext = extArr[extArr.length-1 ];         var  nextPath = path+'.' +ext;                  fs.renameSync(path, nextPath);     }          ctx.body = `{          "fileUrl":"${uploadHost} ${nextPath.slice(nextPath.lastIndexOf('/' )+1 )} "     }` ;}); var  server = http.createServer(app.callback());server.listen(port); console .log('demo1 server start ......   ' );
 
扩展: koa-body 文件上传自定义文件夹及文件名称 
局部刷新-iframe 页面内放一个隐藏的 iframe,或者使用 js 动态创建,指定 form 表单的 target 属性值为iframe标签 的 name 属性值,这样 form 表单的 shubmit 行为的跳转就会在 iframe 内完成,整体页面不会刷新。
   <iframe  id ="temp-iframe"  name ="temp-iframe"  src =""  style ="display:none;" > </iframe >          <form  method ="post"  target ="temp-iframe"  action ="http://localhost:8100"  enctype ="multipart/form-data" >          选择文件(可多选):             <input  type ="file"  name ="f1"  id ="f1"  multiple /> <br />  input 必须设置 name 属性,否则数据无法发送<br />  <br />             标题:<input  type ="text"  name ="title" /> <br /> <br /> <br />          <button  type ="submit"  id ="btn-0" > 上 传</button >          </form >                    <script > var  iframe = document .getElementById('temp-iframe' );iframe.addEventListener('load' ,function  ( )  {       var  result = iframe.contentWindow.document.body.innerText;              var  obj = JSON .parse(result);       if (obj && obj.fileUrl.length){           alert('上传成功' );                  }       console .log(obj); }); </script > 
 
无刷新上传 无刷新上传文件肯定要用到XMLHttpRequest,在 ie 时代也有这个对象,单只 支持文本数据的传输,无法用来读取和上传二进制数据。 现在已然升级到了XMLHttpRequest2,较1版本有非常大的升级,首先就是可以读取和上传二进制数据,可以使用·FormData·对象管理表单数据。
 <div >          选择文件(可多选):         <input  type ="file"  id ="f1"  multiple /> <br /> <br />          <button  type ="button"  id ="btn-submit" > 上 传</button >  </div > 
 
<script>     function  submitUpload ( )  {                  var  fileList = document .getElementById('f1' ).files;         if (!fileList.length){             alert('请选择文件' );             return ;         }         var  fd = new  FormData();            fd.append('title' , document .getElementById('title' ).value);                  for (var  i =0 ;i<fileList.length;i++){             fd.append('f1' , fileList[i]);         }         var  xhr = new  XMLHttpRequest();            xhr.open('POST' , 'http://localhost:8100/' , true );         xhr.send(fd);         xhr.onreadystatechange = function  ( )  {             console .log('state change' , xhr.readyState);             if  (this .readyState == 4  && this .status == 200 ) {                 var  obj = JSON .parse(xhr.responseText);                    console .log(obj);                 if (obj.fileUrl.length){                     alert('上传成功' );                 }             }         }     }          document .getElementById('btn-submit' ).addEventListener('click' ,submitUpload); </script>  
 
进度条 
页面内增加一个用于显示进度的标签 div.progress 
js 内处理增加进度处理的监听函数xhr.upload.onprogress 
event.lengthComputable这是一个状态,表示发送的长度有了变化,可计算 
event.loaded表示发送了多少字节 
event.total表示文件总大小 
根据event.loaded和event.total计算进度,渲染div.progress 
 
<h1 > 多文件上传 之 xhr formdata 上传进度条</h1 >      <div > 选择文件:<input  type ="file"  id ="f1"  multiple /> </div >    <div  class ="progress"  id ="progress" >      <span  class ="red" > </span >    </div >    <button  type ="submit"  id ="btn-submit" > 上传</button >    <script >           function  submit ( ) {       var  progressBlock = document .getElementById('progress' ).firstElementChild;       var  fileList = document .getElementById('f1' ).files;       progressBlock.style.width = '0' ;       progressBlock.classList.remove('green' );       if (!fileList.length){         alert('请选择文件' );         return ;       }       console .log(fileList);       var  fd = new  FormData();       for (var  i=0 ;i<=fileList.length;i++){         fd.append('f1' , fileList[i]);       }              var  xhr = new  XMLHttpRequest();       xhr.open('POST' , 'http://localhost:8100/upload' , true );              xhr.onreadystatechange = function ( ) {         console .log('state change' , xhr.readyState);                  if (this .readyState == 4  && this .status == 200 ) {           var  obj = JSON .parse(xhr.responseText);              console .log(obj);           if (obj.fileUrl.length) {                        }         }       }       xhr.onprogress = updateProgress;       xhr.upload.onprogress = updateProgress;       function  updateProgress (event )  {         console .log(event);         if  (event.lengthComputable){           var  completedPercent = (event.loaded / event.total * 100 ).toFixed(2 );           progressBlock.style.width = completedPercent + '%' ;           progressBlock.innerHTML = completedPercent + '%' ;           if (completedPercent>90 ){             progressBlock.classList.add('green' );           }           console .log('已上传' , completedPercent);         }       }       xhr.send(fd);     }          document .getElementById('btn-submit' ).addEventListener('click' , submit);                   </script > 
 
上传预览 
为了预览的需要,我们这里选择上传图片文件,其他类型的也一样,只是预览不方便 
页面内增加一个多图预览的容器div.img-box 
根据选择的文件信息动态创建所属的预览区域和进度条以及取消按钮 
为取消按钮绑定事件,调用xhr.abort();终止上传 
使用window.URL.createObjectURL预览图片,在图片加载成功后需要清除使用的内存window.URL.revokeObjectURL(this.src); 
 
大文件上传 前端 
前端大文件上传网上的大部分文章已经给出了解决方案,核心是利用 Blob.prototype.slice 方法,和数组的 slice 方法相似,调用的 slice 方法可以返回原文件的某个切片
这样我们就可以根据预先设置好的切片最大数量将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片,这样从原本传一个大文件,变成了同时传多个小的文件切片,可以大大减少上传时间
另外由于是并发,传输到服务端的顺序可能会发生变化,所以我们还需要给每个切片记录顺序
const  analyzeFile = (file ) =>  {  return  new  Promise ((resolve, reject ) =>  {     var  chunks = Math .ceil(file.size / chunkSize);      var  spark = new  SparkMD5.ArrayBuffer();     var  reader = new  FileReader();     var  currentChunk = 0 ;     function  loadNext ( )  {       var  start = currentChunk * chunkSize;       var  end = start + chunkSize > file.size ? file.size : (start + chunkSize);       reader.readAsArrayBuffer(blobSlice.call(file, start, end));     };     reader.onload = function  (e )  {       const  result = e.target.result;       spark.append(result);       currentChunk++;       if  (currentChunk < chunks) {         loadNext();         console .log(`第${currentChunk} 分片解析完成,开始解析第${currentChunk + 1 } 分片` );       } else  {                  const  result = spark.end();                           const  sparkMd5 = new  SparkMD5();         sparkMd5.append(result);         sparkMd5.append(file.name);         const  hexHash = sparkMd5.end();         resolve(hexHash);         console .log('解析完成' , hexHash);       }     };     reader.onerror = ()  =>  {       console .warn('文件读取失败!' );     };     loadNext();   }).catch(err  =>  {     console .log(err);   }); } 
 
服务端 
服务端需要负责接受这些切片,并在接收到所有切片后合并切片 这里又引伸出两个问题
何时合并切片,即切片什么时候传输完成 如何合并切片
第一个问题需要前端进行配合,前端在每个切片中都携带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并,也可以额外发一个请求主动通知服务端进行切片的合并 第二个问题,具体如何合并切片呢?这里可以使用 nodejs 的 读写流(readStream/writeStream),将所有切片的流传输到最终文件的流里
router.post('/file/upload' , async  (ctx) => {      var  files = ctx.request.files ? ctx.request.files.file : [];   var  fileSize = ctx.request.body.size || 0 ;    var  chunkCount = ctx.request.body.chunkCount || 0 ;     var  fileIndex = ctx.request.body.index || 0 ;     var  fileHash = ctx.request.body.hash || '' ;     var  result = [];   var  isChunk = chunkCount>0  && fileHash ? true  : false ;       if  (!isChunk && files && !Array .isArray(files)) {     files = [files];   }     if  (isChunk) {     const  chunksPath = path.join(uploadDir, fileHash, '/' );     if  (!fs.existsSync(chunksPath)) fs.mkdirSync(chunksPath);     fs.renameSync(files.path, chunksPath+fileIndex+'-' +fileHash);     ctx.status = 200 ;     ctx.body = `{        "code": 0,       "msg": "success"     }` ;  } else  {     files.forEach(item  =>  {       if (item.size>0  && item.uploadpath) {         result.push(uploadHost+item.uploadpath);       }           });     ctx.body = `{        "code": 0,       "msg": "success",       "data": ${JSON .stringify(result)}      }` ;  } }); const  excludeExt = (fileName ) =>  fileName.slice(fileName.lastIndexOf('.' ), fileName.length);const  createUploadedList = async  (fileHash) => fs.existsSync(path.resolve(uploadDir, fileHash)) ? await  fs.readdir(path.resolve(uploadDir, fileHash)) : [];router.post('/file/verify' , async  (ctx) => {   var  files = ctx.request.files ? ctx.request.files.file : [];   var  fileName = ctx.request.body.name || '' ;   var  hash = ctx.request.body.hash || '' ;   var  ext = excludeExt(fileName);   var  filePath = path.resolve(uploadDir, `${hash} ${ext} ` );   var  param = {     shouldUpload: fs.existsSync(filePath) ? false  : true ,     uploadedList: await  createUploadedList(hash)   }      ctx.status = 200 ;   ctx.body = `{      "code": 0,     "msg": "success",     "data": ${JSON .stringify(param)}    }` ;  }); router.post('/file/merge' , async  (ctx) => {   var  fileName = ctx.request.body.name || '' ;    var  fileSize = ctx.request.body.size || 0 ;    var  chunkCount = ctx.request.body.chunkCount || 0 ;     var  fileHash = ctx.request.body.hash || '' ;     var  chunksPath = path.join(uploadDir, fileHash, '/' );   var  extArr = fileName.split('.' );   var  ext = extArr[extArr.length - 1 ];   var  writeStream = fs.createWriteStream(`${uploadDir} /${fileHash+'.' +ext} ` );   var  cindex = 0 ;      function  mergeFile ( )  {     var  fname = path.join(chunksPath, `${cindex} -${fileHash} ` );     var  readStream = fs.createReadStream(fname);          readStream.pipe(writeStream, { end : cindex + 1  == chunkCount ? true  : false  });     readStream.on("end" , function  ( )  {       fs.unlink(fname, function  (err )  {         if  (err) {           throw  err;         }       });       if  (cindex + 1  < chunkCount) {         cindex += 1 ;         mergeFile();       } else  {         fs.rmdirSync(chunksPath);       }     });   }   mergeFile();   ctx.status = 200 ;   ctx.body = `{      "code": 0,     "msg": 'success'     "data": ${JSON .stringify(uploadHost + fileHash+'.' +ext)}    }` }); 
 
断点续传 生成 hash
无论是前端还是服务端,都必须要生成文件和切片的 hash,之前我们使用文件名 + 切片下标作为切片 hash,这样做文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根据文件内容生成 hash,所以我们修改一下 hash 的生成规则
这里用到另一个库 spark-md5,它可以根据文件内容计算出文件的 hash 值,另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互 由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5
self.importScripts("/spark-md5.min.js" );  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 ); }; 
 
恢复上传 
之前在介绍断点续传的时提到使用第二种服务端存储的方式实现续传 由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了“续传”的效果 而这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果
服务端已存在该文件,不需要再次上传 服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端
所以我们改造一下之前文件秒传的服务端验证接口。
$("#btn-submit" ).on("click" , function  ( )  {   const  file = $("#f1" )[0 ].files[0 ];   if  (!file) {     alert('没有获取文件' );     return ;   }   if  (file.size < chunkSize) {     let  fd = new  FormData();     fd.append('file' , file);     axios.post('/file/upload' , fd).then(res  =>  {       console .log(res.data);     }).catch(e  =>  {       console .log(e);     });     return ;   }   var  chunks = Math .ceil(file.size / chunkSize);    var  axiosPromiseArray = [];     var  uploadedList = [];   var  willUploadList = [];   var  hash = '' ;      function  uploadChunks (list )  {     axios.all(list).then(()  =>  {       let  fd = new  FormData();       fd.append('size' , file.size);       fd.append('name' , file.name);       fd.append('chunkCount' , chunks);       fd.append('hash' , hash);       axios.post('/file/merge' , fd).then(res  =>  {         console .log('上传成功' );         console .log(res.data);         console .log(file);       }).catch(err  =>  {         console .log(err);       })     }).catch(e  =>  {       console .log(e);     });   }      analyzeFile(file).then(res  =>  {          hash = res;     let  fd = new  FormData();     fd.append('name' , file.name);     fd.append('hash' , hash);     return  axios.post('/file/verify' , fd);   }).then(res  =>  {     let  json = res.data;     uploadedList = json.data.uploadedList || [];     if  (!json.data.shouldUpload) {       alert('秒传:上传成功' );       return ;     } else  {       let  CancelToken = axios.CancelToken;       let  cancel;       for  (let  i = 0 ; i < chunks; i++) {         let  start = i * chunkSize;         let  end = Math .min(file.size, start + chunkSize);         let  fd = new  FormData();         fd.append('file' , blobSlice.call(file, start, end));         fd.append('name' , file.name);         fd.append('chunkCount' , chunks);         fd.append('index' , i);         fd.append('size' , file.size);         fd.append('hash' , hash);         let  axiosOptions = {                                            cancelToken: new  CancelToken(function  executor (c )  {             cancel = c;           })         };         let  fileHash = `${i} -${hash} ` ;                  if  (uploadedList.length > 0  && uploadedList.indexOf(fileHash) > -1 ) {           continue ;         } else  {                                                                  axiosPromiseArray.push(axios.post('/file/upload' , fd, axiosOptions));         }                }       uploadChunks(axiosPromiseArray);                                          $("#btn-stop" ).on("click" , function  ( )  {         cancel && cancel('取消传输' );         $(this ).hide();         $("#btn-resume" ).show();       });     }     console .log(json);             }); }); 
 
网络请求并发控制 大文件hash计算后,一次发几百个http请求,计算哈希没卡,结果TCP建立的过程就把浏览器弄死了,而且我记得本身异步请求并发数的控制,本身就是头条的一个面试题
思路其实也不难,就是我们把异步请求放在一个队列里,比如并发数是3,就先同时发起3个请求,然后有请求结束了,再发起下一个请求即可, 思路清楚,代码也就呼之欲出了
我们通过并发数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"); +        const form = forms[idx].form; +        const index = forms[idx].index; +        idx++ +        request({ +          url: '/upload', +          data: form, +          onProgress: this.createProgresshandler(this.chunks[index]), +          requestList: this.requestList +        }).then(() => { +          max++; // 释放通道 +          counter++; +          if (counter === len) { +            resolve(); +          } else { +            start(); +          } +        }); +      } +    } +    start(); +  }); +} async uploadChunks(uploadedList = []) {   // 这里一起上传,碰见大文件就是灾难   // 没被hash计算打到,被一次性的tcp链接把浏览器稿挂了   // 异步并发控制策略,我记得这个也是头条一个面试题   // 比如并发量控制成4   const list = this.chunks     .filter(chunk => uploadedList.indexOf(chunk.hash) == -1)     .map(({ chunk, hash, index }, i) => {       const form = new FormData();       form.append("chunk", chunk);       form.append("hash", hash);       form.append("filename", this.container.file.name);       form.append("fileHash", this.container.hash);       return { form, index };     }) -     .map(({ form, index }) => -       request({ -           url: "/upload", -         data: form, -         onProgress: this.createProgresshandler(this.chunks[index]), -         requestList: this.requestList -       }) -     ); -   // 直接全量并发 -   await Promise.all(list);      // 控制并发   +   const ret =  await this.sendRequest(list,4)   if (uploadedList.length + list.length      // 上传和已经存在之和 等于全部的再合并     await this.mergeRequest();   } }, 
 
demo koa各种文件上传 koa-upload   
参考