前言大文件快速上传解决方案相信大家已经了解了。其实无非就是减小文件大小,也就是压缩文件资源或者把文件资源分成块再上传。本文只介绍分块上传资源的方法,通过前端(vue3+vite)和服务端(nodejs+koa2)的交互,实现分块上传大文件的简单功能。梳理思路问题一:谁负责资源分区?谁负责资源整合?当然,这个问题也很简单。肯定是前端负责分区,服务端负责整合。问题二:前端如何划分资源?首先是选择上传的文件资源,然后就可以得到对应的文件对象File,File.prototype.slice方法可以实现资源的划分。当然也有人说是Blob.prototype.slice方法,因为Blob.prototype.slice===File.prototype.slice。问题三:服务端怎么知道什么时候整合资源?如何保证资源整合的有序性?由于前端将资源分成chunk,然后分别发送请求,也就是说一个文件对应一个上传请求,而现在可能变成一个文件对应n个上传请求,所以前端可以根据这些请求组合起来Promise.all集成多个接口,上传完成后,发送合并请求,通知服务端进行合并。合并时,可以使用nodejs中的读写流(readStream/writeStream),将所有分片的流通过管道输入到最终文件的流中。在发送请求的资源时,前端会判断每个文件对应的序号,并将当前区块、序号、文件哈希等信息发送给服务器。服务器合并时,可以通过序号顺序合并。问题四:部分上传请求失败怎么办?服务器上的上传请求一旦失败,会返回当前区块失败的信息,包括文件名、文件哈希、区块大小、区块号等,前端收到该信息后可以重传.什么时候用Promise.allSettled替换Promise.all更方便。前端部分通过pnpmcreatevite创建工程,对应的文件目录如下:requestmodulesrc/request.js该文件是对axios的简单封装,如下:importaxiosfrom"axios";constbaseURL='http://localhost:3001';exportconstuploadFile=(url,formData,onUploadProgress=()=>{})=>{returnaxios({method:'post',url,baseURL,headers:{'Content-Type':'multipart/form-data'},data:formData,onUploadProgress});}exportconstmergeChunks=(url,data)=>{returnaxios({method:'post',url,baseURL,headers:{'Content-Type':'application/json'},data});}文件资源分块是根据DefualtChunkSize=5*1024*1024,即5MB来计算文件的资源分块,以及使用spark-md5[1]根据文件内容计算出文件的hash值,方便其他优化。例如,当哈希值不变时,服务器就不需要重复读写文件。//获取文件分块constgetFileChunk=(file,chunkSize=DefualtChunkSize)=>{returnnewPromise((resovle)=>{letblobSlice=File.prototype.slice||File.prototype.mozSlice||File.prototype.webkitSlice,chunks=Math.ceil(file.size/chunkSize),currentChunk=0,spark=newSparkMD5.ArrayBuffer(),fileReader=newFileReader();fileReader.onload=function(e){console.log('读取chunknr',currentChunk+1,'of');constchunk=e.target.result;spark.append(chunk);currentChunk++;if(currentChunk=file.size)?文件大小:开始+块大小;letchunk=blobSlice.call(file,start,end);fileChunkList.value.push({chunk,size:chunk.size,name:currFile.value.name});fileReader.readAsArrayBuffer(块);}loadNext();});}通过Promise.all方法发送上传请求和合并请求,整合所有分块上传请求,所有分块资产上传完毕后,在then发送合并请求//上传请求constuploadChunks=(fileHash)=>{constrequests=fileChunkList.value.map((item,index)=>{constformData=newFormData();formData.append(`${currFile.value.name}-${fileHash}-${index}`,item.chunk);formData.append("filename",currFile.value.name);formData.append("hash",`${fileHash}-${index}`);formData.append("fileHash",fileHash);returnuploadFile('/upload',formData,onUploadProgress(item));});Promise.all(requests).then(()=>{mergeChunks('/mergeChunks',{size:DefaultChunkSize,filename:currFile.value.name});});}进度条数据块进度数据使用onUploadProgressaxios中配置项获取数据,使用computed根据block进度dataChangesAuto自动计算当前文件的总进度。//总进度条consttotalPercentage=computed(()=>{if(!fileChunkList.value.length)return0;constloaded=fileChunkList.value.map(item=>item.size*item.percentage).reduce((curr,next)=>curr+next);returnparseInt((loaded/currFile.value.size).toFixed(2));})//块进度条constonUploadProgress=(item)=>(e)=>{item.percentage=parseInt(String((e.loaded/e.total)*100));}server部分搭建服务使用koa2搭建一个简单的服务,端口为3001,使用koa-body处理接收前端下发的'Content-Type':'multipart/form-data'类型数据使用koa-router注册服务器端路由,使用koa2-cors处理跨域问题目录/文件划分服务器/服务器。js这个文件就是服务端的具体代码实现。用于处理接收和集成分块资源。server/resources该目录用于存放单个文件的多个block,以及最后block合并后的资源:当block资源没有合并时,会在该目录下以当前文件名创建一个目录来存放这个时需要合并与该文件相关的所有block和block资源,读取该文件对应目录下的所有block资源,然后整合到原文件中。块资源合并后,对应的文件目录会被删除。只保留合并后的原文件,生成的文件名比真实文件名多一个_前缀。例如,原文件名“testfile.txt”对应合并后的文件名“_testfile.txt”,使用koa-body接收blocksformidable进程中的formidable配置中的onFileBegin函数处理FormData中的文件资源前端。前端处理对应的块名时的格式为:filename-fileHash-index,所以这里可以直接拆分块名得到对应的信息。//上传请求router.post('/upload',//处理文件form-data数据koaBody({multipart:true,formidable:{uploadDir:outputPath,onFileBegin:(name,file)=>{const[filename,fileHash,index]=name.split('-');constdir=path.join(outputPath,filename);//保存当前chunk信息,出错时返回currChunk={filename,fileHash,index};//检查文件夹是否存在如果不存在则新建文件夹if(!fs.existsSync(dir)){fs.mkdirSync(dir);}//覆盖文件存放的完整路径`${dir}/${fileHash}-${index}`;},onError:(error)=>{app.status=400;app.body={code:400,msg:"上传失败",data:currChunk};return;},},}),//异步处理响应(ctx)=>{ctx.set("Content-Type","application/json");ctx.body=JSON.stringify({code:2000,message:'上传成功!'});});整合chunks通过文件名找到对应的文件chunk目录,使用fs.readdirSync(chunkDir)方法获取对应目录下所有chunk的名称,通过fs.createWriteStream/fs.createReadStream/可读流创建可写文件,结合pipeline管道将stream整合到同一个文件中,合并完成后,通过fs.rmdirSync(chunkDir)删除对应的chunk目录//合并请求router.post('/mergeChunks',async(ctx)=>{const{filename,size}=ctx.request.body;//合并块awaitmergeFileChunk(path.join(outputPath,'_'+filename),filename,size);//处理响应ctx.set("Content-Type","application/json");ctx.body=JSON.stringify({data:{code:2000,filename,size},message:'mergechunkssuccessful!'});});//通过管道处理流constpipeStream=(path,writeStream)=>{returnnewPromise(resolve=>{constreadStream=fs.createReadStream(path);readStream.pipe(writeStream);readStream.on("end",()=>{fs.unlinkSync(path);resolve();});});}//合并切片constmergeFileChunk=async(filePath,文件名,大小)=>{constchunkDir=path.join(outputPath,文件名);constchunkPaths=fs.readdirSync(chunkDir);如果(!chunkPaths.length)返回;//按照切片的下标排序,否则直接读取目录得到的顺序可能是乱序的chunkPaths.sort((a,b)=>;a.split("-")[1]-b.split("-")[1]);console.log("chunkPaths=",chunkPaths);awaitPromise.all(chunkPaths.map((chunkPath,index)=>pipeStream(path.resolve(chunkDir,chunkPath),//在指定位置创建可写流fs.createWriteStream(filePath,{start:index*size,结束:(索引+1)*大小})))));//合并后删除切片所在目录fs.rmdirSync(chunkDir);};Front-end&serverinteraction前端分块上传测试文件信息:选择文件类型为19.8MB,上面设置默认的chunksize为5MB,所以要分成4块,即4个请求服务器区块接收前端发送合并请求服务器合并区块扩展-断点续传&二传经过以上核心逻辑后,要实现断点续传和即时传输的功能只需要扩展即可,这里不再给出具体实现,仅列出一些想法。断点续传断点续传其实就是让请求被打断,然后在上次被打断的位置继续发送。这时候应该保存每个请求的实例对象,以便后面可以取消对应的请求,并且可以保存取消的请求或者记录原来的阻止列表取消位置信息等,以便重新请求——后来发起。取消请求的几种方法。如果你使用原生XHR,你可以使用(newXMLHttpRequest()).abort()。如果你使用axios,你可以使用newCancelToken(function(cancel){})来取消请求。如果使用fetch,可以使用(newAbortController()).abort()取消即时传输的请求。不要被这个名字误导了。其实所谓的即时传输是不需要传输的。在正式发起上传请求时,首先发起校验请求。这个请求会携带对应的文件哈希值到服务端,服务器端负责查找是否有相同的文件哈希值。如果存在,此时可以直接复用文件资源,不需要前端额外发起上传请求。最后,前端分片上传的内容,从纯理论的角度其实很容易理解,但是在实践中,自己实现的时候还是有一些坑。比如服务端接收并解析formData格式的数据时,无法获取到文件的二进制数据等。