前端如何写一个大文件上传组件

1. 核心思路

  • 分片上传:将大文件分割成多个小文件(chunk),逐个上传。

  • 记录上传进度:通过本地存储(如 localStorage)或服务端记录已上传的分片。

  • 断点续传:上传中断后,重新上传时只上传未完成的分片。

  • 合并文件:所有分片上传完成后,通知服务端合并文件。


2. 实现步骤

1) 前端分片上传
  • 使用 File 对象的 slice 方法将文件分割成多个分片。

  • 通过 FormData 将分片上传到服务端。

2) 记录上传进度
  • 每个分片上传成功后,记录已上传的分片信息(如分片索引、文件唯一标识等)。

  • 可以使用 localStorage 或服务端存储记录上传进度。

3) 断点续传
  • 重新上传时,先检查已上传的分片,跳过已上传的部分。

  • 只上传未完成的分片。

4) 合并文件
  • 所有分片上传完成后,通知服务端合并文件。


3. 代码实现

前端代码(原生JS)
class FileUploader {
  constructor(file, chunkSize = 5 * 1024 * 1024) { // 默认分片大小为5MB
    this.file = file;
    this.chunkSize = chunkSize;
    this.totalChunks = Math.ceil(file.size / chunkSize); // 总分片数
    this.chunkIndex = 0; // 当前分片索引
    this.fileId = null; // 文件唯一标识
    this.uploadedChunks = new Set(); // 已上传的分片索引
  }

  // 生成文件唯一标识(可以用文件名+文件大小+最后修改时间)
  generateFileId() {
    return `${this.file.name}-${this.file.size}-${this.file.lastModified}`;
  }

  // 获取未上传的分片
  getUnuploadedChunks() {
    const unuploadedChunks = [];
    for (let i = 0; i < this.totalChunks; i++) {
      if (!this.uploadedChunks.has(i)) {
        unuploadedChunks.push(i);
      }
    }
    return unuploadedChunks;
  }

  // 上传分片
  async uploadChunk(chunkIndex) {
    const start = chunkIndex * this.chunkSize;
    const end = Math.min(start + this.chunkSize, this.file.size);
    const chunk = this.file.slice(start, end);

    const formData = new FormData();
    formData.append("file", chunk);
    formData.append("chunkIndex", chunkIndex);
    formData.append("totalChunks", this.totalChunks);
    formData.append("fileId", this.fileId);

    try {
      await fetch("/upload", {
        method: "POST",
        body: formData,
      });
      this.uploadedChunks.add(chunkIndex); // 记录已上传的分片
      this.saveProgress(); // 保存上传进度
    } catch (error) {
      console.error("上传失败:", error);
      throw error;
    }
  }

  // 保存上传进度到 localStorage
  saveProgress() {
    const progress = {
      fileId: this.fileId,
      uploadedChunks: Array.from(this.uploadedChunks),
    };
    localStorage.setItem(this.fileId, JSON.stringify(progress));
  }

  // 从 localStorage 加载上传进度
  loadProgress() {
    const progress = JSON.parse(localStorage.getItem(this.fileId));
    if (progress) {
      this.uploadedChunks = new Set(progress.uploadedChunks);
    }
  }

  // 开始上传
  async startUpload() {
    this.fileId = this.generateFileId();
    this.loadProgress(); // 加载上传进度

    const unuploadedChunks = this.getUnuploadedChunks();
    for (const chunkIndex of unuploadedChunks) {
      await this.uploadChunk(chunkIndex);
      console.log(`分片 ${chunkIndex} 上传完成`);
    }

    console.log("所有分片上传完成,通知服务端合并文件");
    await this.mergeFile();
  }

  // 通知服务端合并文件
  async mergeFile() {
    await fetch("/merge", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        fileId: this.fileId,
        fileName: this.file.name,
        totalChunks: this.totalChunks,
      }),
    });
    console.log("文件合并完成");
    localStorage.removeItem(this.fileId); // 清除上传进度
  }
}

// 使用示例
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener("change", async (e) => {
  const file = e.target.files[0];
  if (file) {
    const uploader = new FileUploader(file);
    await uploader.startUpload();
  }
});
前端代码(VUE)
<template>
  <div>
    <input type="file" @change="handleFileChange" />
    <button @click="startUpload" :disabled="!file || isUploading">
      {
   
   { isUploading ? '上传中...' : '开始上传' }}
    </button>
    <div v-if="progress > 0">
      上传进度: {
   
   { progress }}%
    </div>
  </div>
</template>

<script>
import { ref } from 'vue';

export default {
  name: 'FileUploader',
  setup() {
    const file = ref(null); // 选择的文件
    const isUploading = ref(false); // 是否正在上传
    const progress = ref(0); // 上传进度
    const chunkSize = 5 * 1024 * 1024; // 分片大小(5MB)
    const fileId = ref(''); // 文件唯一标识
    const uploadedChunks = ref(new Set()); // 已上传的分片索引

    // 生成文件唯一标识
    const generateFileId = (file) => {
      return `${file.name}-${file.size}-${file.lastModified}`;
    };

    // 获取未上传的分片
    const getUnuploadedChunks = (totalChunks) => {
      const unuploadedChunks = [];
      for (let i = 0; i < totalChunks; i++) {
        if (!uploadedChunks.value.has(i)) {
          unuploadedChunks.push(i);
        }
      }
      return unuploadedChunks;
    };

    // 上传分片
    const uploadChunk = async (chunkIndex, totalChunks) => {
      const start = chunkIndex * chunkSize;
      const end = Math.min(start + chunkSize, file.value.size);
      const chunk = file.value.slice(start, end);

      const formData = new FormData();
      formData.append('file', chunk);
      formData.append('chunkIndex', chunkIndex);
      formData.append('totalChunks', totalChunks);
      formData.append('fileId', fileId.value);

      try {
        await fetch('/upload', {
          method: 'POST',
          body: formData,
        });
        uploadedChunks.value.add(chunkIndex); // 记录已上传的分片
        saveProgress(); // 保存上传进度
      } catch (error) {
        console.error('上传失败:', error);
        throw error;
      }
    };

    // 保存上传进度到 localStorage
    const saveProgress = () => {
      const progressData = {
        fileId: fileId.value,
        uploadedChunks: Array.from(uploadedChunks.value),
      };
      localStorage.setItem(fileId.value, JSON.stringify(progressData));
    };

    // 从 localStorage 加载上传进度
    const loadProgress = () => {
      const progressData = JSON.parse(localStorage.getItem(fileId.value));
      if (progressData) {
        uploadedChunks.value = new Set(progressData.uploadedChunks);
      }
    };

    // 通知服务端合并文件
    const mergeFile = async () => {
      await fetch('/merge', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          fileId: fileId.value,
          fileName: file.value.name,
          totalChunks: Math.ceil(file.value.size / chunkSize),
        }),
      });
      console.log('文件合并完成');
      localStorage.removeItem(fileId.value); // 清除上传进度
    };

    // 开始上传
    const startUpload = async () => {
      if (!file.value) return;

      isUploading.value = true;
      fileId.value = generateFileId(file.value);
      loadProgress(); // 加载上传进度

      const totalChunks = Math.ceil(file.value.size / chunkSize);
      const unuploadedChunks = getUnuploadedChunks(totalChunks);

      for (const chunkIndex of unuploadedChunks) {
        await uploadChunk(chunkIndex, totalChunks);
        progress.value = Math.round((uploadedChunks.value.size / totalChunks) * 100);
        console.log(`分片 ${chunkIndex} 上传完成`);
      }

      console.log('所有分片上传完成,通知服务端合并文件');
      await mergeFile();
      isUploading.value = false;
      progress.value = 100;
    };

    // 选择文件
    const handleFileChange = (event) => {
      const selectedFile = event.target.files[0];
      if (selectedFile) {
        file.value = selectedFile;
        progress.value = 0;
        uploadedChunks.value = new Set();
      }
    };

    return {
      file,
      isUploading,
      progress,
      startUpload,
      handleFileChange,
    };
  },
};
</script>

<style scoped>
/* 样式可以根据需要自定义 */
div {
  margin: 20px;
}
button {
  margin-top: 10px;
}
</style>
服务器端代码
const express = require('express');
const fs = require('fs');
const path = require('path');
const multer = require('multer');

const app = express();
const upload = multer({ dest: 'uploads/' }); // 分片存储目录

// 上传分片
app.post('/upload', upload.single('file'), (req, res) => {
  const { chunkIndex, fileId } = req.body;
  const chunkPath = path.join('uploads', `${fileId}-${chunkIndex}`);

  // 将分片移动到指定位置
  fs.renameSync(req.file.path, chunkPath);
  res.send('分片上传成功');
});

// 合并文件
app.post('/merge', express.json(), (req, res) => {
  const { fileId, fileName, totalChunks } = req.body;
  const filePath = path.join('uploads', fileName);

  // 创建可写流
  const writeStream = fs.createWriteStream(filePath);

  // 依次读取分片并写入文件
  for (let i = 0; i < totalChunks; i++) {
    const chunkPath = path.join('uploads', `${fileId}-${i}`);
    const chunk = fs.readFileSync(chunkPath);
    writeStream.write(chunk);
    fs.unlinkSync(chunkPath); // 删除分片
  }

  writeStream.end();
  res.send('文件合并完成');
});

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