react通关系列之——学妹问我大文件上传

274 阅读9分钟

前言: 近日,正是周末,我在迪士尼玩耍着,吹着刺骨的阴风正黯然神伤,突然,手机一震,来了一条信息,我预感好事来临,打开一看果然是美事,学妹给我发了条信息,怎料我正要开心一下,学妹立马又说道:“在忙吗?学长,我有个问题请教你!”哎,果然,没事学妹是不会找我的,虽然有些许失望,但身为她正直而又热心的学长,我又如何能弃她于不顾?

微信图片_20250310204415.jpg

1.react+ts+promise实现大文件上传

很明显学妹她很急,但正如我所说,学妹你先别急!越是急的事越要慢慢来,越要求稳!

2.问题分析

好的,先让我们把学妹用力地抛向一边,女人只会影响我们思考的速度,我们来分析大文件上传。考虑文件可能很大,所以我们要实现分片上传,考虑到上传速度所以我们要实现并发上传,考虑到可扩展所以我们选择用类来实现,考虑到功能要丰富,所以我们要实现暂停,继续,增加重试机制增加容错,增加断点续传,最后,考虑到代码要写得优雅,利于维护,所以,我们选择用ts…………

所以总结,一个好的文件上传要包含以下内容:

1.分片上传 2.并发上传 3.用类来实现 4.实现暂停,继续,重试机制 5.断点续传 6.使用ts

那么,内容是分析完了,该如何实现一个这样的类来上传文件呢?这样一个类该如何来设计?有哪些参数?又有哪些方法?直接这样想,会有点懵逼,也可能会遗漏某些东西,所以我们还是使用一套方法论来解决这些问题。

天道这部剧不知道大家看过没有,里面的神人丁元英做事情喜欢以果导因,从结果反推过程实现,这确实是一个很妙的方法,今天就让我们以丁元英的视角来把这个上传文件类给他写出来。

3.以果导因

什么是以果导因,放在前端来看,就是从函数使用来倒推函数构成,首先我们希望这样就可以直接上传文件:

   // 我们希望这样来使用
    const uploader = new FileUploader(
            file,//文件
            1024 * 1024, // 文件切片
            3, // 并发数为 3
            'https://example.com/upload', // 上传接口地址
        );

于是乎就有了这几个参数,相信看文字各位就知道什么意思

interface IFileUploader{
    file:File;//file 文件类型
    chunkSize:number;//文件切片大小
    concurrency:number;//并发数
    url:string//请求地址
}

我们希望可以暂停,继续,也就是这样来使用:

uploader.pause()//暂停
uploader.resume()//继续

所以可以推出FileUploader至少拥有这两个方法和这些属性

class FileUploader{
    private file: File;
    private chunkSize: number;
    private concurrency: number;//并发数
    private paused: boolean = false;//用来控制上传暂停
    private uploadUrl: string;
    //继续
     public resume(){
       ....//do something
     }
     //暂停
     public pause(){
       ...//do something
     }
}

我们希望能做到分片和并发控制,和断点续传所以还应该有这三个方法

class FileUploader{
    private file: File;
    private chunkSize: number;
    private concurrency: number;//并发数
    private paused: boolean = false;//用来控制上传暂停
    private uploadUrl: string;
    
    //处理分片
    private splitFileChunks(){
       ...//分片逻辑
    }
    
    //并发控制逻辑
    private concurrencyControl(){
     ...//处理并发
    }
    
    //断点续传控制
    private resumableUploadControl(){
    
     ...//处理断点续传
    }
    
    //继续
     public resume(){
       ....//do something
     }
     //暂停
     public pause(){
       ...//do something
     }
}

还遗漏了些什么属性?这个确实要仔细思考一下,我们一点一点来,首先,是分片,我们希望分片是怎样的?一般来说,后端拿到分片如何拼成一个文件?总不能随意就拼成了吧?所以,chunk必定有一个属性是index,代表是第几个分片,后端要用它的顺序来拼文件的。

还有一个hash值也肯定要,为什么?要想做到断点续传,那前后端就有一个校验的过程,校验哪些分片已经上传了,如果已经上传了就直接跳过,不用上传,直接去上传上次还没传的,那不就做到了断点续传了吗?如何校验已经上传?那肯定是对比了嘛,如何对比?那肯定是前端分片和后端分片一行数据一行数据的对比,可能吗?不可能,所以引入hash的概念,就是把当前文件分片用MD5技术转换成一段简单字符串,用来做对比。

还有一个attempt属性,代表分片的失败重试次数,超过我们指定的重试次数,那就算上传失败了

还有一个abortController,代表我们我们每个分片都是可以暂停的,一点暂停键,所有正在上传的分片都要停下来

还有一个status,代表分片上传的状态,暂定四种,pending,completed,failed,uploading,代表等待上传,成功,失败,上传中,为什么要定义这些状态?便于我们维护任务队列,具体可以看后面

至此,每一个分片的属性已经确定了

// 定义上传任务的状态
enum UploadTaskStatus {
    PENDING = 'pending',
    UPLOADING = 'uploading',
    COMPLETED = 'completed',
    FAILED = 'failed'
}
//chunk 的属性
interface UploadTask {
    chunk: Blob;//分片内容
    index: number;
    status: UploadTaskStatus;
    attempt: number;
    abortController?: AbortController;//控制器,代表是否暂停
    chunkHash?: string;
}

至此,类的属性和方法差不多都已经确定,让我们开始正式编码

4.逐个击破

正当我沉醉在编码过程中的时候,学妹已经等不及了

微信图片_20250312231521.jpg

我勒个去!竟然这么直截了当的说学长不行,看来得找机会让她狠狠知道我的厉害!!!不过当编程的心一起来,女人就不重要了,让我们把学妹再用力地抛向一边,开始编程。

重点1-函数编码-文件切片

文件切片原理很简单,就是用file的slice方法不断切割就行了,只是切的时候,我们用还需要用sparkmd5计算这个分片的hash值,sparkmd5常用来做前端hash值的生成,这个大家有个了解就行,看代码

  // 将文件分割成多个块并计算每个分片的哈希
  private async splitFileIntoChunks() {
    let start = 0;
    let index = 0;
    while (start < this.file.size) {
      const end = Math.min(start + this.chunkSize, this.file.size);
      const chunk = this.file.slice(start, end);
      const task: UploadTask = {
        chunk,
        index,
        status: UploadTaskStatus.PENDING,
        attempt: 0,
      };
      try {
        const hash = await this.calculateChunkHash(chunk);
        task.chunkHash = hash;
        this.taskMap[UploadTaskStatus.PENDING].push(task);
      } catch (e) {
        console.error(e);
      }

      start = end;
      index++;
    }
  }

代码很好理解,初始化start=0,然后不断用chunkSize去跳着切割file,chunkSize就是我们规定的切片大小,一般情况是2M-5M,也就是1024 * 1024 * 2----5 *1024 *1024之间。注意有个函数 calculateChunkHash(chunk),这个函数用来计算分片的hash值,让我们完善它


  // 计算单个分片的哈希
  private async calculateChunkHash(chunk: Blob): Promise<string> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      const spark = new SparkMD5.ArrayBuffer();
      reader.readAsArrayBuffer(chunk);
      reader.onload = (e) => {
        const arrayBuffer = e.target?.result as ArrayBuffer;
        spark.append(arrayBuffer);
        const hash = spark.end();
        resolve(hash);
      };
      reader.onerror = () => {
        reject(new Error("Failed to read chunk for hash calculation"));
      };
    });
  }

这个函数也好理解,sparkMd5直接调用 new SparkMD5.ArrayBuffer()会生成一个二进制加密对象,提供append方法追加数据,end方法生成得到hash

sparkMd5还可以这样用 SparkMD5.hash("aaa"),直接处理字符串。因为fileReader是将file,也就是blob对象读取为二进制格式,所以要用ArrayBuffer来处理

重点2-函数编码-文件上传

文件上传其实涉及到两个部分,一个是上传,一个是并发控制我们先来说说上传,上传文件用的是formData,把我们的分片数据加入到formData中上传就行了,需要重点注意两个地方,一个是上传成功那并发池就多了一个位置,所以要立刻填满并发池,第二个是上传失败要进行重试

 // 上传单个块
  private async uploadChunk(task: UploadTask): Promise<void> {
    task.attempt++;//尝试次数加一,上传报错这个值会累加
    const abortController = new AbortController(); // 用来控制上传是否暂停
    task.abortController = abortController;
    this.moveTaskToStatus(task, UploadTaskStatus.UPLOADING);// 这个方法后面会详细说明,就是起到一个移动任务的作用
    try {
      const formData = new FormData();//用formData上传数据
      formData.append("chunk", task.chunk);
      formData.append("index", task.index.toString());
      formData.append("total", this.totalTaskCount.toString());
      formData.append("fileHash", this.fileHash);
      if (task.chunkHash) {
        formData.append("chunkHash", task.chunkHash);
      }
      const response = await fetch(this.uploadUrl + "/upload", {
        method: "POST",
        body: formData,
        signal: abortController.signal,
      });
      if (!response.ok) {
        throw new Error(`Upload failed with status ${response.status}`);
      }
      this.moveTaskToStatus(task, UploadTaskStatus.COMPLETED);
      this.fillConcurrency();// 上传成功,立刻启动并发池上传新任务
    } catch (error) {
      if (task.attempt < 3 && task.status === UploadTaskStatus.UPLOADING) {
      // 上传失败就重试
        this.moveTaskToStatus(task, UploadTaskStatus.PENDING);
        this.fillConcurrency();
      } else {
        if (!this.paused) {
          // 停止所有上传任务
          this.taskMap[UploadTaskStatus.UPLOADING].forEach((el) => {
            el.abortController?.abort();
          });
          this.moveTaskToStatus(task, UploadTaskStatus.FAILED);
        }
        this.onError(error as Error);
      }
    }
  }

上面代码出现了几个东西需要讲一下是什么,首先是taskMap,它的完整结构是这样的

taskMap = {
    pending: [],
    complete: [],
    uploading: [],
    failed: [],  
}

它其实就是个对象,维护着我们所有的任务,pending里面是等待上传的分片,complete是上传完成的分片,uploading是正在上传的分片,我之所以用map结构就是为了好理解我们整个上传过程,就是这四种任务的切换

第二是moveTaskToStatus函数,其实就是移动任务,比如分片上传成功了,那就从uploading里面移除,加入complete,这样懂了吗?就是起到一个移动作用,实现如下

 // 移动任务到指定状态
  private moveTaskToStatus(task: UploadTask, newStatus: UploadTaskStatus) {
    const oldStatus = task.status;
    task.status = newStatus;
    this.taskMap[oldStatus] = this.taskMap[oldStatus].filter(
      (t) => t.index !== task.index
    );
    this.taskMap[newStatus].unshift(task);
  }
重点3-函数编码-并发池

并发池要处理的其实就是判断当前上传任务队列有空位置吗?有的话,从pending里面拿出来,放到uploading里面上传不就行了吗?对吧,看看如何实现的

 // 填充并发任务
  private fillConcurrency() {
    if (this.paused) return;
    while (
      this.taskMap[UploadTaskStatus.UPLOADING].length < this.concurrency &&
      this.taskMap[UploadTaskStatus.PENDING].length > 0
    ) {
      const pendingTask = this.taskMap[UploadTaskStatus.PENDING].shift();
      if (pendingTask) {
        this.uploadChunk(pendingTask);
      } else {
        break;
      }
    }
  }
重点4-函数编码-断点续传

断点续传如何实现呢?我们想一想,它的原理无非就是判断当前分片后端是否有了,如果有了那就不用上传了,对吧,其实就是调用一个后端check接口,将当前的文件hash传到后端,获取已经上传的分片,然后前端打个标记就行了

这里为什么要用hash值来判断文件的切片上传情况呢?有人就说了,文件名也可以啊!确实可以,不过用hash更好一点,能更精准的代表当前文件

看实现分为两步 第一步:求出整个文件的hash值

  // 计算整个文件的哈希
  private async calculateFileHash(): Promise<string> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      const spark = new SparkMD5.ArrayBuffer();
      reader.readAsArrayBuffer(this.file);
      reader.onload = (e) => {
        const arrayBuffer = e.target?.result as ArrayBuffer;
        spark.append(arrayBuffer);
        const hash = spark.end();
        resolve(hash);
      };
      reader.onerror = () => {
        reject(new Error("Failed to read file for hash calculation"));
      };
    });
  }

第二步:调用接口,获取已经上传的分片,并将已经上传的分片移动到taskMap的complete中去,跳过上传

 // 检查上传状态,获取已上传的分片
  private async checkUploadStatus(): Promise<void> {
    try {
      const { uploadedChunks } = await checkUploadStatus(this.fileHash);
      if (!uploadedChunks?.length) return;
      this.taskMap[UploadTaskStatus.PENDING] = this.taskMap[
        UploadTaskStatus.PENDING
      ].filter((task) => {
        if (uploadedChunks.includes(task.index)) {
          this.completedTaskCount++;
          this.moveTaskToStatus(task, UploadTaskStatus.COMPLETED);
          return false;
        }
        return true;
      });
    } catch (error) {
      throw error;
    }
  }
重点5-函数编码-暂停,继续逻辑补充

暂停的话,我们直接调用abort方法就可以,将uploading里面的任务全部暂停掉

具体实现如下:

public pause() {
    this.paused = true;
    this.taskMap[UploadTaskStatus.UPLOADING].forEach((task) => {
      task.abortController?.abort();
      this.moveTaskToStatus(task, UploadTaskStatus.PENDING);
    });
  }

重试的话,更简单,直接启动并发池,开始任务就行

 // 继续上传
  public resume() {
    this.paused = false;
    this.fillConcurrency();
  }

至此我们所有重要的函数都写完了,但还剩一个最重要的逻辑,看后面继续讲解

重点6-函数编码-start方法

start方法就是开始上传方法,启动这个方法要做好几件事情

第一计算整个文件hash

第二文件切片

第三去判断当前文件有没有上传好的分片

第四启动并发池

具体实现如下

 // 开始上传
  public async start() {
    this.paused = false;
    this.taskMap = {
      [UploadTaskStatus.PENDING]: [],
      [UploadTaskStatus.UPLOADING]: [],
      [UploadTaskStatus.COMPLETED]: [],
      [UploadTaskStatus.PAUSED]: [],
      [UploadTaskStatus.FAILED]: [],
    };

    try {
      this.fileHash = await this.calculateFileHash();

      await this.splitFileIntoChunks();

      await this.checkUploadStatus();

      this.fillConcurrency();
    } catch (e) {
      console.log(e);
    }
  }

至此所有前端工作已经完成

5.完整代码

import SparkMD5 from "spark-md5";

// 定义上传任务的状态
enum UploadTaskStatus {
  PENDING = "pending",
  UPLOADING = "uploading",
  COMPLETED = "completed",
  PAUSED = "paused",
  FAILED = "failed",
}

// 定义上传任务的接口
interface UploadTask {
  chunk: Blob;
  index: number;
  status: UploadTaskStatus;
  attempt: number;
  abortController?: AbortController;
  chunkHash?: string;
}

// 定义上传器类
class FileUploader {
  private file: File;
  private chunkSize: number;
  private concurrency: number;
  private taskMap: { [status: string]: UploadTask[] } = {
    [UploadTaskStatus.PENDING]: [],
    [UploadTaskStatus.UPLOADING]: [],
    [UploadTaskStatus.COMPLETED]: [],
    [UploadTaskStatus.PAUSED]: [],
    [UploadTaskStatus.FAILED]: [],
  };
  private paused: boolean = false;
  private onProgress: ((progress: number) => void) | undefined;
  private onComplete: (() => void) | undefined;
  private onError: ((error: Error) => void) | undefined;
  private uploadUrl: string;
  private fileHash: string;
  private completedTaskCount: number = 0; // 记录已完成的任务数量
  private totalTaskCount: number = 0; // 记录总任务数量
  private networkMonitorInterval: number | null = null;

  constructor(
    file: File,
    chunkSize: number,
    initialConcurrency: number,
    uploadUrl: string,
    onProgress?: (progress: number) => void,
    onComplete?: () => void,
    onError?: (error: Error) => void
  ) {
    this.file = file;
    this.chunkSize = chunkSize;
    this.concurrency = initialConcurrency;
    this.onProgress = onProgress;
    this.onComplete = onComplete;
    this.onError = onError;
    this.uploadUrl = uploadUrl;
  }

  // 计算整个文件的哈希
  private async calculateFileHash(): Promise<string> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      const spark = new SparkMD5.ArrayBuffer();
      reader.readAsArrayBuffer(this.file);
      reader.onload = (e) => {
        const arrayBuffer = e.target?.result as ArrayBuffer;
        spark.append(arrayBuffer);
        const hash = spark.end();
        resolve(hash);
      };
      reader.onerror = () => {
        reject(new Error("Failed to read file for hash calculation"));
      };
    });
  }

  // 将文件分割成多个块并计算每个分片的哈希
  private async splitFileIntoChunks() {
    let start = 0;
    let index = 0;
    while (start < this.file.size) {
      const end = Math.min(start + this.chunkSize, this.file.size);
      const chunk = this.file.slice(start, end);
      const task: UploadTask = {
        chunk,
        index,
        status: UploadTaskStatus.PENDING,
        attempt: 0,
      };
      try {
        const hash = await this.calculateChunkHash(chunk);
        task.chunkHash = hash;
        this.taskMap[UploadTaskStatus.PENDING].push(task);
        this.totalTaskCount++;
        if (
          this.taskMap[UploadTaskStatus.PENDING].length ===
          Math.ceil(this.file.size / this.chunkSize)
        ) {
          // 分割完成后可以进行下一步操作
        }
      } catch (e) {
        console.error(e);
        // this.onError(e);
      }

      start = end;
      index++;
    }
  }

  // 计算单个分片的哈希
  private async calculateChunkHash(chunk: Blob): Promise<string> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      const spark = new SparkMD5.ArrayBuffer();
      reader.readAsArrayBuffer(chunk);
      reader.onload = (e) => {
        const arrayBuffer = e.target?.result as ArrayBuffer;
        spark.append(arrayBuffer);
        const hash = spark.end();
        resolve(hash);
      };
      reader.onerror = () => {
        reject(new Error("Failed to read chunk for hash calculation"));
      };
    });
  }

  // 检查上传状态,获取已上传的分片
  private async checkUploadStatus(): Promise<void> {
    try {
      const res = await fetch(
        this.uploadUrl + `/upload/check?fileHash=${this.fileHash}`
      );
      const { uploadedChunks } = await res.json();
      if (!uploadedChunks?.length) return;
      this.taskMap[UploadTaskStatus.PENDING] = this.taskMap[
        UploadTaskStatus.PENDING
      ].filter((task) => {
        if (uploadedChunks.includes(task.index)) {
          this.completedTaskCount++;
          this.moveTaskToStatus(task, UploadTaskStatus.COMPLETED);
          return false;
        }
        return true;
      });
    } catch (error) {
      throw error;
    }
  }

  // 上传单个块
  private async uploadChunk(task: UploadTask): Promise<void> {
    task.attempt++;
    const abortController = new AbortController();
    task.abortController = abortController;
    this.moveTaskToStatus(task, UploadTaskStatus.UPLOADING);
    try {
      const formData = new FormData();
      formData.append("chunk", task.chunk);
      formData.append("index", task.index.toString());
      formData.append("total", this.totalTaskCount.toString());
      formData.append("fileHash", this.fileHash);
      if (task.chunkHash) {
        formData.append("chunkHash", task.chunkHash);
      }
      const response = await fetch(this.uploadUrl + "/upload", {
        method: "POST",
        body: formData,
        signal: abortController.signal,
      });
      if (!response.ok) {
        throw new Error(`Upload failed with status ${response.status}`);
      }
      this.completedTaskCount++;
      this.updateProgress();
      this.moveTaskToStatus(task, UploadTaskStatus.COMPLETED);
      this.fillConcurrency();
    } catch (error) {
      if (task.attempt < 3 && task.status === UploadTaskStatus.UPLOADING) {
        this.moveTaskToStatus(task, UploadTaskStatus.PENDING);
        this.fillConcurrency();
      } else {
        if (!this.paused) {
          // 停止所有上传任务
          this.taskMap[UploadTaskStatus.UPLOADING].forEach((el) => {
            el.abortController?.abort();
          });
          this.moveTaskToStatus(task, UploadTaskStatus.FAILED);
        }
      }
    }
  }

  // 更新上传进度
  private updateProgress() {
    const progress = (this.completedTaskCount / this.totalTaskCount) * 100;
    // this.onProgress(progress);
    if (this.completedTaskCount === this.totalTaskCount) {
      //   this.onComplete();
      this.stopNetworkMonitoring();
    }
  }

  // 填充并发任务
  private fillConcurrency() {
    if (this.paused) return;
    while (
      this.taskMap[UploadTaskStatus.UPLOADING].length < this.concurrency &&
      this.taskMap[UploadTaskStatus.PENDING].length > 0
    ) {
      const pendingTask = this.taskMap[UploadTaskStatus.PENDING].shift();
      if (pendingTask) {
        this.uploadChunk(pendingTask);
      } else {
        break;
      }
    }
  }

  // 开始上传
  public async start() {
    this.paused = false;
    this.taskMap = {
      [UploadTaskStatus.PENDING]: [],
      [UploadTaskStatus.UPLOADING]: [],
      [UploadTaskStatus.COMPLETED]: [],
      [UploadTaskStatus.PAUSED]: [],
      [UploadTaskStatus.FAILED]: [],
    };

    try {
      this.fileHash = await this.calculateFileHash();

      await this.splitFileIntoChunks();

      await this.checkUploadStatus();

      this.fillConcurrency();
    } catch (e) {
      console.log(e);
    }
  }

  // 暂停上传
  public pause() {
    this.paused = true;
    this.taskMap[UploadTaskStatus.UPLOADING].forEach((task) => {
      task.abortController?.abort();
      this.moveTaskToStatus(task, UploadTaskStatus.PENDING);
    });
    this.stopNetworkMonitoring();
  }

  // 继续上传
  public resume() {
    this.paused = false;
    this.fillConcurrency();
  }

  // 重试失败的任务
  public retry() {
    this.taskMap[UploadTaskStatus.FAILED].forEach((task) => {
      task.attempt = 0;
      this.moveTaskToStatus(task, UploadTaskStatus.PENDING);
    });
    this.fillConcurrency();
  }

  // 开始网络监测
  private startNetworkMonitoring() {
    if (this.networkMonitorInterval) return;
    this.networkMonitorInterval = window.setInterval(() => {
      this.adjustConcurrencyBasedOnNetwork();
    }, 5000); // 每 5 秒检查一次网络状况
    this.adjustConcurrencyBasedOnNetwork(); // 立即执行一次以初始化并发任务
  }

  // 停止网络监测
  private stopNetworkMonitoring() {
    if (this.networkMonitorInterval) {
      window.clearInterval(this.networkMonitorInterval);
      this.networkMonitorInterval = null;
    }
  }

  // 根据网络状况调整并发数
  private adjustConcurrencyBasedOnNetwork() {
    const connection = navigator.connection;
    if (connection) {
      const effectiveType = connection.effectiveType;
      switch (effectiveType) {
        case "slow-2g":
          this.concurrency = 1;
          break;
        case "2g":
          this.concurrency = 2;
          break;
        case "3g":
          this.concurrency = 3;
          break;
        case "4g":
          this.concurrency = 5;
          break;
        default:
          this.concurrency = 3;
      }
      this.fillConcurrency();
    }
  }

  // 移动任务到指定状态
  private moveTaskToStatus(task: UploadTask, newStatus: UploadTaskStatus) {
    const oldStatus = task.status;
    task.status = newStatus;
    this.taskMap[oldStatus] = this.taskMap[oldStatus].filter(
      (t) => t.index !== task.index
    );
    this.taskMap[newStatus].unshift(task);
  }
}

// 使用示例

export default FileUploader;


6.后端搭建

正当我写完代码的时候,学妹又来信息了

微信图片_20250313012809.jpg

看来学妹终究抵抗不了我的魅力,既然学妹这么主动,那我也不能伤她的心!!于是,更加卖力的弄了起来。现在写好了,我们需要弄个环境测试一下。

1.项目初始化,vscode 终端执行以下代码

mkdir nodejs-backend-project ; cd nodejs-backend-project ; yarn init
  1. 安装必要插件
yarn add cors crypto express fs-extra multer

3.新建app.js文件,实现文件检查,上传,合并接口,粘贴如下代码,并执行 node app.js

const express = require("express");
const multer = require("multer");
const cors = require("cors");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const app = express();
const upload = multer({ dest: "uploads/tmp/" });

app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 确保必要的目录存在
const ensureDirectories = () => {
  if (!fs.existsSync("uploads")) fs.mkdirSync("uploads");
  if (!fs.existsSync("uploads/tmp")) fs.mkdirSync("uploads/tmp");
  if (!fs.existsSync("uploads/merged")) fs.mkdirSync("uploads/merged");
};

ensureDirectories();

// 检查上传状态接口
app.get("/upload/check", async (req, res) => {
  const { fileHash } = req.query;
  if (!fileHash) return res.status(400).send("Missing fileHash");

  const uploadDir = path.join("uploads/tmp", fileHash);
  const metadataPath = path.join(uploadDir, "metadata.json");

  try {
    if (!fs.existsSync(metadataPath)) {
      return res.json({ uploadedChunks: [] });
    }

    const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
    res.json({ uploadedChunks: metadata.uploadedChunks });
  } catch (error) {
    res.status(500).send("Error checking upload status");
  }
});

app.delete("/clean", async (req, res) => {
  const result = {
    deletedFiles: [],
    deletedDirs: [],
    errors: [],
    preserved: ["uploads/tmp", "uploads/merged"],
  };

  // 清理临时目录(保留目录结构)
  try {
    const tmpDir = path.join("uploads", "tmp");
    if (fs.existsSync(tmpDir)) {
      fs.rmSync(tmpDir, { recursive: true, force: true });
      fs.mkdirSync(tmpDir, { recursive: true });
      result.deletedDirs.push(tmpDir);
    }
  } catch (error) {
    result.errors.push(`tmp清理失败: ${error.message}`);
  }

  // 清理合并目录(保留目录结构)
  try {
    const mergedDir = path.join("uploads", "merged");
    if (fs.existsSync(mergedDir)) {
      fs.rmSync(mergedDir, { recursive: true, force: true });
      fs.mkdirSync(mergedDir, { recursive: true });
      result.deletedDirs.push(mergedDir);
    }
  } catch (error) {
    result.errors.push(`merged清理失败: ${error.message}`);
  }

  res.json({
    success: result.errors.length === 0,
    message: result.errors.length ? "部分清理失败" : "完全清理成功",
    details: result,
  });
});

// 文件上传接口
app.post("/upload", upload.single("chunk"), async (req, res) => {
  const { index, total, fileHash, chunkHash } = req.body;
  const chunkFile = req.file;

  if (!index || !total || !fileHash || !chunkHash || !chunkFile) {
    return res.status(400).send("Missing parameters");
  }

  const uploadDir = path.join("uploads/tmp", fileHash);
  const chunksDir = path.join(uploadDir, "chunks");
  const metadataPath = path.join(uploadDir, "metadata.json");
  const chunkPath = path.join(chunksDir, `chunk_${index}`);

  try {
    // 确保目录存在
    if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);
    if (!fs.existsSync(chunksDir)) fs.mkdirSync(chunksDir);

    // 初始化或读取元数据
    let metadata = {
      totalChunks: parseInt(total),
      uploadedChunks: [],
      fileHash,
      chunkSize: 0,
      fileName: "",
    };

    if (fs.existsSync(metadataPath)) {
      metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
    }

    // 验证分片哈希
    const fileBuffer = fs.readFileSync(chunkFile.path);
    const hash = crypto.createHash("md5").update(fileBuffer).digest("hex");

    if (hash !== chunkHash) {
      fs.unlinkSync(chunkFile.path);
      return res.status(400).send("Chunk hash mismatch");
    }

    // 移动分片文件到目标位置
    fs.renameSync(chunkFile.path, chunkPath);

    // 更新元数据
    if (!metadata.uploadedChunks.includes(parseInt(index))) {
      metadata.uploadedChunks.push(parseInt(index));
      metadata.uploadedChunks.sort((a, b) => a - b);
      fs.writeFileSync(metadataPath, JSON.stringify(metadata));
    }

    // 检查是否全部上传完成
    if (metadata.uploadedChunks.length === metadata.totalChunks) {
      await mergeChunks(fileHash);
    }

    res.send(`Chunk ${index} uploaded successfully`);
  } catch (error) {
    console.error(error);
    res.status(500).send("Error uploading chunk");
  }
});

// 合并分片
async function mergeChunks(fileHash) {
  const uploadDir = path.join("uploads/tmp", fileHash);
  const chunksDir = path.join(uploadDir, "chunks");
  const metadataPath = path.join(uploadDir, "metadata.json");
  const mergedDir = path.join("uploads/merged");

  try {
    const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
    const chunkPaths = Array.from({ length: metadata.totalChunks }, (_, i) =>
      path.join(chunksDir, `chunk_${i}`)
    );

    const mergedFilePath = path.join(mergedDir, fileHash);
    const writeStream = fs.createWriteStream(mergedFilePath);

    for (const chunkPath of chunkPaths) {
      const buffer = fs.readFileSync(chunkPath);
      writeStream.write(buffer);
    }

    writeStream.end();

    // 清理临时文件
    fs.rmdirSync(uploadDir, { recursive: true });

    console.log(`File ${fileHash} merged successfully`);
  } catch (error) {
    console.error("Error merging chunks:", error);
  }
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

7.前端搭建

1.新建vite项目

npx create-vite my-react-ts-app --template react-ts

2.配置vite.config.ts代理到我们的服务器

import { defineConfig } from "vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    react(),
  ],
  server: {
    proxy: {
      "/upload": {
        target: "http://localhost:3000",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/upload/, ""),
      },
    },
  },
});

3.App.tsx中引入使用

import { useRef } from "react";
import FileUploader from "./UploaderFile";
import { Button, Input } from "antd";
function App() {
  const uploaderRef = useRef<any>(null);

  const handleChange = (e: React.ChangeEvent) => {
    uploaderRef.current = new FileUploader(
      e.target.files[0],
      1024 * 1024 * 5,
      3,
      "http://localhost:3000"
    );
  };

  const handleUpload = () => {
    uploaderRef.current.start();
  };

  return (
    <div>
      <Input type="file" onChange={handleChange} />
    
      <Button type="primary" onClick={handleUpload}>
        上传
      </Button>
      <Button style={{margin:"0 10px"}} type="primary" onClick={() => uploaderRef.current.pause()}>
        暂停
      </Button>
      <Button type="primary" onClick={() => uploaderRef.current.resume()}>
        继续
      </Button>
    </div>
  );
}

export default App;

4.创建1Gb大小文件文件尝试上传 cmd内执行命令

fsutil file createnew C:\TestFiles\1GBFile.txt 1073741824

至此创建了一个1Gb的文件,结合前后端就可以实现文件的分片上传了

8.后话

正当我满怀喜悦去找学妹时,她竟然说另一个人比我快,已经做好了,她和那个人去约会了,这我能忍?学妹啊学妹,你让学长怎么说你好呢,你喜欢什么样的不行,偏偏喜欢快的男人,那我肯定不行,因为我很持久,快不了一点!!!

微信图片_20250313013434.jpg

女人是善变的动物,纯洁的我深受其害

好了,大文件上传到这里就结束了,其实还有好几个地方可以优化,现在的hash计算和分片计算是阻塞的,同步的,很影响上传体验,大文件要等待很久,我提供一些思路,大家有兴趣可以实现一下:

1. 监听浏览器网络变化,动态更新并发数

2. 文件hash异步计算不能阻塞当前上传

3. 文件分片hash分批异步计算,边计算边上传

4. 实现超大文件一读取完毕立刻进行上传,极速反应

大家可以试一试,评论区可以交流

12f0cb48366a41bf91d051891fa35f8f~tplv-k3u1fbpfcp-zoom-in-crop-mark_1512_0_0_0.webp