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

发布时间 2023-11-17 16:17:47作者: 柯基与佩奇

技术关键词

前端:@vue/cli-service+element-ui+axios
后端:node.js+koa

思路分析

拖拽上传

拖拽上传是利用 HTML5 新特性实现拖拽上传,详细用法可阅读 MDN-drag
利用 dragover 事件(当某物被拖动的对象在另一对象容器范围内拖动时触发此事件)和 drop 事件(在一个拖动过程中,释放鼠标键时触发此事件)来处理文件。

文件分片上传

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

在实现文件分片上传之前,前端需要先思考如下问题:
如何获取用户选择的文件
获取到的大文件如何进行分片以及要分多大
一个大文件拆成的小分片如何区别先后顺序,让后端可知先读哪个文件
如何区分多个不同大文件
用户上传多个大文件,前端通过什么来保存每个大文件所对应的小文件切片
如何将每个小分片的数据发送给后端
如何通知后端所有切片都上传完成

在实现文件分片上传之前,后端需要先思考如下问题:
保存分片的接口,需要前端给予哪一些字段,来区分不同文件的保存
合并文件接口,通过什么方式读文件生成新文件

当这些问题的思路清晰的时候,实现起来就不难了。
首先获取文件可通过 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;

断点续传

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

大致的问题就是:
前端如何获取每个小切片的请求中止函数,并且何时处理这些中止函数的时间点
再点击恢复上传时,如何获取已经传过的小切片
获取到的小切片之后,如何在原来的切片数组中进行过滤已经上传过的切片

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。