​ 针对大文件上传的业务场景,前后端采用切片上传的方案,即前端将大文件分割为固定大小的chunk,并循环请求给后端;后端每获取一部分,就写入到服务器指定文件中,最终实现大文件上传。

GSemir0418/file-slice-upload: 大文件切片上传方案,前端vite+js,后端express (github.com)

1 客户端

1.1 初始化

npm init -y

yarn add vite -D

1.2 项目结构

1
2
3
4
5
6
7
8
.
├── index.html		项目首页
├── node_modules
├── package.json	
├── src
│   ├── app.js		项目入口文件,主要方法都写到这里
│   └── config.js	字段映射等配置
└── yarn.lock

1.3 dom结构

<input type='file' />在Edge中点击后不会弹出文件资源管理器,至今未解决,暂时用Chrome替代

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<div>
  <p>
    <progress id="uploadProgress" value="0"></progress>
  </p>
  <p>
    <input type="file" id="videoUploader" label="选择视频" />
  </p>
  <p>
    <span id="uploadInfo"></span>
  </p>
  <p>
    <button id="uploadBtn">上传视频</button>
  </p>
</div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="./src/app.js" type="module"></script>

1.4 整体代码行文结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import { INFO, ALLOWED_TYPE, CHUNK_SIZE, API } from "./config";
// 立即执行函数,可以将docment参数注入到内部函数中形成闭包,以达到简写的效果
((d) => {
  // 获取dom元素
  const oUploader = d.querySelector("#videoUploader");
  ...
  
  // 记录当前上传大小
  let uploadedSize = 0;

  // 绑定事件函数
  function bindEvent() {
    oBtn.addEventListener("click", upload);
  }

  // 上传事件函数
  async function upload() {
    // 解构取出上传的文件及信息
    // 上传文件判空等前端校验
    // 校验通过,开始上传
    // 上传文件名处理(MD5)
    // 利用while循环切片并发送请求
    while (uploadedSize < size) {
      const fileChunk = file.slice(uploadedSize, uploadedSize + CHUNK_SIZE);
      // 构造请求参数
      const formData = createFormData({
        name,
        fileName,
        type,
        size,
        uploadedSize,
        chunk: fileChunk,
      });
      // axios发送请求
      try {
        response = await axios.post(API, formData);
      } catch (error) {
        oInfo.innerHTML = INFO["UPLOAD_FAILED"] + error.message;
        return;
      }
      // chunk上传结束后,更新已上传的size,并同步进度条
      uploadedSize += fileChunk.size;
      oProgress.value = uploadedSize;
    }
    // 全部chunk上传结束后,显示成功,清空数据,展示video元素
  }

  // 构造请求参数的函数
  function createFormData({ name, fileName, type, size, uploadedSize, chunk }) {
    const postData = new FormData();
    postData.append("name", name);
    ...
    return postData;
  }

  // 构造video元素的函数
  function createVideoElement(url) {
    const oVideo = document.createElement("video");
    oVideo.src = url;
    ...
    document.body.appendChild(oVideo);
  }

  function init() {
    bindEvent();
  }
  init();
})(document);

2 Express服务端

2.1 初始化

npm init

yarn add express express-fileupload

yarn global add nodemon

2.2 项目结构

1
2
3
4
5
6
7
.
├── app.js
├── node_modules
├── package.json
├── upload_tem
│   └── 1656466982424_1.mp4.mp4
└── yarn.lock

2.3 代码行文结构

主要熟悉一下express常见中间件及node常用方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
const express = require("express");
// 请求体数据处理中间件
const bodyParser = require("body-parser");
const uploader = require("express-fileupload");
// extname是获取文件后缀名的
const { extname, resolve } = require("path");
// existsSync检查文件是否存在; appendFileSync同步添加数据
const { existsSync, appendFileSync, writeFileSync } = require("fs");

const app = express();

// 解析并返回的请求体对象配置为任意类型
app.use(bodyParser.urlencoded({ extended: true }));
// 解析json格式的请求体
app.use(bodyParser.json());
// 请求体中上传数据的处理,返回的数据在req.files中
app.use(uploader());
// 指定静态文件url
app.use("/", express.static("upload_tem"));

// 跨域处理
app.all("*", (_, res, next) => {
  res.header("Access-Control-Allow-origin", "*");
  res.header("Access-Control-Allow-methods", "POST,GET");
  next();
});

// 处理上传响应
app.post("/upload_video", (req, res, next) => {
  const { name, fileName, uploadedSize } = req.body;
  const { chunk } = req.files;
  if (!chunk) {
    res.send({
      code: 1001,
      msg: "No file Fond",
    });
    return;
  }
  // 处理fileName 添加后缀名
  const filename = fileName + extname(name);
  // 声明文件保存路径
  const filePath = resolve(__dirname, "./upload_tem/" + filename);

  // 根据uploadedSize判断新建或追加数据文件
  if (uploadedSize !== "0") {
    // 注意是字符串0
    if (!existsSync(filePath)) {
      res.send({
        code: 1002,
        msg: "No file exists",
      });
      return;
    }
    // append数据到文件,结束本次上传
    appendFileSync(filePath, chunk.data);
    res.send({
      code: 200,
      msg: "chunk appended",
      // 将服务器静态数据文件路径发送给前端
      video_url: "http://localhost:8000/" + filename,
    });
    return;
  }
  // 如果uploadedSize为0,表示没有正在上传的数据
  // 则创建并写入这个文件
  writeFileSync(filePath, chunk.data);
  res.send({ code: 200, msg: "file created" });
});

// 开启服务器 监听8000端口
app.listen(8000, () => {
  console.log("Server is running");
});