前言: 近日,正是周末,我在迪士尼玩耍着,吹着刺骨的阴风正黯然神伤,突然,手机一震,来了一条信息,我预感好事来临,打开一看果然是美事,学妹给我发了条信息,怎料我正要开心一下,学妹立马又说道:“在忙吗?学长,我有个问题请教你!”哎,果然,没事学妹是不会找我的,虽然有些许失望,但身为她正直而又热心的学长,我又如何能弃她于不顾?
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.逐个击破
正当我沉醉在编码过程中的时候,学妹已经等不及了
我勒个去!竟然这么直截了当的说学长不行,看来得找机会让她狠狠知道我的厉害!!!不过当编程的心一起来,女人就不重要了,让我们把学妹再用力地抛向一边,开始编程。
重点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.后端搭建
正当我写完代码的时候,学妹又来信息了
看来学妹终究抵抗不了我的魅力,既然学妹这么主动,那我也不能伤她的心!!于是,更加卖力的弄了起来。现在写好了,我们需要弄个环境测试一下。
1.项目初始化,vscode 终端执行以下代码
mkdir nodejs-backend-project ; cd nodejs-backend-project ; yarn init
- 安装必要插件
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.后话
正当我满怀喜悦去找学妹时,她竟然说另一个人比我快,已经做好了,她和那个人去约会了,这我能忍?学妹啊学妹,你让学长怎么说你好呢,你喜欢什么样的不行,偏偏喜欢快的男人,那我肯定不行,因为我很持久,快不了一点!!!
女人是善变的动物,纯洁的我深受其害
好了,大文件上传到这里就结束了,其实还有好几个地方可以优化,现在的hash计算和分片计算是阻塞的,同步的,很影响上传体验,大文件要等待很久,我提供一些思路,大家有兴趣可以实现一下:
1. 监听浏览器网络变化,动态更新并发数
2. 文件hash异步计算不能阻塞当前上传
3. 文件分片hash分批异步计算,边计算边上传
4. 实现超大文件一读取完毕立刻进行上传,极速反应
大家可以试一试,评论区可以交流