在企业级 Web 应用中,大文件上传几乎是一个绕不开的话题:视频、日志包、模型文件、设计稿、数据集……
当文件体积从几十 MB 上升到 GB 级别,传统的上传方式往往会彻底失效。
本文将系统讲清楚:
- 大文件上传会遇到哪些核心问题
- 分片上传的整体设计思路
- 秒传 / 断点续传 / 并发控制如何实现
- 前后端核心实现代码
- 线上落地的最佳实践
一、为什么普通上传在大文件面前会失败?
1. 请求不稳定,失败率高
- HTTP 请求时间过长
- 网络抖动、代理超时、浏览器中断
- 任意一次失败都要 重头再来
2. 用户体验极差
- 上传过程中页面刷新或关闭 → 全部丢失
- 无法展示准确进度
- 失败后不可恢复
3. 服务端压力巨大
- 单请求占用连接时间过长
- 内存 / 带宽被长时间占用
- 高并发下容易拖垮服务
二、大文件上传的核心解决思路
核心思想:分而治之
把一个大文件,拆成多个可控的小文件,分别上传,最后在服务端合并。
标准分片上传流程
选择文件
↓
文件切片(chunk)
↓
并发上传分片
↓
记录已上传分片
↓
全部完成后通知服务端合并
三、一个成熟的分片上传方案应具备哪些能力?
| 能力 | 说明 |
|---|---|
| 分片上传 | 将大文件拆分为多个 chunk |
| 断点续传 | 已上传的分片不再重复上传 |
| 秒传 | 文件已存在时直接完成 |
| 并发控制 | 控制同时上传的分片数量 |
| 失败重试 | 单分片失败可重试 |
| 服务端合并 | 确保合并顺序和完整性 |
四、整体架构设计
前后端职责划分
前端负责:
- 文件切片
- 计算文件唯一标识(hash)
- 分片并发上传
- 失败重试
- 上传进度控制
后端负责:
- 校验文件是否已存在(秒传)
- 记录已上传分片
- 接收分片并持久化
- 按顺序合并文件
- 清理临时分片
五、前端核心实现(浏览器)
1️⃣ 文件切片
TypeScript
/**
* 将文件切分为固定大小的分片
*/
function createFileChunks(file: File, chunkSize = 5 * 1024 * 1024) {
const chunks: Blob[] = [];
let start = 0;
while (start < file.size) {
chunks.push(file.slice(start, start + chunkSize));
start += chunkSize;
}
return chunks;
}
2️⃣ 计算文件 Hash(用于秒传 & 断点续传)
TypeScript
/**
* 计算文件 hash(基于 Web Worker + SparkMD5 更优)
*/
async function calculateHash(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
3️⃣ 上传单个分片
TypeScript
/**
* 上传单个分片
*/
function uploadChunk(
chunk: Blob,
index: number,
hash: string
) {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', index.toString());
formData.append('hash', hash);
return fetch('/upload/chunk', {
method: 'POST',
body: formData,
});
}
4️⃣ 并发上传控制(重点)
TypeScript
/**
* 控制并发上传
*/
async function uploadChunksWithLimit(
tasks: (() => Promise<any>)[],
limit = 4
) {
const executing: Promise<any>[] = [];
for (const task of tasks) {
const p = task();
executing.push(p);
if (executing.length >= limit) {
await Promise.race(executing);
executing.splice(
executing.findIndex(e => e === p),
1
);
}
}
return Promise.all(executing);
}
5️⃣ 合并请求
TypeScript
/**
* 通知服务端合并分片
*/
function mergeChunks(hash: string, fileName: string) {
return fetch('/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash, fileName }),
});
}
六、服务端核心实现(Node.js 示例)
1️⃣ 接收分片
TypeScript
/**
* 接收分片并保存到临时目录
*/
app.post('/upload/chunk', upload.single('chunk'), (req, res) => {
const { hash, index } = req.body;
const chunkDir = path.resolve(__dirname, 'temp', hash);
if (!fs.existsSync(chunkDir)) {
fs.mkdirSync(chunkDir, { recursive: true });
}
fs.renameSync(
req.file.path,
path.join(chunkDir, index)
);
res.json({ success: true });
});
2️⃣ 合并分片
TypeScript
/**
* 合并所有分片
*/
app.post('/upload/merge', async (req, res) => {
const { hash, fileName } = req.body;
const chunkDir = path.resolve(__dirname, 'temp', hash);
const filePath = path.resolve(__dirname, 'uploads', fileName);
const chunks = fs.readdirSync(chunkDir).sort((a, b) => Number(a) - Number(b));
const writeStream = fs.createWriteStream(filePath);
for (const chunk of chunks) {
const chunkPath = path.join(chunkDir, chunk);
writeStream.write(fs.readFileSync(chunkPath));
fs.unlinkSync(chunkPath);
}
writeStream.end();
fs.rmdirSync(chunkDir);
res.json({ success: true });
});
七、断点续传与秒传如何实现?
关键接口:查询已上传分片
TypeScript
GET /upload/status?hash=xxx
返回:
JSON
{
"uploadedChunks": [0,1,2,5,6]
}
前端只上传 缺失的分片,即可实现:
- 断点续传
- 页面刷新继续
- 网络失败恢复
八、最佳实践总结(非常重要)
前端最佳实践
- 分片大小:2MB ~ 10MB
- 并发数:3 ~ 6
- hash 计算使用 Web Worker
- 上传失败要支持重试
- 显示真实进度(已完成分片 / 总分片)
后端最佳实践
- 分片目录按 hash 隔离
- 合并采用流式写入
- 合并完成立即清理临时文件
- 对合并接口加锁,防止重复合并
- 支持幂等处理
九、总结
大文件分片上传并不是一个“技巧”,而是一个系统性工程问题:
- 前端:性能、并发、稳定性、体验
- 后端:可靠性、幂等性、资源控制
- 架构:可恢复、可扩展、可维护
一套设计良好的分片上传方案,能够将 GB 级文件上传成功率提升到接近 100%,并显著改善用户体验。
文章评论