说实话,我最初接到“上传整个素材库并保留目录结构”的需求时,第一反应是“这不就是网盘上传嘛”。但真开始做才发现,用单文件上传的思路去扛一个几千文件、几个G甚至几十个G的文件夹,基本是必翻车:要么请求体超限被服务端拒收,要么传完一片文件在服务器上摊平成“大水漫灌”,用户验收时一脸懵:“我明明选择的是‘分类/年度/项目名’这个目录,怎么服务器上全没了?”
这篇文章要解决的问题就是:在 C#.NET 后端前提下,前端上传插件(浏览器端可复用组件)如何支持大文件夹上传,并且完整保留本地目录结构。我会从目录树的获取、分片上传调度、C#.NET 接口设计、路径重建合并这几个维度拆开讲,每一步都给出可落地的方案和踩坑记录。无论是你在给内部系统做资料归档工具,还是做类网盘项目,这篇文章都值得参考。
1. 为什么“上传整个文件夹”和“上传多个文件”完全是两回事
1.1 目录结构不是“字符串”,是必须落地的物理路径
很多第一次做这个需求的人会想:目录结构保留,不就是把文件路径记下来,再按路径拼接一下吗?实际上,浏览器端拿到的“目录结构”本质上只是一个相对路径字符串,比如 素材库/视频/剪辑片段/正片.mp4。但到了服务器端,这个字符串要变成真实存在的物理目录树,涉及路径分隔符、非法字符、目录层级创建、路径穿越防护等一系列问题。
更麻烦的是,同一个目录下可能有几千个小文件,也可能有几个几十G的大视频。如果只是把路径记下来、然后逐个调用“简单文件上传”,在小规模场景下能跑通,但规模一上来就会暴露三个致命问题:
- 单文件上传普遍依赖 multipart/form-data 整体提交,文件越大越容易超出服务器请求体限制;
- 文件一多,浏览器和服务端之间的请求数会成倍增长,性能急剧恶化;
- 前端如果没有对文件做切片和断点续传,一旦中途断网,整个文件夹要重新传。
所以,“支持大文件夹上传”不是“支持多个文件上传”的加强版,而是一个需要重新设计的链路。前端要负责目录枚举、分片调度、任务队列;后端要负责接收分片、临时存储、幂等校验、顺序合并、目录重建。两者必须协同,缺一环都会出问题。
1.2 三个单文件方案解决不了的大文件夹硬伤
先罗列一下我见过的一些“看起来很省事”的替代方案,以及它们为什么不行:
| 替代方案 | 看起来行的地方 | 实际硬伤 |
|---|---|---|
| 用户先把文件夹压缩成zip上传,服务端解压 | 一个请求搞定所有文件 | 压缩大文件夹耗时极长;服务端解压需要额外依赖;还不能实时显示每个文件的进度 |
| 前端遍历文件夹,逐个调用传统文件上传接口 | 实现简单,复用老接口 | 对服务器压力大;没有断点续传;大文件还受请求体大小限制 |
| 直接用现成上传组件,比如 WebUploader/Plupload | 有现成分片和进度条 | 这些组件对“目录结构保留”的支持几乎为零,传完还是一马平川 |
这些方案我都在项目里试过或调研过。最后的结论很一致:想做到“大文件可靠传输 + 目录结构完整重建”,必须自研一条链路,前端负责拆,后端负责收,中间用一套明确的协议约定好“每个文件相对路径是什么、分成多少片、当前第几片”。
这个协议设计,就是接下来要讲的核心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前端插件入口:webkitdirectory、webkitRelativePath与那批神秘的File对象
2.1 拿到文件夹:一个input属性就够
HTML5 早就给 <input type="file"> 加了 webkitdirectory 属性。虽然带 webkit 前缀,但 Chrome、Edge、新版本 Safari 对它的支持已经非常稳定,是目前“选择整个文件夹”最通用的方案。
html复制<input type="file" id="folderInput" webkitdirectory multiple />
multiple 在这里也很关键。比如用户选了 素材库/视频 这个目录,如果没有 multiple,部分浏览器会限制成单选;加了之后,可以一下子选中整个目录树。
javascript复制document.querySelector('#folderInput').addEventListener('change', (e) => {
const picked = Array.from(e.target.files);
const tasks = picked.map((file, index) => ({
file,
fileIndex: index,
relativePath: file.webkitRelativePath, // "素材库/视频/剪辑片段/正片.mp4"
fileName: file.name,
size: file.size,
lastModified: file.lastModified
}));
console.log(`总共选出了 ${tasks.length} 个文件`);
});
这里最核心的一个属性是 file.webkitRelativePath。它的值永远是相对路径,分隔符统一用 /,并且包含顶层文件夹名。比如用户选了 素材库 文件夹,选中里面的 视频/1.mp4,这个值就是 素材库/视频/1.mp4。
它的意义在于:浏览器替我们把整个目录树“拍平”成了一组带路径的 File 对象。每个 File 对象本身就是独立文件,可以单独读取、单独切分、单独上传。这正是“保留目录结构”的地基。
2.2 空目录,前端天然看不见
这里有一个所有人都会踩的坑:如果你用 webkitdirectory 选择文件夹,空目录根本不会出现在 FileList 里。也就是说,前端无法通过这种方式感知“这个文件夹下面还有一个空目录”。
产品经理如果说“空目录也得保留”,那不能只靠这个方案。我做过的一个比较实用的折中处理是:
- 默认告知用户“空文件夹不会被上传”;
- 如果业务上确实需要保留空目录,可以让用户在空目录里放一个
.keep占位文件,服务端识别到.keep后只创建目录不保留文件; - 或者额外用 File System Access API 的
showDirectoryPicker递归枚举空目录,但该 API 的兼容性受限,我一般不作为主方案。
所以,技术上能拿到完整文件列表,但“空目录”是浏览器层面的信息盲区。设计产品时一定要提前确认,否则验收阶段会因为这个细节扯皮。
2.3 顶层文件夹名到底保不保留
这是另一个容易忽略的产品决策点。webkitRelativePath 是包含顶层文件夹名的。比如用户选中了 素材库,所有文件的相对路径都以 素材库/ 开头。
如果服务端直接拿 Path.Combine(存储根目录, webkitRelativePath) 去重建,那么服务器上会多出一层顶层文件夹。有的业务希望保留这一层(比如多用户上传,每个人一个独立根目录),有的业务则不想要(用户只想让“视频/1.mp4”落在目标目录下)。
我的建议是:保留。理由有三:
- 保留顶层目录,多用户批量上传时天然隔离,不容易互相覆盖;
- 用户本地是什么结构,线上就是什么结构,验收直观;
- 如果想去掉,在后端重建路径时去掉第一段即可,纯属一行代码的事。
所以这个决策最好放在产品层,技术层只需要保证“相对路径原样上传、原样重建”即可。
3. 分片上传与调度:再大的文件也是5MB一包地发
3.1 为什么不能把所有文件 append 进一个 FormData
很多刚接触前端上传的人,会把文件夹里所有文件循环 formData.append('file', file) 到一个请求里提交。这种做法在演示环境里能跑,但在生产环境会很快碰壁:
- 浏览器把所有文件写成 multipart 请求体时,虽然 File 对象底层是流式读取,但整个请求体要完整经过网络层、服务端解析器,其中任何一个环节对请求体大小有限制都会直接失败;
- 一旦断网,整个请求作废,所有文件从头传;
- 服务端解析一个包含几千文件的超大 multipart 请求,内存和临时磁盘占用都非常吓人。
所以正确姿势是:前端把“整个文件夹”拆成“一堆独立文件”,再把“大文件”拆成“独立分片”。每个分片是独立的 HTTP 请求,请求体大小可控、服务端可以给每个分片做幂等校验、失败后只需要重传那一片。
3.2 整传还是分片:按文件大小走双通道
分片不是万能的。如果文件本身只有几 KB,硬切成 5MB 分片只会让单个文件变成 1 个没必要的分片,徒增一次请求。更合理的策略是:
- 小于阈值(比如 5MB)的文件,整体上传,一次请求完成;
- 大于阈值的文件,按固定分片大小(比如 5MB)切片,每个分片单独上传。
这样一来,一个包含几万个小文件 + 十几个大视频的素材库,小文件走“整传通道”,大文件走“分片通道”,请求总量被控制住,大文件的断点续传能力也有了。
前端分片逻辑比较简单,用 File.slice 即可:
javascript复制const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
function splitChunks(file) {
const total = Math.ceil(file.size / CHUNK_SIZE);
const chunks = [];
for (let i = 0; i < total; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
chunks.push({ blob: file.slice(start, end), index: i });
}
return { chunks, total };
}
这里有个小建议:分片不要用 1MB 这种过小的值。分片越小,请求数越多,服务端打开/关闭临时文件的次数越多,磁盘 IOPS 会成为瓶颈。我一般推荐 5MB~10MB,尤其在大文件夹上传场景下,10MB 分片对服务端更友好。
3.3 并发数、进度条与“断点续传的下半场”
上传本质是 IO 密集型操作,限制并发数能避免一下打爆浏览器和服务器的连接数。我的经验是:并发控制在 5~6 比较稳。
原因有二:一是浏览器对同一域名有连接数限制(HTTP/1.1 下通常是 6),并发开太高会在连接排队上浪费时间;二是服务端每个分片都要落盘,并发高会加剧磁盘竞争。实测下来,千兆内网里 5~6 并发基本能把带宽吃满,再高也快不了多少。
并发调度的核心可以简化成下面这个“任务池”模式:
javascript复制async function runPool(tasks, limit) {
const queue = [...tasks];
const workers = Array.from({ length: limit }, async () => {
while (queue.length) {
const task = queue.shift();
await task();
}
});
await Promise.all(workers);
}
上传分片时,我建议用 XMLHttpRequest 而不是 fetch,因为 XHR 有现成的 upload.onprogress 事件,进度条好做。分片粒度很小,进度条往往做到“文件级”:每个文件一个进度,所有文件汇总成整体百分比。
javascript复制function uploadChunk(meta, chunkInfo) {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append('batchId', meta.batchId);
form.append('fileIndex', meta.fileIndex);
form.append('chunkIndex', chunkInfo.index);
form.append('totalChunks', meta.totalChunks);
form.append('fileName', meta.fileName);
form.append('relativePath', meta.relativePath);
form.append('fileSize', meta.file.size);
form.append('lastModified', meta.file.lastModified);
form.append('file', chunkInfo.blob, meta.fileName);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload/chunk');
xhr.onload = () => resolve();
xhr.onerror = () => reject(new Error('网络错误'));
xhr.send(form);
});
}
关于断点续传,需要说明:前端能做的“断点续传”只是不把已上传分片删掉,真正的“续传”要依赖服务端分片是否已存在。正确做法是在上传每个文件之前,先问服务端“这个文件的哪些分片已经有了”,然后只传缺失的分片。这个问题稍后在服务端部分一起讲。
4. C#.NET接口:从Kestrel限制到分片落盘
4.1 接口用multipart还是裸二进制流
先回答一个常见疑问:分片上传接口,用 multipart/form-data 还是直接把分片数据塞到 body 里?
我推荐用 multipart。虽然裸二进制流性能略好(省去 multipart 解析开销),但它要求你把元数据放在 header 或 query string 里,前端构造请求时绕来绕去,服务端取值也不直观。而 multipart 方案里,元数据和分片内容在同一个表单里,C# 模型绑定器可以直接帮你把字段绑到 DTO 上,代码最简洁,也最容易维护。
C# 那边的 DTO 大致长这样:
csharp复制public class ChunkUploadDto
{
public string BatchId { get; set; } // 本次上传批次
public int FileIndex { get; set; } // 批次内文件序号
public int ChunkIndex { get; set; } // 当前分片序号,从0开始
public int TotalChunks { get; set; } // 该文件总分片数
public string FileName { get; set; } // 文件名
public string RelativePath { get; set; } // 相对路径,如 "素材库/视频/1.mp4"
public long FileSize { get; set; } // 文件总大小
public long LastModified { get; set; } // 文件最后修改时间戳
}
对应的接口:
csharp复制[ApiController]
[Route("api/upload")]
public class UploadController : ControllerBase
{
[HttpPost("chunk")]
[RequestSizeLimit(20 * 1024 * 1024)]
public async Task<IActionResult> UploadChunk(
[FromForm] ChunkUploadDto dto,
[FromForm] IFormFile file)
{
if (file.Length == 0)
{
return BadRequest("空分片");
}
var dir = Path.Combine(_options.TempRoot, dto.BatchId, dto.FileIndex.ToString());
Directory.CreateDirectory(dir);
var partPath = Path.Combine(dir, $"{dto.ChunkIndex}.part");
await using (var fs = new FileStream(
partPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
81920))
{
await file.CopyToAsync(fs);
}
return Ok();
}
}
注意 RequestSizeLimit(20 * 1024 * 1024),这是按照 10MB 分片 + 表单元数据余量来设的。分片大小如果自己调整,这个限制也要跟着调。
4.2 RequestSizeLimit不是唯一的坑:IIS和nginx都拦一道
RequestSizeLimit 只是 ASP.NET Core 这一层的限制。实际部署环境里,前面可能还挡着 IIS 或 nginx,它们各有各的请求体大小限制,任何一个不放开,都会在上传大分片时收到 413。
- Kestrel 默认限制是 30MB,但显式设置更稳妥;
- IIS 的
maxAllowedContentLength默认约为 28.6MB,必须调大; - nginx 的
client_max_body_size默认只有 1MB,这个最容易被忽略。
csharp复制// Program.cs
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 20 * 1024 * 1024;
});
如果是 IIs 部署,web.config 里加:
xml复制<system.webServer>
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="20971520" />
</requestFiltering>
</security>
</system.webServer>
如果是 nginx 反代:
nginx复制client_max_body_size 20m;
我遇到过很多次“本地跑得好好的,上一测试环境就 413”的情况,最后查下来全是 nginx 这一层没放开。这个环节最好在联调前就检查一遍,别等项目快要验收了才想起来。
4.3 分片落盘为什么“每片独立存临时文件”
服务端收到分片后,最忌讳的做法是:把分片直接“追加写”到目标文件里。因为分片可能乱序到达,网络层也不保证顺序,一旦后到的分片先写入,文件就坏了。
所以我的落盘策略是:每个分片独立保存为一个 .part 文件,文件名就是分片序号。
临时目录结构大概长这样:
code复制upload_temp/
└── {batchId}/
├── 0/
│ ├── 0.part
│ ├── 1.part
│ └── 2.part
├── 1/
│ ├── 0.part
│ └── 1.part
└── ...
batchId 是前端生成的一个 GUID,一次文件夹选择对应一个批次;FileIndex 是批次内文件的序号;{序号}.part 就是分片文件。
这个方案最大的优点有两个:
- 天然支持幂等:同一分片重复上传,直接覆盖同名
.part文件,不会造成数据叠加,所以前端偶尔重试分片不会出错; - 合并时按序号排序逐个读取即可,逻辑简单可靠。
缺点是临时磁盘占用大约是原文件的一倍(分片 + 合并后的成品)。这点在接受范围之内,因为临时文件在合并后可以立刻清理。
4.4 幂等校验:同一分片传两次不会坏
断点续传的关键在于:前端想知道“哪些分片服务端已经收了”,然后跳过这些分片。所以我额外加了一个状态查询接口:
csharp复制[HttpPost("status")]
public async Task<ActionResult<UploadStatus>> GetStatus([FromBody] StatusQuery query)
{
var dir = Path.Combine(_options.TempRoot, query.BatchId, query.FileIndex.ToString());
var uploaded = Directory.Exists(dir)
? Directory.GetFiles(dir, "*.part")
.Select(p => int.Parse(Path.GetFileNameWithoutExtension(p)))
.ToArray()
: Array.Empty<int>();
return Ok(new UploadStatus
{
UploadedChunkIndexes = uploaded,
// 秒传逻辑,见下文
ServerFileExists = _fileStore.TryMatch(query.RelativePath, query.FileSize, query.LastModified)
});
}
前端上传某个文件前先查状态,UploadedChunkIndexes 里已经包含的分片就跳过。这样不管是用户中途刷新页面,还是网络闪断重连,都能从断点继续,而不是从头再来。
顺带说下“秒传/去重”。对大文件夹来说,用户很可能传过同一批素材,如果能跳过完全相同的文件,能省大量时间。我这里使用的是“相对路径 + 文件大小 + 最后修改时间”的弱校验,误判概率极低。想要更严格的,可以在前端 Web Worker 里用 SparkMD5 算文件内容哈希,上传前拿到服务端比对,但大文件全量哈希很耗时间,我会根据业务重要度决定是否做。
5. 目录结构重建与分片合并:真正的脏活在这里
5.1 相对路径的清洗与防穿越
分片都收齐后,就到了最关键的目录重建环节。这个环节的坑非常多,第一个就是路径安全。
relativePath 虽然来自浏览器,理论上不会出现 ../ 这种路径穿越,但接口是公开的,恶意用户完全可以伪造一个 relativePath=../../Windows/system32 来尝试覆盖服务器文件。所以服务端必须做两级防护:
- 去掉空段、
.、..,并对每段的非法字符做清洗; - 拼接完整路径后,校验它确实在存储根目录之内。
csharp复制private string BuildDestinationPath(string storeRoot, string relativePath)
{
var normalized = relativePath.Replace('\\', '/');
var segments = normalized.Split('/')
.Where(s => !string.IsNullOrWhiteSpace(s) && s != "." && s != "..")
.Select(SanitizeSegment)
.ToArray();
var fullRoot = Path.GetFullPath(storeRoot);
var candidate = Path.GetFullPath(Path.Combine(fullRoot, Path.Combine(segments)));
var rel = Path.GetRelativePath(fullRoot, candidate);
// 如果相对路径以 .. 开头,说明越界了
if (rel.StartsWith(".."))
{
throw new InvalidOperationException("非法的相对路径");
}
return candidate;
}
private string SanitizeSegment(string segment)
{
var invalid = Path.GetInvalidFileNameChars();
var chars = segment.Select(c => invalid.Contains(c) ? '_' : c).ToArray();
return new string(chars);
}
还有一个 Windows 下的细节:路径长度。如果相对路径特别深,拼接后可能超过 Windows 默认的 260 字符限制。最省事的策略是让用户目录层级不要过深,或者使用 \\?\ 长路径前缀(需要 .NET 开启 longPath 支持)。这个一般场景下用不到,但心里得有数。
5.2 按chunkIndex顺序合并,而不是按到达顺序
合并的逻辑非常直白:读取该文件临时目录下所有 .part 文件,按序号排序,逐个写入最终文件。
csharp复制public async Task MergeFileAsync(
string batchId,
int fileIndex,
int totalChunks,
string relativePath,
string storeRoot)
{
var partDir = Path.Combine(_options.TempRoot, batchId, fileIndex.ToString());
var partFiles = Directory.GetFiles(partDir, "*.part")
.Select(p => int.Parse(Path.GetFileNameWithoutExtension(p)))
.OrderBy(x => x)
.ToArray();
if (partFiles.Length != totalChunks)
{
throw new InvalidOperationException($"分片不完整:期望 {totalChunks},实际 {partFiles.Length}");
}
var destPath = BuildDestinationPath(storeRoot, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
await using (var output = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920))
{
foreach (var idx in partFiles)
{
var partPath = Path.Combine(partDir, $"{idx}.part");
await using var input = new FileStream(partPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920);
await input.CopyToAsync(output);
}
}
Directory.Delete(partDir, true);
}
这个顺序问题值得强调:合并时必须 OrderBy(x => x),千万不能依赖分片到达顺序或者文件名枚举顺序。文件系统枚举 .part 文件时不保证数字排序,直接 GetFiles 后按字符串排,会出现 10.part 排在 2.part 前面的问题,所以我这里先 int.Parse 再按数字排。
5.3 batch-complete:所有文件都齐了才处理空目录与清理
每个文件的分片传完不一定等于整个批次完成,因为一个文件夹里可能有很多文件,有的文件还在传。所以前端在批次内所有文件都上传完后,要再发一个 batch-complete 请求,告诉服务端“批次结束了”。
服务端在 batch-complete 里做三件事:
- 遍历批次元数据,确认每个文件的
.part文件数量与TotalChunks一致; - 按元数据里的
RelativePath重建目录结构; - 删除批次临时目录,释放磁盘空间。
这里还有个细节:如果某文件的目标路径已经存在且大小一致,默认直接跳过合并,避免重复覆盖。这在弱校验秒传场景下是合理的。
批次完成接口的伪代码:
csharp复制[HttpPost("batch-complete")]
public async Task<IActionResult> BatchComplete([FromBody] BatchCompleteDto dto)
{
// 1. 从缓存/元数据文件里读出该批次所有文件清单
// 2. 校验清单中每个文件是否分片齐全
// 3. 调用 MergeFileAsync 合并每个文件
// 4. 删除批次临时目录
}
关于“空目录保留”,如果产品要求保留空目录,那么前端需要在 batch-complete 请求里额外传一个 emptyDirs 字段。但前面说过,webkitdirectory 无法枚举空目录,所以这个字段只能来自 File System Access API 或其他补充交互。非必要不建议做,复杂度会显著上升。
5.4 临时磁盘占用和并发合并的锁
合并过程有一个容易忽视的问题:batch-complete 可能会被前端重复触发,或者同一批次的两个请求并发到达,导致同一个文件被合并两次。合并动作本身是覆盖写,重复执行最终结果相同,但会造成无谓的磁盘开销,甚至可能在“删除临时目录”的瞬间还有另一个请求在读 .part 文件,导致异常。
最简单的防护是给合并逻辑加一个“目标文件已存在且大小匹配则跳过”的检查。更严格一点,可以用一个 ConcurrentDictionary<string, SemaphoreSlim> 给每个 batchId + fileIndex 加锁:
csharp复制private static readonly ConcurrentDictionary<string, SemaphoreSlim> MergeLocks = new();
var key = $"{batchId}:{fileIndex}";
var semaphore = MergeLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync();
try
{
await MergeFileAsync(...);
}
finally
{
semaphore.Release();
MergeLocks.TryRemove(key, out _);
}
临时磁盘占用也是一个要提前做配额评估的环节。如果上传全集是 100GB,临时目录峰值就可能到 100GB。我一般建议把临时目录单独挂一块磁盘,不要跟系统盘、成品目录挤在一起,并且加一个定时任务,清理超过 24 小时未完成的批次临时文件。
6. 实测数据、现成插件对比和后续扩展
6.1 一组6483文件/9.6GB素材的实测表现
拿最近一次上线前压测的数据来收尾整个方案。
测试集是一份真实的视频剪辑工程目录:6483 个文件,总大小约 9.6GB,里面有大量几百 KB 的 JSON、脚本、缩略图,也有十几个超过 1GB 的视频素材。前端配置是:5MB 分片、并发 6、小于 5MB 的文件直接整传。
几个值得记住的数字结论:
- 小于 5MB 的文件大约 5200 个,走了整传通道;
- 超过 5MB 的文件约 1000 多个,切出大约 1200 个分片;
- 总请求数约 6400 个,而不是所有文件都分片;
- 千兆内网环境下,整个批次在十来分钟内完成,瓶颈不是带宽,而是大量小文件带来的请求往返和磁盘写入;
- 服务端临时目录峰值约 3GB(因为大文件还在分批合并,不是所有文件同时占用两倍空间)。
这个测试也验证了一件事:对大文件夹上传而言,真正的性能杀手不是“大文件”,而是“小文件数量”。文件数量一旦上万,服务端的 IOPS 和前端调度效率决定了整体体验。所以前端并发调度、后端异步落盘这两个点一定要认真处理。
6.2 成熟上传插件为什么在“保留目录”这里集体翻车
我调研过的方案不少,放个对比表方便大家决策:
| 方案 | 目录结构保留 | 大文件分片 | 断点续传 | 适用判断 |
|---|---|---|---|---|
| 自研 HTML5 组件 + ASP.NET Core | 完整支持 | 可控 | 可控 | 推荐,目录保留是硬需求时最优 |
| Plupload | 不支持 | 有但偏老 | 弱 | 停止维护,不建议新项目 |
| WebUploader | 不支持 | 有 | 有 | 停止维护,部分老项目仍在使用 |
| FilePond | 需要大量二次开发 | 插件支持 | 插件支持 | UI 漂亮,但目录保留要自己实现 |
| resumable.js | 不支持目录 | 核心能力 | 强 | 单文件上传思路,不太适合整目录 |
结论很明确:现成插件基本都把焦点放在“单个大文件”上,几乎没有把“整棵目录树”作为一等公民。与其改造它们,不如自研一个轻量组件,反正核心代码也就上文那几百行。控制在自己手里之后,无论是加哈希秒传、服务端进度推送还是对象存储分片直传,都更容易扩展。
6.3 可以继续做的三个方向
如果这个方案已经跑通,下一步可以根据业务需要做三件事:
-
服务端进度聚合:用 SignalR 把批次整体进度推给前端,这样所有用户的浏览器都能实时看到“已上传 X GB / 共 Y GB”,而不是靠前端自己统计分片。
-
对象存储分片直传:如果成品文件最终要放 MinIO 或 OSS,可以复用这套“批次 + fileIndex + chunkIndex”协议,把合并动作从应用服务器转移到对象存储的 Multipart Upload,应用服务器只记录元数据,磁盘压力会小很多。
-
严格秒传:在 Web Worker 里用 SparkMD5 全量计算文件哈希,上传前先比对哈希,服务端标记为“已存在”就直接秒传。大文件夹场景里,这个功能对重复素材多、带宽紧张的团队价值很大。
这套方案我在两个内部系统里已经落地,一个做素材归档,一个做跨团队设计稿交付,用户反馈都比较稳定。实际操作中我最大的体会是:技术上最难的不是某个单一环节,而是把“前端分片调度”和“后端分片合并”这两个环节的协议对齐。协议一旦定清楚,后面跑调优、改扩展点都会顺很多。如果你正在做类似功能,建议先把前端传参和后端 DTO 字段名一张表写清楚,再动手写代码,能省掉后面大量联调时间。
