文件分片上传分析

在实现文件上传时,通常会去讨论文件分片上传、秒传以及断点续传等问题,这里整理一下解决方法.

问题分片上传的好处
大文件上传超时 / 失败分片失败可重传,避免全部重传
网络不稳定分片可以断点续传
并发能力弱分片可并发上传提高速度
浏览器限制(如 body 大小)避免单次请求过大被限制
支持上传进度显示每片进度可追踪

文件分片上传是一种将大文件分割成多个小片段(分片),然后逐个上传这些分片的技术。这种方法有几个优点,包括提高上传的成功率、减少内存占用以及支持断点续传等。下面简要介绍文件分片上传的概念和实现方法。

前端流程:

  1. 选择文件并读取其大小
  2. 将文件按固定大小(如 1MB)切片(使用 Blob.slice
  3. 逐片上传(可并发)
  4. 上传成功后通知服务器进行合并

可以通过hash分片验证是否已经上传,从而提升用户文件上传体验.

Hash检测 实现秒传

秒传的关键是“文件去重” —— 客户端通过计算文件指纹(如 MD5、SHA256),先向服务器查询是否已存在相同文件,如果存在则跳过上传,直接“秒传成功”。

实现步骤:

  1. 前端计算文件 hash(通常用 CryptoJSspark-md5)。
  2. 发请求询问服务器:这个 hash 是否已上传?
  3. 如果服务器返回“存在”:
    • 返回文件地址(或秒传成功)
  4. 否则再走正常上传流程。

注意事项:

  • 前端文件 hash 计算是异步的,推荐使用 Web Worker 加速。
  • 服务端需要维护文件 hash 与实际文件路径的映射表(如 Redis、数据库)
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
const file = ref<File | null>(null);
const handleFileChange = (event:Event)=> {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
file.value = target.files[0] as File;
console.log(file.value.name);
const chunks = calculateChunks(file.value);
calculateHash(chunks).then((hash) => {
console.log("File hash:", hash);
}).catch((error) => {
console.error("Error calculating hash:", error);
});

}
}
const calculateChunks = (file:File):Blob[]=>{
const size = file.size;
const chunks:Blob[] = [];
let start = 0;
while(start < size){
const end = Math.min(start + CHUNK_SIZE, size);
const chunk = file.slice(start, end);
chunks.push(chunk);
start = end;
}
return chunks;
}

const calculateHash = async(chunks:Blob[])=>{
return new Promise((resolve, reject) => {
const size = chunks.length;
console.log(`一共有${size}个分片`);
const targets:Blob[] = [];
const fileReader = new FileReader();
chunks.forEach((chunk:Blob, index) => {
if(index == 0 || index == size - 1){
targets.push(chunk);
}else{
targets.push(chunk.slice(0, 2));
targets.push(chunk.slice(CHUNK_SIZE/2, CHUNK_SIZE/2 + 2));
targets.push(chunk.slice(CHUNK_SIZE - 2, CHUNK_SIZE));
}
});
fileReader.readAsArrayBuffer(new Blob(targets));
const spark =new SparkMD5.ArrayBuffer();
fileReader.onload = (event:ProgressEvent<FileReader>) => {
const arrayBuffer = event.target?.result as ArrayBuffer;
spark.append(arrayBuffer);
resolve(spark.end());
};

fileReader.onerror = (event:ProgressEvent<FileReader>) => {
console.error("Error reading file:", event);
reject(event);
};

})

切片上传

上传表单form/multipart数据,包括切片索引以及hash信息,后端每次下载切片到一个文件夹下.

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
const uploadChunks = async (chunks: Blob[]) => {
const data = chunks.map((chunk: Blob, index: number) => {
const formData = new FormData();
formData.append("fileHash", fileHash.value as string);
formData.append("chunk", chunk);
formData.append("size", chunk.size.toString());
formData.append("chunkIndex", index.toString());
formData.append("chunkHash", fileHash.value + "-" + index);
return formData;
});
console.log(data);

const MAX_REQUEST = 6;
// 并发请求
const taskPool: Promise<Response>[] = [];
let index = 0;
while (index < data.length) {
const task = fetch("http://localhost:3000/upload", {
method: "POST",
body: data[index],
});
taskPool.push(task);

task
.then((response) => {
if (response.ok) {
console.log("分片上传成功");
taskPool.splice(taskPool.indexOf(task), 1);
} else {
console.error("分片上传失败");
}
})
.catch((error) => {
console.error("Error uploading chunk:", error);
});
if (taskPool.length == MAX_REQUEST) {
Promise.race(taskPool)
.then((result) => {
if (result.ok) {
console.log("分片上传成功");
taskPool.splice(taskPool.indexOf(task), 1);
} else {
console.error("分片上传失败");
}
// 只要有一个完成就返回
// 如果上传成功,移除该任务
})
.catch((error) => {
console.error("Error uploading chunk:", error);
});
}
index++;
}
await Promise.all(taskPool);
};

原理:

大文件切成多个小块(如每块 1MB),逐个上传,上传完毕后由服务器合并为完整文件

实现步骤:

  1. 前端将文件切片,通常通过:

    1
    const chunk = file.slice(start, end);
  2. 每个切片附带索引和文件唯一标识(如 hash)上传。

  3. 后端接收每个切片,按 hash 和索引编号保存。

  4. 前端上传完所有切片后,请求服务端合并文件

关键点:

  • 客户端:控制并发上传(推荐并发数 3~5)。
  • 服务端:需要记录哪些切片已经上传(可用 Redis 或文件夹索引)。
  • 最后合并文件时可用 fs.appendFilefs.createWriteStream

文件合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const mergeRequest = async () => {
const reqData = {
fileHash: fileHash.value,
fileName: fileName.value,
fileTotalSize: file.value?.size,
chunkSize: CHUNK_SIZE,
};
const response = await fetch("http://localhost:3000/merge", {
headers: {
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify(reqData),
});
if (response.ok) {
console.log("文件合并成功");
alert("合并成功");
} else {
console.error("文件合并失败");
}
};
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
74
75
76
77
78
79
80
81
app.post("/merge", async (req, res) => {
// 合并文件 json请求
const { fileHash, fileName, fileTotalSize, chunkSize } = req.body;
const chunkFile = path.resolve(UPLOAD_DIR, fileHash + getSuffix(fileName));
if (fs.existsSync(chunkFile)) {
res.status(200).json({
ok: true,
msg: "文件已经存在",
});
} else {
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
if (!fs.existsSync(chunkDir)) {
res.status(401).json({
ok: false,
msg: "合并失败,请重新上传",
});
}
// 读取目录下文件
const mergeFilePath = chunkFile;
const chunkPaths = await fse.readdir(chunkDir);
if (chunkPaths.length === 0) {
res.status(401).json({
ok: false,
msg: "合并失败,请重新上传",
});
}
chunkPaths.sort((a, b) => {
return a.split("-")[1] - b.split("-")[1];
});
const tasks = chunkPaths.map((chunkPath, index) => {
return new Promise((resolve, reject) => {
// 读取文件
const chunkFilePath = path.resolve(chunkDir, chunkPath);
// const fileBuffer = fs.readFileSync(chunkFilePath);
// 写入文件
const readStream = fse.createReadStream(chunkFilePath);
const writeStream = fse.createWriteStream(mergeFilePath, {
start: index * chunkSize,
end: (index + 1) * chunkSize,
});
// writeStream.on("finish", async () => {
// console.log(`删除文件${chunkFilePath}`);
// await fse.unlink(chunkFilePath);
// });
try {
pipeline(readStream, writeStream).then(() => {
fse.unlinkSync(chunkFilePath);
resolve();
}).catch((err) => {
console.log(err);
reject(err);
});
} catch (err) {
console.log(err);
reject(err);
}
// fs.appendFileSync(mergeFilePath, fileBuffer);
})
});
try {
await Promise.all(tasks);
} catch (err) {
res.status(401).json({
ok: false,
msg: "合并失败,请重新上传",
});
return;
}
const files = await fse.readdir(chunkDir);
const fileCount = files.length;
console.log(fileCount)
if (fileCount === 0) {
await fse.rmdir(chunkDir);
}
res.status(200).json({
ok: true,
msg: "合并成功",
});
}
});

在切片上传完毕之后再发送merge请求.

断点续传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 客户端
const verifyHash = async(hash: string,fileName:string) => {
return fetch("http://localhost:3000/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
fileHash: hash,
fileName: fileName,
}),
}).then(resp=>resp.json())
.then((result) => {
return {existFile:!result.shouldUpload,existChunks:result?.existChunks};
}).catch((error) => {
console.error("Error verifying file hash:", error);
throw new Error(error);
});
};
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
// 服务端
app.post("/verify", (req, res) => {
const { fileHash, fileName } = req.body;

// 返回服务器已经上传成功的切片索引
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
if (fs.existsSync(chunkDir)) {
// 如果存在该目录,查看其中的最大文件索引
const files = fs.readdirSync(chunkDir);
// const maxIndex = Math.max(...files.map((fileName) => {
// return parseInt(fileName.split("-")[1]);
// }));
res.status(200).json({
ok: false,
msg: "完整文件不存在",
shouldUpload:true,
existChunks: files,
});
}


// 验证是否已经有合并后的完整文件
const suffix = getSuffix(fileName);
const filePath = path.resolve(UPLOAD_DIR, fileHash + suffix);
if (fs.existsSync(filePath)) {
res.status(200).json({
ok: true,
msg: "文件已经存在",
shouldUpload:false,
});
} else {
res.status(200).json({
ok: false,
msg: "完整文件不存在",
shouldUpload:true,
});
}

})

原理:

上传中断后,可以只上传未完成的部分,而不用从头开始。

实现方式:

  • 通常与分片上传结合使用
  • 客户端在重试上传前,先向服务端询问已上传的切片列表。
  • 客户端只上传未完成的切片,最后合并。

关键点:

  • 客户端需记录上传状态(如切片状态、索引)。
  • 服务端提供接口返回已有的切片(如 /upload/status?fileHash=xxx)。
  • 文件 hash 一定要稳定,用于唯一标识该文件上传任务。

服务端解决方案

技术点实现方式
存储切片保存到本地临时目录 or 对象存储(如 OSS、S3)
存储状态Redis(高效) or MongoDB/MySQL
文件合并fs.createWriteStream + fs.createReadStream
断点续传状态文件夹结构 + index.json、数据库记录

推荐前端工具库

  • spark-md5:快速计算文件 hash(支持分片 hash)
  • axios:支持中断、重试、并发控制
  • 可配合 Web WorkerFileReader 异步读取文件

    Web Worker

Web Workers 是一种让网页内容在后台线程中运行脚本的方式。工作线程可以在不影响用户界面的情况下执行任务。此外,它们可以使用 fetch()XMLHttpRequest API 发起网络请求。一旦创建,一个工作线程可以通过向创建它的 JavaScript 代码指定的事件处理器发送消息来与其通信(反之亦然)

Using Web Workers - Web APIs | MDN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const fileInput = document.getElementById('fileInput');
const result = document.getElementById('result');

// 创建 Worker 实例
const worker = new Worker('hash-worker.js');

worker.onmessage = function (event) {
result.textContent = '文件 MD5: ' + event.data;
};

fileInput.addEventListener('change', function () {
const file = this.files[0];
if (!file) return;

// 读取文件为 ArrayBuffer,发送给 Worker
const reader = new FileReader();
reader.onload = function () {
worker.postMessage(reader.result); // ArrayBuffer 发送给 Worker
};
reader.readAsArrayBuffer(file);
});

WorkerGlobalScope: importScripts() method - Web APIs | MDN

1
2
3
4
5
6
7
8
9
10
11
// 引入 spark-md5(注意路径)或使用 CDN + importScripts
importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js');

onmessage = function (event) {
const arrayBuffer = event.data;
const spark = new self.SparkMD5.ArrayBuffer();
spark.append(arrayBuffer);
const hash = spark.end();
postMessage(hash);
};

  • 主线程不卡顿。
  • 计算任务可异步处理,提升用户体验。
  • ArrayBuffer 是可转移对象,性能更好(不会拷贝,只是转移)。

注意事项

  • Worker 中无法访问 DOMwindow
  • Worker 是异步的,不支持 alertconfirm 等。
  • 跨域引用 worker.js 时需要注意同源策略,或使用 Blob 方式创建 Worker。

实战优化

  • 支持大文件(如 1GB+),首选分片上传 + 秒传。

  • 支持上传失败重试(单个切片失败可重试)。

  • 使用服务端合并标记(如 done.flag)避免重复合并。

  • 上传前先校验文件 hash,实现秒传。

  • 提供进度回调、UI反馈。

-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道