一次性搞懂文件上传

118 阅读4分钟

准备工作

前端代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      input {
        display: block;
        margin: 10px 0;
      }
    </style>
  </head>

  <body>
    <input type="file" id="file" />
    <input type="button" id="upload" value="上传" />
    <input type="button" id="continue" value="继续上传" />
    <!-- 文件名hash用 -->
    <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
    <script src="./upload.js"></script>
  </body>
</html>

upload文件

const fileEle = document.querySelector("#file");
const uploadButton = document.querySelector("#upload");
uploadButton.addEventListener("click", async () => {
  let file = fileEle.files[0];
  console.log("🚀 ~ file:", file);
  uploadFile(file);
});
const uploadFile = (file) => {
  let fd = new FormData();
  fd.append("file", file);
  fetch("http://localhost:3000/upload", {
    method: "POST",
    body: fd,
  });
};

后端代码

    npm i express cors multer
const express = require("express");
const cors = require("cors");
const multer = require("multer");
const app = express();
const upload = multer({ dest: "uploads/" });

// 使用 cors 中间件
app.use(cors());
app.use(express.static("static"));
// 导入中间件
const bodyParser = require("body-parser");
// 使用中间件
app.use(bodyParser.urlencoded({ extended: false }));
// 处理JSON格式的数据
app.use(bodyParser.json());
const path = require("path");
const fse = require("fs-extra");
app.post("/upload", upload.single("file"), (req, res) => {
  console.log(req.file);
  res.send({
    msg: "上传成功",
    success: true,
  });
});

app.listen(3000, () => {
  console.log("服务已运行:http://localhost:3000");
});

1、 单文件上传的逻辑

选择完文件点上传后 前后端内容

前端

image.png

后端

image.png

此时我们后端文件所在的同级目录下已经有这个文件了,但是没有给相对应的后缀名,如果需要给文件加上后缀可参考下面内容

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, "uploads/"); // 保存到 uploads 目录
  },
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname); // 获取文件后缀
    const fileName = file.fieldname + "-" + Date.now() + ext;
    cb(null, fileName);
  },
});
const upload = multer({ storage: storage });

至此 我们的单文件上传已经完成了

2、分片上传

在单文件上传的背景下,当一个文件足够大的时候,直接进行单文件上传可能会对服务器造成很大的压力,这个时候,我们通常需要分片上传了

步骤

  • 1、前端根据文件大小对文件进行分片,同时记录文件名的hash值
  • 2、在每一个切片下保存文件内容和文件名确保后端可以进行合并
  • 3、因为浏览器最多支持六个请求,在分片的同时做一个请求池
  • 4、所有切片上传结束,告诉后端该把文件合并了

1、对文件进行分片同时记录文件名的hash值到每一个切片

//获取文件的hash值
const getHash = (file) => {
  return new Promise((resolve) => {
    const fileReader = new FileReader();
    fileReader.readAsArrayBuffer(file);
    fileReader.onload = function (e) {
      let fileMd5 = SparkMD5.ArrayBuffer.hash(e.target.result);
      resolve(fileMd5);
    };
  });
};
//分片
const createChunks = (file, fileHash) => {
  // 使用单独常量保存预设切片大小 1MB
  const chunkSize = 1024 * 1024 * 1;
  const chunks = [];
  let start = 0;
  let index = 0;
  while (start < file.size) {
    let curChunk = file.slice(start, start + chunkSize);
    chunks.push({
      file: curChunk,
      uploaded: false,
      chunkIndex: index,
      fileHash: fileHash,
    });
    index++;
    start += chunkSize;
  }
  return chunks;
};
//修改 click 事件内容

uploadButton.addEventListener("click", async () => {
  let file = fileEle.files[0];
  const fileHash = await getHash(file);
  const chunks = createChunks(file, fileHash);

  console.log("🚀 ~ chunks:", chunks);

  // uploadFile(file);
});

此时前端上传后

image.png

2、对得到的chunks进行上传

//首先对chunk进行一个处理
const uploadHandler = (chunk) => {
  return new Promise(async (resolve, reject) => {
    try {
      let fd = new FormData();
      fd.append("file", chunk.file);
      fd.append("fileHash", chunk.fileHash);
      fd.append("chunkIndex", chunk.chunkIndex);
      console.log("🚀 ~ fd:", fd);
      let result = await fetch("http://localhost:3000/upload", {
        method: "POST",
        body: fd,
      }).then((res) => res.json());
      chunk.uploaded = true;
      resolve(result);
    } catch (err) {
      reject(err);
    }
  });
};
const uploadChunks = (chunks, maxRequest = 6) => {
  return new Promise((resolve, reject) => {
    if (chunks.length == 0) {
      resolve([]);
    }
    let requestPoor = []; //请求池
    let start = 0;
    let index = 0;
    let requestReaults = [];
    while (start < chunks.length) {
      //每次添加maxRequest
      requestPoor.push(chunks.slice(start, start + maxRequest));
      start += maxRequest;
    }
    //定义请求函数
    async function request() {
      //当前请求超过requestPoor长度,则表示结束
      if (index > requestPoor.length - 1) {
        resolve(requestReaults);
        return;
      }
      let currentChunks = requestPoor[index];
      const result = [];
      for (const chunk of currentChunks) {
        result.push(uploadHandler(chunk));
      }
      const currentResult = await Promise.all(result);
      console.log("🚀 ~ currentResult:", currentResult);
      index++;
      request();
      //有需要这里做错误处理
    }
    request();
  });
};

修改我们的后端代码

app.post("/upload", upload.single("file"), (req, res) => {
  const { fileHash, chunkIndex } = req.body;
  console.log(fileHash, chunkIndex);
  // 切片上传的临时目录文件夹
  let tempFileDir = path.resolve("uploads", fileHash);
  // 如果当前文件的临时文件夹不存在,则创建该文件夹
  if (!fse.pathExistsSync(tempFileDir)) {
    fse.mkdirSync(tempFileDir);
  }
  // 目标切片位置
  const tempChunkPath = path.resolve(tempFileDir, chunkIndex);
  // 当前切片位置(multer默认保存的位置)
  let currentChunkPath = path.resolve(req.file.path);
  if (!fse.existsSync(tempChunkPath)) {
    fse.moveSync(currentChunkPath, tempChunkPath);
  } else {
    fse.removeSync(currentChunkPath);
  }
  res.send({
    msg: "上传成功",
    success: true,
  });
});

此时点击上传

image.png

image.png

同时我们的后端地址会出现这个

image.png

3、合并切片

前端在结束的时候告诉后端合并

// 合并分片请求
const mergeRequest = (fileHash, fileName) => {
  return fetch(
    `http://localhost:3000/merge?fileHash=${fileHash}&fileName=${fileName}`,
    {
      method: "GET",
    }
  ).then((res) => res.json());
};

uploadButton.addEventListener("click", async () => {
  let file = fileEle.files[0];
  const fileHash = await getHash(file);
  const chunks = createChunks(file, fileHash);
  console.log("🚀 ~ chunks:", chunks);
  await uploadChunks(chunks);
  await mergeRequest(fileHash, file.name);
});

后端代码

app.get("/merge", async (req, res) => {
  const { fileHash, fileName } = req.query;
  // 最终合并的文件路径
  const filePath = path.resolve(
    "uploads",
    fileHash + path.extname(fileName)
  );
  // 临时文件夹路径
  let tempFileDir = path.resolve("uploads", fileHash);
  // 读取临时文件夹,获取所有切片
  const chunkPaths = fse.readdirSync(tempFileDir);
  // 将切片追加到文件中
  let mergeTasks = [];
  for (let index = 0; index < chunkPaths.length; index++) {
    mergeTasks.push(
      new Promise((resolve) => {
        // 当前遍历的切片路径
        const chunkPath = path.resolve(tempFileDir, index + "");
        // 将当前遍历的切片切片追加到文件中
        fse.appendFileSync(filePath, fse.readFileSync(chunkPath));
        // 删除当前遍历的切片
        fse.unlinkSync(chunkPath);
        resolve();
      })
    );
  }
  await Promise.all(mergeTasks);
  // 等待所有切片追加到文件后,删除临时文件夹
  fse.removeSync(tempFileDir);
  res.send({
    msg: "合并成功",
    success: true,
  });
});

后续 待更