Skip to content

大文件分片上传

在网络环境不稳定或上传过程中出现错误的情况下,大文件上传失败需要重新上传一整份大文件,而文件分片则可以在上传失败时只重新上传受影响的部分。

通过前端将大文件拆分成小块,然后逐个上传到后端服务器,再由后端服务器将这些小块组合成完整的文件。

文件分片上传

测试:

总大小: 0

已上传: 0

进度: 0%

前端代码:

使用 XHR
vue
<template>
  总大小: <span id="total">0</span>

  已上传: <span id="loaded">0</span>

  进度: <span id="progress">0</span>%
  <input type="file" id="file" />
  <el-button type="primary" @click="uploadFile">开始上传</el-button>
</template>

<script setup>
import { ElButton } from "element-plus";
import "element-plus/es/components/button/style/css";

const uploadFile = async () => {
  const fileInput = document.querySelector("#file");
  const file = fileInput.files[0];
  const chunkSize = 1024 * 1024;
  let start = 0;

  const uploadChunk = async () => {
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);
    const formData = new FormData();
    formData.append("file", chunk);

    const xhr = new XMLHttpRequest();
    xhr.open("POST", "http://localhost:3000/chunkupload", true);

    const uploadPromise = new Promise((resolve, reject) => {
      xhr.onload = function () {
        resolve();
      };

      xhr.onerror = function () {
        reject(new Error("文件上传过程中发生错误。"));
      };
    });

    xhr.send(formData);

    // 等待上传完成再进行下一个块的上传
    await uploadPromise;

    start = end;

    // 继续上传下一个块
    if (start < file.size) {
      await uploadChunk();
    } else {
      console.log("文件上传完成!");
    }
  };

  try {
    await uploadChunk();
  } catch (error) {
    console.error(error.message);
  }
};
</script>
vue
<template>
  总大小: <span id="total">0</span>

  已上传: <span id="loaded">0</span>

  进度: <span id="progress">0</span>%
  <input type="file" id="file" />
  <el-button type="primary" @click="uploadFile">开始上传</el-button>
</template>

<script setup>
import { ElButton } from 'element-plus'
import 'element-plus/es/components/button/style/css'

const uploadFile = async () => {
  const fileInput = document.querySelector("#file");
  const loaded = document.querySelector("#loaded");
  const progress = document.querySelector("#progress");
  const file = fileInput.files[0];
  const chunkSize = 1024 * 1024;

  let start = 0;
  document.querySelector("#total").innerHTML = file.size

  while (start < file.size) {
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);
    const formData = new FormData();
    formData.append("file", chunk)
    formData.append('totalCount', file.size)
    formData.append('currentCount', end)
    formData.append('fileName', file.name)

    try {
      const response = await fetch("http://localhost:3000/chunkupload", {
        method: "POST",
        body: formData,
      });

      const { percent, currentCount } = await response.json()

      progress.innerHTML = percent
      loaded.innerHTML = end

      if (!response.ok) {
        throw new Error("文件上传失败。");
      }

      start = end;
    } catch (error) {
      console.error(error.message);
      break; // 停止上传并处理错误
    }
  }
  if(file.size===start){
    console.log("文件上传完成!");
  }
};
</script>

后端代码:

js

import { createServer } from "http";
import { ChunkUpload } from "./chunkUpload.js";

const server = createServer((req, res) => {
  const { method, url } = req;
  if (method === "OPTIONS") {
    res.writeHead(200, {
      "Content-Type": "text/plain",
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": 'GET, POST, PUT, DELETE, OPTIONS'
    });

    res.end();
  }
  if (url === "/" && method === "GET") {
    res.writeHead(200, {
      "content-type": "application/json",
      // 处理浏览器跨域
      "Access-Control-Allow-Origin": "*",
    });

    res.end(
      JSON.stringify({
        message: "GET AJAX",
      })
    );
  } else if (url === "/chunkupload" && method === "POST") {
    ChunkUpload(req, res);
  }
});

server.listen(3000, "localhost", () => {
  console.log("服务已启动: http://localhost:3000");
});
js
import { createWriteStream,createReadStream, existsSync, mkdirSync } from "fs";
import formidable from "formidable";

const ChunkUpload = (req, res) => {
  const form = formidable();

  if (!existsSync('./uploads')) {
    mkdirSync('./uploads');
  }

  form.parse(req, (err, fields, files) => {
    if (err) {
      sendResponse(res, 500, { message: "上传失败" });
      return;
    }

    const { totalCount, currentCount, fileName } = fields;
    const filePath = `./uploads/${fileName[0]}`;
    const writeStream = createWriteStream(filePath, { flags: "a" });

    const readStream = createReadStream(files.file[0].filepath);
    readStream.pipe(writeStream);

    readStream.on('error', (err) => {
      sendResponse(res, 500, { message: "上传失败" });
    });

    readStream.on('end', () => {
      if (+currentCount[0] === +totalCount[0]) {
        sendResponse(res, 200, { message: "文件上传完成", percent: 100 });
      } else {
        sendResponse(res, 200, {
          message: "已成功接收分片",
          percent: Math.floor((+currentCount[0] / +totalCount[0]) * 100),
          currentCount: currentCount[0],
        });
      }
    });
  });
};

const sendResponse = (res, statusCode, data) => {
  res.writeHead(statusCode, {
    "content-type": "application/json",
    "Access-Control-Allow-Origin": "*",
  });
  res.end(JSON.stringify(data));
};

export { ChunkUpload };
json
{
  "name": "study_backend",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "d": "nodemon index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "formidable": "^3.5.0",
    "nodemon": "^3.0.1"
  }
}