实现多个大文件拖拽上传+大文件分片上传+断点续传+文件预览

4,266 阅读9分钟

前言

之前看了掘友写了一个单文件分片上传和断点续传的文章,对此充满了兴趣,因此开始自研学习。经过一段时间的学习,自己动手写了一个小demo。这篇文章将记录自己coding遇见的问题和总结自己小demo的思路。

技术关键词

前端:@vue/cli-service+element-ui+axios

后端:node.js+koa

coding思路分析

拖拽上传

拖拽上传是利用HTML5新特性实现拖拽上传,详细用法可阅读MDN-drag

利用dragover事件(当某物被拖动的对象在另一对象容器范围内拖动时触发此事件)和drop事件(在一个拖动过程中,释放鼠标键时触发此事件)来处理文件。

文件分片上传

文件分片的大体思路就是前端将大文件拆成一小片的文件,发送给后端,后端进行保存分片的文件,然后当完成所有分片文件的上传之后,前端通过调用后端的合并接口来通知后端将保存的分片文件进行读取写入新的文件里。

在实现文件分片上传之前,前端需要先思考如下问题:

  1. 如何获取用户选择的文件

  2. 获取到的大文件如何进行分片以及要分多大

  3. 一个大文件拆成的小分片如何区别先后顺序,让后端可知先读哪个文件

  4. 如何区分多个不同大文件

  5. 用户上传多个大文件,前端通过什么来保存每个大文件所对应的小文件切片

  6. 如何将每个小分片的数据发送给后端

  7. 如何通知后端所有切片都上传完成

在实现文件分片上传之前,后端需要先思考如下问题:

  1. 保存分片的接口,需要前端给予哪一些字段,来区分不同文件的保存

  2. 合并文件接口,通过什么方式读文件生成新文件

当这些问题的思路清晰的时候,实现起来就不难了,这也是我在学习过程中总结的。

首先获取文件可通过change事件来获取文件,实现多文件上传可通过给input标签设置属性multiple。当获取到的大文件之后,要对大文件进行切片,可通过slice方法实现,切多大可根据用户传的props进行选择。因为用户可能会上传多个文件,所以要将每个大文件所对应的小文件关联起来,这样子在数量文件数据方面不会乱掉,这里我用对象进行处理保存,代码如下:

	//接受文件函数
    handleChange(e){
      this.filesAry=Array.from(e.target.files);
      this.data=this.createChunk(this.filesAry);
    },
	//将单个文件切割
    handleChunk(file){
      let current=0;
      let fileList=[];
      while(current<=file.size){
        fileList.push({
          file:file.slice(current,this.SIZE+current)
        });
        current+=this.SIZE;
      }
      return fileList;
    },
    //将大文件切割
    createChunk(files=[]){
      let filesObj=files.reduce((pre,cur,index,ary)=>{
        pre[`${cur.name}_${index}`]=this.handleChunk(cur);
        return pre;
      },{});
      return filesObj;
    },

最后this.data就是用户上传所有文件的容器,key是有文件名+索引组成的,key所对应的值是一个数组,存放每个大文件所对应的小文件切片。

到这里基本上完成了前端的1-2的coding问题了。

接下来,this.data数据中的每个小分片就是要送给后端的数据,即要发送请求。demo中,通过axios进行请求,并且通过FormData表单的形式发送数据。那么axios的请求封装代码如下:

createRequest({method='post',url='',data={}}){
    return axios({
            method:method,
            url:url,
            data:data,
          })
},

所以在请求之前,要先将this.data大文件所对应的每个小文件组装成formData的数据格式,传送的数据有:file==文件自身,index==小文件自身的索引值,hash==大文件名字_大文件所对应的索引,nameHash==文件的MD5,filename==文件名称。这些在数据上我觉的都送给后端,这样子到后面后端处理文件时,要什么数据都可直接用了。

那么在组装成formData数据时需要用到文件的MD5(这是唯一值),所以用到spark-md5这个库,详细用法可查看官网。生成MD5的代码如下:

createMd5(fileChunkList=[]){
    let currentChunk=0,md5;
    let reader=new FileReader();
    let spark = new SparkMD5.ArrayBuffer();	
    function readFile(){
        if(fileChunkList[currentChunk].file){
            reader.readAsArrayBuffer(fileChunkList[currentChunk].file)
        }
    }
    readFile();
    return new Promise(resolve=>{
        reader.onload=e=>{
            currentChunk++;
            spark.append(e.target.result);
            if(currentChunk<fileChunkList.length){
                readFile();
            }else{
                md5=spark.end();
                resolve(md5);
            }
        };
    })
 },

那么,就可以将每个小分片生成formData数据了,然后将每个formDta对象都一一调用请求函数,就可完成切片的请求封装了。代码如下:

//将每个切片组装成formdata对象
    createFormDataRequest(files=[],prop='',nameHash='',fileName='',fileChunk=[]){
      let target=files.map((file,index)=>{
          let formdata= new FormData();
          formdata.append('file',file.file);
          formdata.append('index',index);
          formdata.append('hash',prop);
          formdata.append('nameHash',nameHash);
          formdata.append('filename',fileName);
          return {formdata,index};
      }).map(({formdata,index})=>{
        return this.createRequest({
            method:'post',
            url:'http://localhost:3001/api/handleUpload',
            data:formdata,
          })
      })
      return target;
    },

最后在点击处理上传按钮处理函数里面调用生成MD5,然后在传给createFormDataRequest函数。因为是多个大文件上传,所以要循环遍历this.data,将每个大文件所对应的小文件数组传给createMd5函数做处理。代码如下:

//点击上传函数
    async handleUpload(e){
       this.targetRequest={};
      for(let prop in this.data){
        if(this.data.hasOwnProperty(prop)){
            let fileName=splitFilename(prop);
            let nameHash=await this.createMd5(this.data[prop]);
            this.targetRequest[`${fileName}_${nameHash}`]=this.createFormDataRequest(this.data[prop],prop,nameHash,fileName);
        }
      }
      //发送请求,并且请求完成之后合并
      Object.keys(this.targetRequest).forEach(async key=>{
        let {filename,nameHash} =splitFileHash(key);
        await Promise.all(this.targetRequest[key]).then(async res=>{
          this.createRequest({
              method:'post',
              url:'http://localhost:3001/api/handleMerge',
              data:{
                filename,
                nameHash,
                SIZE:this.SIZE
              },
          }).then(res=>{
            this.$message.success({
              message:`${filename}上传成功~`
            })
          })
        })
      })
    },

获取到的targetRequest是一个数组,里面承载了Promise(就是每个小分片)。所有通过并发处理发送请求,在完成请求之后,调用合并函数通知后端应该进行文件的合并了。

基本上大文件分片上传前端方面完成了,那么后端就是写处理分片和合并分片的接口。

后端在处理分片时,创建一个文件夹存放每个小分片,文件夹的名称就是用nameHash命名,每个小分片进行重命名,改写成文件名_文件的索引。

const Router=require('koa-router');
const apiRouter=new Router();
const path=require('path');
const fs=require('fs');
const targetPath=path.resolve(__dirname,'../target/');

const splitExt=(filename='')=>{
  let name= filename.slice(0,filename.lastIndexOf('.'));
  let ext=filename.slice(filename.lastIndexOf('.')+1,filename.length);
  return {name,ext};
}


//合并文件
/**
 * hash:大文件名+大文件索引
 * nameHash:大文件的MD5
 * filename:文件名称
 * index:小文件分片的索引
 */
apiRouter.post('/api/handleUpload',async (ctx)=>{
  const {hash,nameHash,filename,index}=ctx.request.body;
  const chunkPath=path.resolve(targetPath,`${nameHash}`);
  if(!fs.existsSync(chunkPath)){
    await fs.mkdirSync(chunkPath);
  }
  const {name,ext}=splitExt(filename);
  console.log(ctx.request.files.file.path,'-',index);
  await fs.renameSync(ctx.request.files.file.path,`${chunkPath}/${filename}_${index}`);
  return ctx.response.status=200;
});



module.exports=apiRouter;

在处理合并的时候,关键是要有目标文件的文件名和扩展名,然后通过createWriteStream和createReadStream流的形式将内容写入到目标文件。部分代码如下:

const Router=require('koa-router');
const apiRouter=new Router();
const path=require('path');
const fs=require('fs');
const targetPath=path.resolve(__dirname,'../target/');
const splitExt=(filename='')=>{
  let name= filename.slice(0,filename.lastIndexOf('.'));
  let ext=filename.slice(filename.lastIndexOf('.')+1,filename.length);
  return {name,ext};
}
//文件合并
/**
 * 参数:filename :大文件mingc
 * nameHash:文件的MD5
 * SIZE:切割的大小
 */
apiRouter.post('/api/handleMerge',async (ctx)=>{
  const {filename,nameHash,SIZE}=ctx.request.body;
  const targetFilePath=path.resolve(targetPath,`${filename}`);
  const pipStream = (path, writeStream) => {
		return new Promise(resolve => {
			const readStream = fs.createReadStream(path);
			readStream.on("end", function(err){
				if(err) throw err;
				// fs.unlinkSync(path);
				resolve();
			});
			readStream.pipe(writeStream,{end:false});
		})
	};
  fs.readdir(path.resolve(targetPath,nameHash),async (err,files)=>{
    if(err) return console.log('err:',err);
    files.sort((a,b)=>a.split('_')[1]-b.split('_')[1]);
    files=files.map(file=>path.resolve(targetPath,nameHash,file));
    Promise.all(files.map(async(file,index)=>{
      return pipStream(file,fs.createWriteStream(targetFilePath,{
        start:index * SIZE,
        end:(index+1)*SIZE,
      }))
    }))
  })
  ctx.response.status=200;
})

module.exports=apiRouter;

断点续传

断点续传的概念是当用户点击暂停按钮时,将正在发送请求的小切片的请求中止了,点击恢复上传之后,再在原来已经上传过的小切片的基础上再进行上传。

大致的问题就是:

  1. 前端如何获取每个小切片的请求中止函数,并且何时处理这些中止函数的时间点

  2. 再点击恢复上传时,如何获取已经传过的小切片

  3. 获取到的小切片之后,如何在原来的切片数组中进行过滤已经上传过的切片

demo中是通过后端将已经上传的文件切片名称进行返回,所以先提供一个接口,返回已经上传过的切片,接收大文件的MD5和大文件名称。代码如下:

const Router=require('koa-router');
const apiRouter=new Router();
const path=require('path');
const fs=require('fs');
const targetPath=path.resolve(__dirname,'../target/');

const splitExt=(filename='')=>{
  let name= filename.slice(0,filename.lastIndexOf('.'));
  let ext=filename.slice(filename.lastIndexOf('.')+1,filename.length);
  return {name,ext};
}

//重新上传文件
/**
 * nameHash:大文件的MD5
 * filename:文件名称
 */
apiRouter.post('/api/handleAgain',async (ctx)=>{
  const {nameHash,filename}=ctx.request.body;
  console.log(nameHash,filename)
  const chunkPath=path.resolve(targetPath,`${nameHash}`);
  if(fs.existsSync(chunkPath)){
    let filesChunk=await fs.readdirSync(chunkPath);
    return ctx.body={fileChunk:filesChunk,filename:filename,flag:true}; //找到了
  }else{
    return ctx.body={fileChunk:[],filename:filename,flag:false};  //找不到
  }
});



module.exports=apiRouter;

接口不仅返给前端已经上传的文件切片,还给一个flag标识符(代码是否找的到切片的文件夹),而且将文件名称一起给前端,因为是多个文件上传,这样前端在待会恢复上传函数处理数据会更简单一些。

demo是通过axios的CancelToken进行生成每个小切片的中止函数,并且是在请求拦截器进行获取。然后在点击暂停按钮进行中止函数的发布,再清空。当然要记住已经完成请求的中止函数要过滤掉。在createRequest函数新增代码:

axios.interceptors.request.use((config)=>{
      let CancelToken = axios.CancelToken;
      //设置取消函数
      config.cancelToken = new CancelToken((c)=>{
      	this.cancelAry.push({fn:c,url:config.url});
      });
      return config;
      },(err)=>{
      	return Promise.reject(err);
      });
      axios.interceptors.response.use((response)=>{
      	let {config} =response;
      	this.cancelAry=this.cancelAry.filter(cancel=>cancel.url!==config.url);
      	return response;
      },(err)=>{
      	return Promise.reject(err);
});

在点击取消处理函数中发布中止函数:

//点击暂停函数
    handleCancel(e){
      this.cancelAry.forEach(fn=>fn());
      this.cancelAry=[];
    },

接下来就是完成点击恢复上传的逻辑,调用/api/handleAgain将已经上传的数据获取,然后过滤数据,最后再调用上传分片的和合并分片的接口。

为了不让代码冗余,将过滤数据的逻辑新增到createFormDataRequest函数中,这样过滤完成就可生成formData数据。新增的代码如下:

files.filter((file,index)=>fileChunk.includes(`${fileName}_${index}`)!=true)

后端的flag标识符是为了做是否是第一次上传。因为如果flag为true,即后端找到了大目标文件,那么说明就要过滤数据,并且给用户秒传成功的信号。如何第一次开始上传的大文件,后端没有数据,那么就是第一次上传,就不需要过滤数据。并且在点击上传文件的时候就要做这层的判断了,因为目标文件要在原有基础上继续上传。 所以在点击上传函数要新增调用恢复点击上传处理函数,新增代码如下:

let {result,fileChunk}=await this.handleAgain(this.data[prop],prop,nameHash,fileName);
            if(!result){
              return;
            }
            this.targetRequest[`${fileName}_${nameHash}`]=this.createFormDataRequest(this.data[prop],prop,nameHash,fileName,fileChunk);

那么点击恢复上传的部分代码如下:

 //点击重新上传函数
    async handleAgain(nameHash,filename,requestChunk=[]){
      let result,fileChunk=[];
        await this.restoreFile(nameHash,filename).then(res=>{
          if(res.status==200){
            if(res.data.flag==false){
              this.$message('秒传成功~~');
              result=true;
            }else{
              fileChunk=re.data.fileChunk;
            }
          }
        })
      return {result,fileChunk};
    },

好了,基本上完成了断点续传的功能了。

文件预览

demo目前支持图片预览功能,后续有时间再继续完善,主要是运用FileReader来生成reader对象,并且通过readAsDataURL来获取url。

coding问题记录

问题一

关键词:illegal operation on a directory 对目录进行非法操作 原因是因为我在学习的过程中运用createWriteStream这个api的时候,传入的第一个参数文件路径和已经存在的文件重名报错了。亲手测试了~~

问题二

err: Error: write after end at writeAfterEnd (_stream_writable.js:236:12) at Form.Writable.write (_stream_writable.js:287:5) at IncomingMessage.ondata (_stream_readable.js:639:20) at emitOne (events.js:116:13) at IncomingMessage.emit (events.js:211:7) at addChunk (_stream_readable.js:263:12) at readableAddChunk (_stream_readable.js:250:11) at IncomingMessage.Readable.push (_stream_readable.js:208:10) at HTTPParser.parserOnBody (_http_common.js:130:22)

问题三

关键词:BadRequestError: stream ended unexpectedly 问题二和问题三的原因是因为我在写上传分片接口的是将const multipart=new multiparty.Form();不是放在接口处理逻辑里面,而是放在外面。这样就导致了,同一个大文件下的几个个分片同一使用multipart对象,应该是每个分片都有对应的multipart对象。

总结

经过自己的学习和研究,收获还是挺大的。demo还有优化的点,想想如何解决将并发请求。后续会更新demo的代码,敬请期待~~

文章整理良久,感觉不错,望手动点赞鼓励~~

多个大文件拖拽上传的代码地址是:koa-vue-uploadfile-demo

自研的代码是我之前搭建的开箱即用的demo拉了一个小分支进行学习的,代码地址是:project-dev-demo

如果觉得不错还望手动点star~~

参考资料

字节跳动面试官:请你实现一个大文件上传和断点续传

写给新手前端的各种文件上传攻略,从小图片到大文件断点续传