专栏名称: 孤雨随风zz
目录
相关文章推荐
高校人才网V  ·  深圳大学生命与海洋科学学院黄腾波课题组202 ... ·  4 天前  
高校人才网V  ·  荆州学院2024-2025年学年招聘公告 ·  6 天前  
51好读  ›  专栏  ›  孤雨随风zz

React+Koa文件上传的实现

孤雨随风zz  · 掘金  ·  · 2021-04-05 18:13

正文

阅读 4

React+Koa文件上传的实现

背景

最近在写毕设的时候,涉及到了一些文件上传的功能,其中包括了普通文件上传,大文件上传,断点续传等等

服务端依赖

  • koa(node.js框架)
  • koa-router(Koa路由)
  • koa-body(Koa body 解析中间件,可以用于解析post请求内容)
  • koa-static-cache(Koa 静态资源中间件,用于处理静态资源请求)
  • koa-bodyparser(解析 request.body 的内容)

后端配置跨域

app.use(async (ctx, next) => {
  ctx.set('Access-Control-Allow-Origin', '*');
  ctx.set(
    'Access-Control-Allow-Headers',
    'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , yourHeaderFeild',
  );
  ctx.set('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
  if (ctx.method == 'OPTIONS') {
    ctx.body = 200;
  } else {
    await next();
  }
});
复制代码

后端配置静态资源访问 使用 koa-static-cache

// 静态资源处理
app.use(
  KoaStaticCache('./pulbic', {
    prefix: '/public',
    dynamic: true,
    gzip: true,
  }),
);
复制代码

后端配置requst body parse 使用 koa-bodyparser

const bodyParser = require('koa-bodyparser');
app.use(bodyParser());

复制代码

前端依赖

  • React
  • Antd
  • axios

正常文件上传

后端

后端只需要使用 koa-body 配置好options,作为中间件,传入router.post('url',middleware,callback)即可

  • 后端代码

     // 上传配置
    const uploadOptions = {
    // 支持文件格式
      multipart: true,
      formidable: {
        // 上传目录 这边直接上传到public文件夹,方便访问 文件夹后面要记得加/
        uploadDir: path.join(__dirname, '../../pulbic/'),
        // 保留文件扩展名
        keepExtensions: true,
      },
    };
    router.post('/upload', new KoaBody(uploadOptions), (ctx, next) => {
      // 获取上传的文件
      const file = ctx.request.files.file;
      const fileName = file.path.split('/')[file.path.split('/').length-1];
      ctx.body = {
          code:0,
          data:{
            url:`public/${fileName}`
          },
          message:'success'
    
      }
    });
    复制代码

前端

  我这里使用的是formData传递的方式,前端通过<input type='file'/> 来访问文件选择器,通过onChange事件 e.target.files[0] 即可获取选择的文件,而后创建FormData 对象将获取的文件formData.append('file',targetFile)即可

  • 前端代码
         const Upload = () => {
         const [url, setUrl] = useState<string>('')
         const handleClickUpload = () => {
             const fileLoader = document.querySelector('#btnFile') as HTMLInputElement;
             if (isNil(fileLoader)) {
                 return;
             }
             fileLoader.click();
         }
         const handleUpload = async (e: any) => {
             //获取上传文件
             const file = e.target.files[0];
             const formData = new FormData()
             formData.append('file', file);
             // 上传文件
             const { data } = await uploadSmallFile(formData);
             console.log(data.url);
             setUrl(`${baseURL}${data.url}`);
         }
         return (
             <div>
                 <input type="file" id="btnFile" onChange={handleUpload} style={{ display: 'none' }} />
                 <Button onClick={handleClickUpload}>上传小文件</Button>
                 <img src={url} />
             </div>
         )
     }
    
    复制代码
  • 其他可选方法
    • input+form 设置form的aciton为后端页面,enctype="multipart/form-data",type=‘post’
    • 使用fileReader读取文件数据进行上传 兼容性不是特别好

大文件上传

  文件上传的时候,可能会因为文件过大,导致请求超时,这时候就可以采取分片的方式,简单来说就是将文件拆分为一个个小块,传给服务器,这些小块标识了自己属于哪一个文件的哪一个位置,在所有小块传递完毕后,后端执行merge 将这些文件合并了完整文件,完成整个传输过程

前端

  • 获取文件和前面一样,不再赘述
  • 设置默认分片大小,文件切片,每一片名字为 filename.index.ext,递归请求直到整个文件发送完请求合并
    const handleUploadLarge = async (e: any) => {
          //获取上传文件
          const file = e.target.files[0];
          // 对于文件分片
          await uploadEveryChunk(file, 0);
      }
      const uploadEveryChunk = (
          file: File,
          index: number,
      ) => {
          console.log(index);
          const chunkSize = 512; // 分片宽度
          // [ 文件名, 文件后缀 ]
          const [fname, fext] = file.name.split('.');
          // 获取当前片的起始字节
          const start = index * chunkSize;
          if (start > file.size) {
              // 当超出文件大小,停止递归上传
              return mergeLargeFile(file.name);
          }
          const blob = file.slice(start, start + chunkSize);
          // 为每片进行命名
          const blobName = `${fname}.${index}.${fext}`;
          const blobFile = new File([blob], blobName);
          const formData = new FormData();
          formData.append('file', blobFile);
          uploadLargeFile(formData).then((res) => {
              // 递归分片上传
              uploadEveryChunk(file, ++index);
          });
      };
复制代码

后端

后端需要提供两个接口

上传

将上传的每一个分块存储到对应name 的文件夹,便于之后合并

const uploadStencilPreviewOptions = {
multipart: true,
formidable: {
  uploadDir: path.resolve(__dirname, '../../temp/'), // 文件存放地址
  keepExtensions: true,
  maxFieldsSize: 2 * 1024 * 1024,
},
};

router.post('/upload_chunk', new KoaBody(uploadStencilPreviewOptions), async (ctx) => {
try {
  const file = ctx.request.files.file;
  // [ name, index, ext ] - 分割文件名
  const fileNameArr = file.name.split('.');

  const UPLOAD_DIR = path.resolve(__dirname, '../../temp');
  // 存放切片的目录
  const chunkDir = `${UPLOAD_DIR}/${fileNameArr[0]}`;
  if (!fse.existsSync(chunkDir)) {
    // 没有目录就创建目录
    // 创建大文件的临时目录
    await fse.mkdirs(chunkDir);
  }
  // 原文件名.index - 每个分片的具体地址和名字
  const dPath = path.join(chunkDir, fileNameArr[1]);

  // 将分片文件从 temp 中移动到本次上传大文件的临时目录
  await fse.move(file.path, dPath, { overwrite: true });
  ctx.body = {
    code: 0,
    message: '文件上传成功',
  };
} catch (e) {
  ctx.body = {
    code: -1,
    message: `文件上传失败:${e.toString()}`,
  };
}
});

复制代码

合并

  根据前端传来合并请求,携带的name去临时缓存大文件分块的文件夹找到属于该name的文件夹,根据index顺序读取chunks后,合并文件fse.appendFileSync(path,data) (按顺序追加写即合并),然后删除临时存储的文件夹释放内存空间

router.post('/merge_chunk', async (ctx) => {
 try {
   const { fileName } = ctx.request.body;
   const fname = fileName.split('.')[0];
   const TEMP_DIR = path.resolve(__dirname, '../../temp');
   const static_preview_url = '/public/previews';
   const STORAGE_DIR = path.resolve(__dirname, `../..${static_preview_url}`);
   const chunkDir = path.join(TEMP_DIR, fname);
   const chunks = await fse.readdir(chunkDir);
   chunks
     .sort((a, b) => a - b)
     .map((chunkPath) => {
       // 合并文件
       fse.appendFileSync(
         path.join(STORAGE_DIR, fileName),
         fse.readFileSync(`${chunkDir}/${chunkPath}`),
       );
     });
   // 删除临时文件夹
   fse.removeSync(chunkDir);
   // 图片访问的url
   const url = `http://${ctx.request.header.host}${static_preview_url}/${fileName}`;
   ctx.body = {
     code: 0,
     data: { url },
     message: 'success',
   };
 } catch (e) {
   ctx.body = { code: -1, message: `合并失败:${e.toString()}` };
 }
});

复制代码

断点续传

  大文件在传输过程中,如果刷新页面或者临时的失败导致传输失败,又需要从头传输对于用户的体验是很不好的。因此就需要在传输失败的位置,做好标记,下一次直接在这里进行传输即可,我采取的是在localStorage读写的方式

   const handleUploadLarge = async (e: any) => {
       //获取上传文件
       const file = e.target.files[0];
       const record = JSON.parse(localStorage.getItem('uploadRecord') as any);
       if (!isNil(record)) {
           // 这里为了便于展示,先不考虑碰撞问题, 判断文件是否是同一个可以使用hash文件的方式
           // 对于大文件可以采用hash(一块文件+文件size)的方式来判断两文件是否相同
           if(record.name === file.name){
               return await uploadEveryChunk(file, record.index);
           }
       }
       // 对于文件分片
       await uploadEveryChunk(file, 0);
   }
   const uploadEveryChunk = (
       file: File,
       index: number,
   ) => {
       const chunkSize = 512; // 分片宽度
       // [ 文件名, 文件后缀 ]
       const [fname, fext] = file.name.split('.');
       // 获取当前片的起始字节
       const start = index * chunkSize;
       if (start > file.size) {
           // 当超出文件大小,停止递归上传
           return mergeLargeFile(file.name).then(()=>{
               // 合并成功以后删除记录
               localStorage.removeItem('uploadRecord')
           });
       }
       const blob = file.slice(start, start + chunkSize);
       // 为每片进行命名
       const blobName = `${fname}.${index}.${fext}`;
       const blobFile = new File([blob], blobName);
       const formData = new FormData();
       formData.append('file', blobFile);
       uploadLargeFile(formData).then((res) => {
           // 传输成功每一块的返回后记录位置
           localStorage.setItem('uploadRecord',JSON.stringify({
               name:file.name,
               index:index+1
           }))
           // 递归分片上传
           uploadEveryChunk(file, ++index);
       });
   };
复制代码

文件相同判断

  通过计算文件MD5,hash等方式均可,当文件过大时,进行hash可能会花费较大的时间。 可取文件的一块chunk与文件的大小进行hash,进行局部的采样比对, 这里展示 通过 crypto-js库进行计算md5,FileReader读取文件的代码

// 计算md5 看是否已经存在
     const sign = tempFile.slice(0, 512);
     const signFile = new File(
       [sign, (tempFile.size as unknown) as BlobPart],
       '',
     );
     const reader = new FileReader();
     reader.onload = function (event) {
       const binary = event?.target?.result;
       const md5 = binary && CryptoJs.MD5(binary as string).toString();
       const record = localStorage.getItem('upLoadMD5');
       if (isNil(md5)) {
         const file = blobToFile(blob, `${getRandomFileName()}.png`);
         return uploadPreview(file, 0, md5);
       }
       const file = blobToFile(blob, `${md5}.png`);
       if (isNil(record)) {
         // 直接从头传 记录这个md5
         return uploadPreview(file, 0, md5);
       }
       const recordObj = JSON.parse(record);
       if (recordObj.md5 == md5) {
         // 从记录位置开始传
         //断点续传
         return uploadPreview(file, recordObj.index, md5);
       }
       return uploadPreview(file, 0, md5);
     };
     reader.readAsBinaryString(signFile);

复制代码

总结

  之前一直对于上传文件没有过太多的了解,通过毕设的这个功能,对于上传文件的前后端代码有了初步的认识,可能这些方法也只是其中的选项并不包括所有,希望未来的学习中能够不断的完善。
  第一次在掘金写博客,在参加实习以后,发现自己的知识体量的不足,希望能够通过坚持写博客的方式,来梳理自己的知识体系,记录自己的学习历程,也希望各位大神在发现问题时不吝赐教,thx

即使最后没有人为你鼓掌,也要优雅的谢幕,感谢自己的认真付出