代码地址
什么是断点续传?
使用普通上传文件时,突然遇到网络断开或其他某个问题导致上传文件停止,这时重新上传文件,服务端将从头开始,小文件倒没多大问题,大文件就显得浪费资源。而断点续传就是解决这个问题,断点续传其实正如字面意思,就是在下载的断开点继续开始传输,不用再从头开始。
原理
上传文件时,可通过blob分成多个块上传,到最后的时候,将这些块合并成一个文件,在这过程中,如果文件上传停止,下次重新上传时,拿到上次上传文件的块索引,在这个索引叠加上传即可,最后上传所有再合并成一个文件。
环境
后端
formidable 文件上传模块 express Web框架
前端
axios 请求接口 spark-md5 MD5加密
创建工程
前端代码index.html
view:
<divclass="upload"><h3>大文件上传</h3><form><divclass="upload-file"><labelfor="file">请选择文件</label><inputtype="file"name="file"id="big-file"accept="application/*"></div><divclass="upload-progress">当前进度:<p><spanstyle="width:0;"id="big-current"></span></p></div></form></div>
css:
body{margin:0;font-size:16px;background:#f8f8f8;}h1,h2,h3,h4,h5,h6,p{margin:0;}/**{outline:1pxsolidpink;}*/.upload{box-sizing:border-box;margin:30pxauto;padding:15px20px;width:500px;height:auto;border-radius:15px;background:#fff;}.uploadh3{font-size:20px;line-height:2;text-align:center;}.upload.upload-file{position:relative;margin:30pxauto;}.upload.upload-filelabel{display:flex;justify-content:center;align-items:center;width:100%;height:150px;border:1pxdashed#ccc;}.upload.upload-fileinput{position:absolute;top:0;left:0;width:100%;height:100%;opacity:0;}.upload-progress{display:flex;align-items:center;}.upload-progressp{position:relative;display:inline-block;flex:1;height:15px;border-radius:10px;background:#ccc;overflow:hidden;}.upload-progresspspan{position:absolute;left:0;top:0;width:0;height:100%;background:linear-gradient(torightbottom,rgb(163,76,76),rgb(231,73,52));transition:all.4s;}.upload-link{margin:30pxauto;}.upload-linka{text-decoration:none;color:rgb(6,102,192);}@mediaalland(max-width:768px){.upload{width:300px;}}
js:
<scriptsrc="https://cdn.bootcdn.net/ajax/libs/axios/0.21.1/axios.min.js"></script><scriptsrc="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.0/spark-md5.min.js"></script><script>constbigFile=document.querySelector('#big-file');letbigCurrent=document.querySelector('#big-current');letbigLinks=document.querySelector('#big-links');letfileArr=[];letmd5Val='';letext='';bigFile.addEventListener('change',(e)=>{letfile=e.target.files[0];letindex=file.name.lastIndexOf('.')ext=file.name.substr(index+1)if(file.type.indexOf('application')==-1){returnalert('文件格式只能是文档应用!');}if((file.size/(1000*1000))>100){returnalert('文件不能大于100MB!');}this.uploadBig(file);},false);//操作上传asyncfunctionuploadBig(file){letchunkIndex=0fileArr=sliceFile(file)md5Val=awaitmd5File(fileArr)//获取上次上传索引letdata=awaitaxios({url:`${baseUrl}/big?type=check&md5Val=${md5Val}&total=${fileArr.length}`,method:'post',})if(data.data.code==200){chunkIndex=data.data.data.data.chunk.length?data.data.data.data.chunk.length-1:0console.log('chunkIndex',chunkIndex)}awaituploadSlice(chunkIndex)}//切割文件functionsliceFile(file){constfiles=[];constchunkSize=128*1024;for(leti=0;i<file.size;i+=chunkSize){constend=i+chunkSize>=file.size?file.size:i+chunkSize;letcurrentFile=file.slice(i,(end>file.size?file.size:end));files.push(currentFile);}returnfiles;}//获取文件md5值functionmd5File(files){constspark=newSparkMD5.ArrayBuffer();letfileReader;for(vari=0;i<files.length;i++){fileReader=newFileReader();fileReader.readAsArrayBuffer(files[i]);}returnnewPromise((resolve)=>{fileReader.onload=function(e){spark.append(e.target.result);if(i==files.length){resolve(spark.end());}}})}//分块上传请求asyncfunctionuploadSlice(chunkIndex=0){letformData=newFormData();formData.append('file',fileArr[chunkIndex]);letdata=awaitaxios({url:`${baseUrl}/big?type=upload¤t=${chunkIndex}&md5Val=${md5Val}&total=${fileArr.length}`,method:'post',data:formData,})if(data.data.code==200){if(chunkIndex<fileArr.length-1){bigCurrent.style.width=Math.round((chunkIndex+1)/fileArr.length*100)+'%';++chunkIndex;uploadSlice(chunkIndex);}else{mergeFile();}}}//合并文件请求asyncfunctionmergeFile(){letdata=awaitaxios.post(`${baseUrl}/big?type=merge&md5Val=${md5Val}&total=${fileArr.length}&ext=${ext}`);if(data.data.code==200){alert('上传成功!');bigCurrent.style.width='100%';bigLinks.href=data.data.data.url;}else{alert(data.data.data.info);}}</script>
后端代码index.js
constexpress=require('express');constformidable=require('formidable');constpath=require('path');constfs=require('fs');constbaseUrl='http://localhost:3000/file/doc/';constdirPath=path.join(__dirname,'/static/')constapp=express()//解决跨域app.all('*',function(req,res,next){res.header('Access-Control-Allow-Origin','*')res.header('Access-Control-Allow-Headers','Content-Type')res.header('Access-Control-Allow-Methods','*');res.header('Content-Type','application/json;charset=utf-8')next();});app.post('/big',asyncfunction(req,res){lettype=req.query.type;letmd5Val=req.query.md5Val;lettotal=req.query.total;letbigDir=dirPath+'big/';lettypeArr=['check','upload','merge'];if(!type){returnres.json({code:101,msg:'get_fail',data:{info:'上传类型不能为空!'}})}if(!md5Val){returnres.json({code:101,msg:'get_fail',data:{info:'文件md5值不能为空!'}})}if(!total){returnres.json({code:101,msg:'get_fail',data:{info:'文件切片数量不能为空!'}})}if(!typeArr.includes(type)){returnres.json({code:101,msg:'get_fail',data:{info:'上传类型错误!'}})}if(type==='check'){letfilePath=`${bigDir}${md5Val}`;fs.readdir(filePath,(err,data)=>{if(err){fs.mkdir(filePath,(err)=>{if(err){returnres.json({code:101,msg:'get_fail',data:{info:'获取失败!',err}})}else{returnres.json({code:200,msg:'get_succ',data:{info:'获取成功!',data:{type:'write',chunk:[],total:0}}})}})}else{returnres.json({code:200,msg:'get_succ',data:{info:'获取成功!',data:{type:'read',chunk:data,total:data.length}}})}});}elseif(type==='upload'){letcurrent=req.query.current;if(!current){returnres.json({code:101,msg:'get_fail',data:{info:'文件当前分片值不能为空!'}})}letform=formidable({multiples:true,uploadDir:`${dirPath}big/${md5Val}/`,})form.parse(req,(err,fields,files)=>{if(err){returnres.json(err);}letnewPath=`${dirPath}big/${md5Val}/${current}`;fs.rename(files.file.path,newPath,function(err){if(err){returnres.json(err);}returnres.json({code:200,msg:'get_succ',data:{info:'uploadsuccess!'}})})});}else{letext=req.query.ext;if(!ext){returnres.json({code:101,msg:'get_fail',data:{info:'文件后缀不能为空!'}})}letoldPath=`${dirPath}big/${md5Val}`;letnewPath=`${dirPath}doc/${md5Val}.${ext}`;letdata=awaitmergeFile(oldPath,newPath);if(data.code==200){returnres.json({code:200,msg:'get_succ',data:{info:'文件合并成功!',url:`${baseUrl}${md5Val}.${ext}`}})}else{returnres.json({code:101,msg:'get_fail',data:{info:'文件合并失败!',err:data.data.error}})}}})//多个块合并成一个文件functionmergeFile(filePath,newPath){returnnewPromise((resolve,reject)=>{letfiles=fs.readdirSync(filePath),newFile=fs.createWriteStream(newPath);letfilesArr=arrSort(files).reverse();main();functionmain(index=0){letcurrentFile=filePath+'/'+filesArr[index];letstream=fs.createReadStream(currentFile);stream.pipe(newFile,{end:false});stream.on('end',function(){if(index<filesArr.length-1){index++;main(index);}else{resolve({code:200});}})stream.on('error',function(error){reject({code:102,data:{error}})})}})}//文件排序functionarrSort(arr){for(leti=0;i<arr.length;i++){for(letj=0;j<arr.length;j++){if(Number(arr[i])>=Number(arr[j])){lett=arr[i];arr[i]=arr[j];arr[j]=t;}}}returnarr;}app.listen(3000,()=>{console.log('http://localhost:3000/')})