前端大文件上传,即以流的方式上传

5,418 阅读4分钟

前言

在上传较大的文件时,将文件切割成多个小块,然后每次只发送一小块,等到全部传输完毕之后,服务端将接受的多个小块进行合并,组成上传的文件,这就是前端上传大文件的方式,也就是所谓的以流的方式上传

下面会介绍如下几个快内容

  • 前端代码如何编写
  • 后端代码如何编写(node)
  • vue 中如何处理
  • 使用插件如何处理

1. 前端代码实现

这里先不通过 vue,而是通过原生的 html、js 的方式实现上传,如此更加容易理解逻辑,等后面再将其转换成 vue 写法 文件上传通过 axios ,所以,可以先配置其 baseurl,我这里为axios.defaults.baseURL = http://localhost:3000`;`

html 代码

<div id="app">
  <form action="">
    <input type="file" name="" id="uploadInput" />
    <button id="uploadBtn">上传</button>
  </form>
</div>

1.1 选择上传文件

为 文件域 添加 change 事件,当用户选择要上传的文件后,将文件信息赋值给一个变量,方便上传文件时使用

document
    .getElementById("uploadInput")
    .addEventListener("change", handleFileChange);
    
let file = null;
  // 文件被更改
  function handleFileChange(event) {
    const file = event.target.files[0];
    if (!file) return;
    window.file = file;
  }

1.2 文件上传

文件上传分为如下几个步骤

① 创建切片

② 上传切片

③ 全部上传成功后,告诉后端,后端将所有的切片整合成一个文件

首先编写几个函数,用于切片的处理及上传,最后再组合到一起实现完整功能

1.2.1 创建切片

// 创建切片
  const createFileChunks = function (file, size = 1024*100) {
      // 创建数组,存储文件的所有切片
      let fileChunks = [];
      for (let cur = 0; cur < file.size; cur += size) {
        // file.slice 方法用于切割文件,从 cur 字节开始,切割到 cur+size 字节
        fileChunks.push(file.slice(cur, cur + size));
      }
      return fileChunks;
    };

createFileChunks 方法接收两个参数

  • 要进行切片的文件对象
  • 切片大小,这里设置默认值为 1024*100,单位为字节

1.2.2 拼接 formData

上传的时候,通过 formData 对象组装要上传的切片数据

/**
   * 2、拼接 formData
   * 参数1:存储文件切片信息的数组
   * 参数2:上传时的文件名称
   */
  const concatFormData = function (fileChunks, filename) {
    /**
     * map 方法会遍历切片数组 fileChunks中的元素map 方法会遍历切片数组 fileChunks中的元素,
     * 数组中有多少个切片,创建几个 formData,在其中上传的文件名称、hash值和切片,并将此 formData
     * 返回,最终chunksList中存储的就是多个 formData(每个切片对应一个 formData)
     *
     */
    const chunksList = fileChunks.map((chunk, index) => {
      let formData = new FormData();
      // 这个'filename' 字符串的名字要与后端约定好
      formData.append("filename", filename);
      // 作为区分每个切片的编号,后端会以此作为切片的文件名称,此名称也应该与后端约定好
      formData.append("hash", index);
      // 后端会以此作为切片文件的内容
      formData.append("chunk", chunk);
      return {
        formData,
      };
    });
    return chunksList;
  };

1.2.3 上传切片

遍历上面的 chunksList 数组,调用 axios 对每个 formData 信息进行提交

// 3、上传切片
    const uploadChunks=async (chunksList)=>{
      const uploadList = chunksList.map(({ formData }) =>
        axios({
          method: "post",
          url: "/upload",
          data: formData,
        })
      );
      await Promise.all(uploadList);
    }

1.2.4 合并切片

当所有切片都已经上传成功后,告诉后端一声

 // 合并切片
    const mergeFileChunks = async function (filename) {
      await axios({
        method: "get",
        url: "/merge",
        params: {
          filename,
        },
      });
    };

1.2.5 方法组合

上面编写了几个函数,下面将几个方法串联起来,实现切片上传功能

为上传按钮绑定单击事件

document
    .getElementById("uploadBtn")
    .addEventListener("click", handleFileUpload);

handleFileUpload 函数

 // 大文件上传
  async function handleFileUpload(event) {
    event.preventDefault();

    const file = window.file;
    if (!file) return;
    // 1、切片切割,第二个参数采用默认值
    const fileChunks = createFileChunks(file);
    // 2、将切片信息拼接成 formData 对象
    const chunksList = concatFormData(fileChunks, file.name);
    // 3、上传切片
    await uploadChunks(chunksList);
    // 4、所有切片上传成功后后,再告诉后端所有切片都已完成
    await mergeFileChunks(file.name);
    console.log("上传完成");
  }

1.2.6 完整代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>大文件上传</title>
  </head>
  <body>
    <div id="app">
      <form action="">
        <input type="file" name="" id="uploadInput" />
        <button id="uploadBtn">上传</button>
      </form>
    </div>
  </body>
</html>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
  axios.defaults.baseURL = `http://localhost:3000`;

  let file = null;
  // 文件被更改
  function handleFileChange(event) {
    const file = event.target.files[0];
    if (!file) return;
    window.file = file;
  }

  // 1、创建切片
  const createFileChunks = (file, size = 1024 * 100) => {
    // 创建数组,存储文件的所有切片
    let fileChunks = [];
    for (let cur = 0; cur < file.size; cur += size) {
      // file.slice 方法用于切割文件,从 cur 字节开始,切割到 cur+size 字节
      fileChunks.push(file.slice(cur, cur + size));
    }
    return fileChunks;
  };
  /**
   * 2、拼接 formData
   * 参数1:存储文件切片信息的数组
   * 参数2:上传时的文件名称
   */
  const concatFormData = function (fileChunks, filename) {
    /**
     * map 方法会遍历切片数组 fileChunks中的元素map 方法会遍历切片数组 fileChunks中的元素,
     * 数组中有多少个切片,创建几个 formData,在其中上传的文件名称、hash值和切片,并将此 formData
     * 返回,最终chunksList中存储的就是多个 formData(每个切片对应一个 formData)
     *
     */
    const chunksList = fileChunks.map((chunk, index) => {
      let formData = new FormData();
      // 这个'filename' 字符串的名字要与后端约定好
      formData.append("filename", filename);
      // 作为区分每个切片的编号,后端会以此作为切片的文件名称,此名称也应该与后端约定好
      formData.append("hash", index);
      // 后端会以此作为切片文件的内容
      formData.append("chunk", chunk);
      return {
        formData,
      };
    });
    return chunksList;
  };
  // 3、上传切片
  const uploadChunks = async (chunksList) => {
    const uploadList = chunksList.map(({ formData }) =>
      axios({
        method: "post",
        url: "/upload",
        data: formData,
      })
    );
    await Promise.all(uploadList);
  };

  // 大文件上传
  async function handleFileUpload(event) {
    event.preventDefault();

    const file = window.file;
    if (!file) return;
    // 1、切片切割,第二个参数采用默认值
    const fileChunks = createFileChunks(file);
    // 2、将切片信息拼接成 formData 对象
    const chunksList = concatFormData(fileChunks, file.name);
    // 3、上传切片
    await uploadChunks(chunksList);
    // 4、所有切片上传成功后后,再告诉后端所有切片都已完成
    await mergeFileChunks(file.name);
    console.log("上传完成");
  }

  // 合并切片
  const mergeFileChunks = async function (filename) {
    await axios({
      method: "get",
      url: "/merge",
      params: {
        filename,
      },
    });
  };

  document
    .getElementById("uploadInput")
    .addEventListener("change", handleFileChange);
  document
    .getElementById("uploadBtn")
    .addEventListener("click", handleFileUpload);
</script>

2. 后端代码实现

因为后端不是我们主要关注点,所以直接上代码,就不做太过详细的解释了,有以下几点提起注意

  • 因为前端通过 Promise.all 的方式执行所有的请求,所以切片发送的顺序是随机的,也就是说,后端获取的切片并保存切片的顺序可能是随机的,所以切片文件的名称不一定是从小到大排序的,所以读取切片组成文件时,要先按照切片名称从小答案排序,然后再组合,否则文件可能出错,这在上传大文件的时候非常明显
const multiparty = require("multiparty");
const EventEmitter = require("events");
const express = require("express");
const cors = require("cors");
const fs = require("fs");
const path = require("path");
const { Buffer } = require("buffer");

const server = express();
server.use(cors());

const STATIC_TEMPORARY = path.resolve(__dirname, "static/temporary");
const STATIC_FILES = path.resolve(__dirname, "static/files");

server.post("/upload", (req, res) => {
  const multipart = new multiparty.Form();
  const myEmitter = new EventEmitter();

  const formData = {
    filename: undefined,
    hash: undefined,
    chunk: undefined,
  };

  let isFieldOk = false,
    isFileOk = false;

  multipart.parse(req, function (err, fields, files) {
    formData.filename = fields["filename"][0];
    formData.hash = fields["hash"][0];

    isFieldOk = true;
    myEmitter.emit("start");
  });

  multipart.on("file", function (name, file) {
    formData.chunk = file;
    isFileOk = true;
    myEmitter.emit("start");
  });

  myEmitter.on("start", function () {
    if (isFieldOk && isFileOk) {
      const { filename, hash, chunk } = formData;
      const dir = `${STATIC_TEMPORARY}/${filename}`;

      try {
        if (!fs.existsSync(dir)) fs.mkdirSync(dir);

        const buffer = fs.readFileSync(chunk.path);
        const ws = fs.createWriteStream(`${dir}/${hash}`);
        ws.write(buffer);
        ws.close();

        res.send(`${filename}-${hash} 切片上传成功`);
      } catch (error) {
        console.error(error);
      }

      isFieldOk = false;
      isFileOk = false;
    }
  });
});

server.get("/merge", async (req, res) => {
  const { filename } = req.query;

  try {
    let len = 0;
    const hash_arr = fs.readdirSync(`${STATIC_TEMPORARY}/${filename}`);
    // 将 hash 值按照大小进行排序
    hash_arr.sort((n1, n2) => {
      return Number(n1) - Number(n2);
    });
    const bufferList = hash_arr.map((hash) => {
      console.log(hash);
      const buffer = fs.readFileSync(`${STATIC_TEMPORARY}/${filename}/${hash}`);
      len += buffer.length;
      return buffer;
    });

    const buffer = Buffer.concat(bufferList, len);
    const ws = fs.createWriteStream(`${STATIC_FILES}/${filename}`);
    ws.write(buffer);
    ws.close();

    res.send(`切片合并完成`);
  } catch (error) {
    console.error(error);
  }
});

function deleteFolder(filepath) {
  if (fs.existsSync(filepath)) {
    fs.readdirSync(filepath).forEach((filename) => {
      const fp = `${filepath}/${filename}`;
      if (fs.statSync(fp).isDirectory()) deleteFolder(fp);
      else fs.unlinkSync(fp);
    });
    fs.rmdirSync(filepath);
  }
}

server.listen(3000, () => {
  console.log("Server is running at http://127.0.0.1:3000");
});

3. vue 改造

当然只需要改造前端代码,后端代码是不用修改的

新建单文件组件

<template>
  <div>
    <form action="">
      <input type="file" @change="handleFileChange($event)" />
      <button @click.prevent="handleFileUpload()">上传</button>
    </form>
  </div>
</template>
<script>
import axios from "axios";
axios.defaults.baseURL = "http://localhost:3000";
export default {
  data() {
    return {
      file: null,
    };
  },
  methods: {
    handleFileChange(event) {
      const file = event.target.files[0];
      if (!file) return;
      this.file = file;
    },
    // 1、创建切片
    createFileChunks(size = 1024 * 100) {
      // 创建数组,存储文件的所有切片
      let fileChunks = [];
      for (let cur = 0; cur < this.file.size; cur += size) {
        // file.slice 方法用于切割文件,从 cur 字节开始,切割到 cur+size 字节
        fileChunks.push(this.file.slice(cur, cur + size));
      }
      return fileChunks;
    },
    /**
     * 2、拼接 formData
     * 参数1:存储文件切片信息的数组
     * 参数2:上传时的文件名称
     */
    concatFormData(fileChunks, filename) {
      /**
       * map 方法会遍历切片数组 fileChunks中的元素map 方法会遍历切片数组 fileChunks中的元素,
       * 数组中有多少个切片,创建几个 formData,在其中上传的文件名称、hash值和切片,并将此 formData
       * 返回,最终chunksList中存储的就是多个 formData(每个切片对应一个 formData)
       *
       */
      const chunksList = fileChunks.map((chunk, index) => {
        let formData = new FormData();
        // 这个'filename' 字符串的名字要与后端约定好
        formData.append("filename", filename);
        // 作为区分每个切片的编号,后端会以此作为切片的文件名称,此名称也应该与后端约定好
        formData.append("hash", index);
        // 后端会以此作为切片文件的内容
        formData.append("chunk", chunk);
        return {
          formData,
        };
      });
      return chunksList;
    },
    // 3、上传切片
    async uploadChunks(chunksList) {
      const uploadList = chunksList.map(({ formData }) =>
        axios({
          method: "post",
          url: "/upload",
          data: formData,
        })
      );
      await Promise.all(uploadList);
    },

    // 大文件上传
    async handleFileUpload() {
      console.log(1);
      const file = this.file;
      if (!file) return;
      // 1、切片切割,第二个参数采用默认值
      const fileChunks = this.createFileChunks();
      // 2、将切片信息拼接成 formData 对象
      const chunksList = this.concatFormData(fileChunks, this.file.name);
      // 3、上传切片
      await this.uploadChunks(chunksList);
      // 4、所有切片上传成功后后,再告诉后端所有切片都已完成
      await this.mergeFileChunks(this.file.name);
      console.log("上传完成");
    },

    // 合并切片
    async mergeFileChunks(filename) {
      await axios({
        method: "get",
        url: "/merge",
        params: {
          filename,
        },
      });
    },
  },
};
</script>