关于文件上传的那点事

933 阅读4分钟

阅读本文你将学会

  • 自定义上传按钮样式
  • 文件流方式上传文件
  • 文件转换为bs64上传
  • 大文件断点上传

一、文件上传的两套方案

  • 1、基于文件流的方式上传
    • 格式:multipart/form-data
    • 数据格式:form-data
      • 文件流信息:file
      • 文件名字:filename
  • 2、客户端将文件转换为BASE64上传到服务器端

二、使用文件流的方式上传文件

  • 1、前端页面(不使用elementui自带的上传组件)

  • 2、自定义上传按钮的原理

    <input type="file" style="display:none;" id="file" />
    <el-button type="primary" @click="uploadFile">选择上传文件</el-button>
    <el-button type="warning" @click="confirmUpload">开始上传</el-button>
    

    一个原始的上传文件的我们设置为隐藏状态,另外设置一个美观的按钮来做点击上传按钮,当点击上传文件按钮的时候去触发原始上传文件按钮的点击事件,这时候就可以在电脑端打开选择文件面板了,然后监听原始按钮的change事件,根据选择的文件存储到data中,点击开始上传按钮才发送ajax请求

  • 3、前端实现上传的代码片段

    import axios from 'axios';
    export default {
      name: 'App',
      data () {
        return {
          currentFile: null,
        }
      },
      methods: {
        // 点击上传按钮
        uploadFile () {
          const that = this;
          const fileNode = document.getElementById('file');
          // 触发原始上传文件的点击事件
          fileNode.click();
          fileNode.addEventListener('change', function (ev) {
            that.currentFile = ev.target.files[0];
          })
        },
        // 上传前的钩子函数(根据你需求放到哪里去)
        beforeUpload (file) {
          if (!file) {
            this.$message.warning('请选择上传的文件');
            return false;
          }
          const { type, size } = file;
          // 文件格式化校验
          if (!/(png|gif|jpeg|jpg)$/i.test(type)) {
            this.$message.warning("文件合适不正确");
            return false;
          }
          // 文件大小
          if (size > 5 * 1024 * 1024) {
            this.$message.warning('文件过大,请上传小于5MB的文件');
            return false;
          }
          return true;
        },
        // 开始上传
        confirmUpload () {
          let formData = new FormData();
          const currentFile = this.currentFile;
          // 上传前的钩子函数
          const flag = this.beforeUpload(currentFile);
          if (!flag) {
            return false;
          };
          // 组装数据发送axios请求
          formData.append('file', currentFile, currentFile.name);
          // 根据后端上传文件携带其他的参数来写,不需要参数可以不写
          formData.append('title', '测试文件');
          // 设置请求头
          const headers = {
            // "Content-Type": "multipart/form-data",
          }
          // 发送ajax请求
          axios.post('/upload1', formData, { headers }).then(res => {
            this.currentFile = null;
            console.log(res);
          })
        }
      },
      components: {
      }
    }
    
  • 4、使用express后端接收上传文件

    const express = require('express')
    const bodyParser = require('body-parser')
    const multiparty = require('multiparty')
    const PORT = 8888
    const app = express()
    
    // 处理post请求体数据
    app.use(bodyParser.urlencoded({
      extended: false,
      limit: '1024mb'
    }))
    app.use(bodyParser.json());
    
    // 设置上传目录
    const uploadDir = `${__dirname}/upload`
    
    // 定义公共上传的方法
    function handleMultiparty (req, res, tem = false) {
      return new Promise((resolve, reject) => {
        const options = {
          maxFieldsSize: 200 * 1024 * 1024
        }
        if (!tem) {
          options.uploadDir = uploadDir
        }
        const form = new multiparty.Form(options)
        form.parse(req, function (err, fields, files) {
          if (err) {
            res.send({
              code: 1,
              message: JSON.stringify(err)
            })
            reject(err);
            return false;
          }
          resolve({
            fields, 
            files,
          })
        })
      })
    }
    
    // 基于form-data上传数据
    app.post('/upload1', async(req, res) => {
      const { files, fields} = await handleMultiparty(req, res);
      console.log(fields, 'formData中携带的参数');
      const file = files.file[0];
      res.send({
        code: 0,
        originalFilename: file.originalFilename,
        path: file.path.replace(__dirname, `http://127.0.0.1:${PORT}`)
      })
    })
    
    app.listen(PORT, () => {
      console.log(`服务已经启动,请访问localhost:${PORT}`)
    })
    
  • 5、前端完整代码App.vue

三、前端上传base64到后端

  • 1、前端中定义将文件转换为bs64字符的方法

    // 将文件转换为bs64
    fileParse (file, type = "base64") {
      return new Promise(resolve => {
        let fileRead = new FileReader();
        if (type === "base64") {
          fileRead.readAsDataURL(file);
        } else if (type === "buffer") {
          fileRead.readAsArrayBuffer(file);
        }
        fileRead.onload = (ev) => {
          resolve(ev.target.result);
        };
      });
    }
    
  • 2、文件上传

    // 开始上传
    async confirmUpload () {
      let formData = new FormData();
      const currentFile = this.currentFile;
      // 上传前的钩子函数
      const flag = this.beforeUpload(currentFile);
      if (!flag) {
        return false;
      };
      const result = await this.fileParse(currentFile, 'base64');
      const postData = {
        chunk: encodeURIComponent(result),
        filename: currentFile.name,
        title: '测试文件',
      };
      // 设置请求头
      const headers = {
        // "Content-Type": "multipart/form-data",
      }
      // 发送ajax请求
      axios.post('/upload2', postData, { headers }).then(res => {
        this.currentFile = null;
        console.log(res);
      })
    },
    
  • 3、后端接收前端处理的数据并将转换为Buffer存储到服务器端

    const SparkMD5 = require('spark-md5');
    const fs = require('fs');
    
    // 处理当文件过大,bs64比较大的时候出现request entity too large错误
    app.use(bodyParser.json({
      limit: '50mb'
    }));
    ...
    // 使用base64上传文件
    app.post('/upload2', async(req, res) => {
      const {chunk, filename, title} = req.body;
      console.log(title)
      // 将前端传递过来的bs64转换为buffer
      const chunk1 = decodeURIComponent(chunk);
      const chunk2 = chunk1.replace(/^data:image\/\w+;base64,/, "");
      const chunk3 = Buffer.from(chunk2, 'base64');
      // 存储文件到服务器端
      const spark = new SparkMD5.ArrayBuffer();
      const suffix = /\.([0-9a-zA-Z]+)$/.exec(filename)[1]; // 文件的后缀名
      spark.append(chunk3);
      const path = `${uploadDir}/${spark.end()}.${suffix}`;
      fs.writeFileSync(path, chunk3);
      res.send({
        code: 0,
        originalFilename: filename,
        path: path.replace(__dirname, `http://127.0.0.1:${PORT}`)
      });
    });
    
  • 4、前端完整代码App2.vue

四、前端大文件断点上传

大致原理就是将大文件分割成好几个部分(根据固定数量/固定大小方式),每个切片都有自己的数据和各自的名字,每一部分都发起一次ajax请求,将切片传递到服务器端。服务器端根据文件创建一个文件夹,用来存放大文件的切片,当客户端将全部切片传递到服务器端的时候,再发起一次请求告知服务器端,前端将数据全部传递完成了,服务器端接收到传递完成的通知的时候,将刚刚文件夹里面的文件全部合并成一个文件,最后将该文件夹删除。简短概括:大文件-->拆成很多小文件-->发起很多ajax请求发送小文件-->服务器端接收小文件-->组装成大文件

  • 1、将大文件拆分成很多小文件来上传

    ...
    // 根据文件内容生成唯一的hash
    import SparkMD5 from "spark-md5";
    ...
    // 开始上传
    async confirmUpload () {
      let formData = new FormData();
      const currentFile = this.currentFile;
      // 上传前的钩子函数
      const flag = this.beforeUpload(currentFile);
      if (!flag) {
        return false;
      };
      const fileBuffer = await this.fileParse(currentFile, 'buffer');
      let spark = new SparkMD5.ArrayBuffer();
      spark.append(fileBuffer);
      const hash = spark.end();
      const suffix = /\.([0-9a-zA-Z]+)$/i.exec(currentFile.name)[1];
      // 将文件切割为100份来上传
      let partList = [];
      const partSize = currentFile.size / 100;
      let cur = 0;
      for (let i = 0; i < 100; i++) {
        let item = {
          chunk: currentFile.slice(cur, cur + partSize),
          filename: `${hash}_${i}.${suffix}`,
        }
        cur += partSize;
        partList.push(item);
      }
      this.partList = partList;
      this.hash = hash;
      // 发送ajax请求到服务器端
      this.sendRequest();
    },
    
  • 2、根据文件切片发起ajax请求

    async sendRequest () {
      // 根据多少切片来创建多少请求
      let requestList = [];
      // 设置请求头
      const headers = {
        // "Content-Type": "multipart/form-data",
      }
      this.partList.forEach((item, index) => {
        const fn = () => {
          let formData = new FormData();
          formData.append('chunk', item.chunk);
          formData.append('filename', item.filename);
          // 发送ajax请求
          axios.post('/upload3', formData, { headers }).then(res => {
            const data = res.data;
            if (data.code == 0) {
              this.total += 1;
              // 传完的切片我们把它移除掉
              this.partList.splice(index, 1);
            }
          })
        }
        requestList.push(fn);
      });
      let currentIndex = 0;
      const send = async () => {
        // 如果中断上传就不在发送请求
        if (this.abort) return;
        if (currentIndex >= requestList.length) {
          // 调用上传完成的按钮,告诉后端合并文件
          this.complete();
          return;
        }
        await requestList[currentIndex]();
        currentIndex++;
        send();
      }
      send();
    },
    
  • 3、全部切片上传完成后通知后端上传完成

    // 文件上传,需要后端合并文件
    complete () {
      axios.get('/merge', {
        params: {
          hash: this.hash,
        }
      }).then(res => {
        console.log(res, '上传完成');
      })
    },
    
  • 4、模拟暂停与开始

    // 暂停和开始
    handleBtn () {
      if (this.btn) {
        //断点续传
        this.abort = false;
        this.btn = false;
        this.sendRequest();
        return;
      }
      //暂停上传
      this.btn = true;
      this.abort = true;
    }
    
  • 5、代码见App3.vue

五、大文件上传后端部分代码

  • 1、接收文件切片

    // 切片上传
    app.post('/upload3', async (req, res) => {
      const {fields,files} = await handleMultiparty(req, res, true);
      const [chunk] = files.chunk;
      const [filename] = fields.filename;
      // 获取上传文件的hash
      const hash = /([0-9a-zA-Z]+)_\d+/.exec(filename)[1];
      const dir = `${uploadDir}/${hash}`;
      if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir);
      }
      const path = `${dir}/${filename}`;
      fs.access(path, async err => {
        // 如果已经存在了就不做任何处理
        if (!err) {
          res.send({
            code: 0,
            path: path.replace(__dirname, `http://127.0.0.1:${PORT}`)
          })
        }
        // 测试上传需要时间,手动延迟
        await new Promise(resolve => {
          setTimeout(() => {
            resolve();
          }, 100);
        });
        // 不存在的时候就创建
        const readStream = fs.createReadStream(chunk.path);
        const writeStream = fs.createWriteStream(path);
        readStream.pipe(writeStream);
        readStream.on('end', function() {
          fs.unlinkSync(chunk.path);
          res.send({
            code: 0,
            path: path.replace(__dirname, `http://127.0.0.1:${PORT}`)
          });
        })
      })
    });
    
  • 2、合并多个切片文件

    // 大文件上传后
    app.get('/merge',(req, res) => {
      const { hash } = req.query;
      const path = `${uploadDir}/${hash}`;
      const fileList = fs.readdirSync(path);
      let suffix = null;
      fileList.sort((a, b) => {
        const reg = /_(\d+)/;
        return reg.exec(a)[1] - reg.exec(b)[1];
      }).forEach(item => {
        !suffix ? suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1] : null;
        // 写入文件
        fs.appendFileSync(`${uploadDir}/${hash}.${suffix}`, fs.readFileSync(`${path}/${item}`));
        // 删除文件
        fs.unlinkSync(`${path}/${item}`);
      });
      fs.rmdirSync(path);
      res.send({
        code: 0,
        path: `http://127.0.0.1:${PORT}/upload/${hash}.${suffix}`
      });
    })
    

六、源码地址