最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • React+Koa文件上传的实现

    正文概述 掘金(孤雨随风zz)   2021-04-05   542

    背景

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

    服务端依赖

    • 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

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


    起源地下载网 » React+Koa文件上传的实现

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元