资讯详情

C# ASP.NET Core实现网页文件夹上传:从webkitdirectory到后端落盘全指南

📅 2026/10/16 3:33:16 | 华诺云谱 👁 阅读
C# ASP.NET Core实现网页文件夹上传:从webkitdirectory到后端落盘全指南
如果你做过企业后台系统一定会遇到这种需求用户想把本地一个多级文件夹里的几十个文件一次性全部传到服务器还要保持目录结构。常规的上传控件只允许选文件用户只能一个个选要么压缩成zip再后台解压但很多业务方嫌麻烦尤其是非技术用户。之前我在做某内部工具平台时就卡在这里后来用webkitdirectory属性配合C#后端处理才真正把“选文件夹”变成了“上传文件夹”。这篇文章就是围绕“使用C#.NET框架如何实现网页上的文件夹选择并上传”来展开把前端怎么拿到文件夹里的文件、后端怎么安全地还原目录结构、大文件夹上传会遇到哪些坑一次讲清楚。适合正在做ASP.NET Core上传功能的后端开发、全栈开发者以及被这类需求折磨过的实施同学。1. 网页端选择文件夹的真实制约浏览器为什么默认不给这个能力1.1 从用户需求到技术方案的转化过程先说一个很多人没想明白的点HTML标准里其实没有一个input typefolder。文件上传控件input typefile从诞生之日起就是“选择文件”而不是“选择目录”。这是浏览器出于安全考虑做的刻意限制——如果网页能直接读取用户任意目录那恶意站点就可以扫描本地文件了。所以当产品经理提出“用户要能选择文件夹上传”时第一反应应该是这个需求本质上不是让浏览器去读用户磁盘上的任意文件夹而是让“用户主动授权”某个目录下的文件并且把这些文件的相对路径信息一并交给服务器。理解了这一点方案就清晰了前端必须要有一个“目录选择”入口浏览器把目录下的所有文件作为FileList暴露给页面同时携带每个文件相对于根目录的路径字符串。C#后端只需要把这些文件按照相对路径重新落到磁盘即可。早期的IE时代确实有过ActiveX控件做目录选择但那是Windows专属还触发一堆安全提示现在已经没人用了。现代浏览器提供的是webkitdirectory属性虽然带webkit前缀但Firefox和Edge都支持已经成为事实标准。1.2 webkitdirectory、directory和webkitRelativePath的兼容性底牌写代码之前一定要搞清楚这三个东西的关系webkitdirectory加在input typefile上的布尔属性加了这个属性打开对话框时就能选择目录选中目录后input.files里包含该目录下所有文件的File对象。directory是webkitdirectory的标准别名目前不少浏览器也接受但为了稳妥HTML里建议直接写webkitdirectory因为标准属性名在部分旧版本浏览器上识别不完整。webkitRelativePath每个File对象自带的只读属性记录这个文件相对于所选目录的路径比如选了D:\projects\demo目录里面有个src\app.cs那webkitRelativePath就是demo/src/app.cs。简单理解webkitdirectory提供“选择目录”的能力webkitRelativePath提供“目录内相对路径”的能力两者配合才能达到“选择并上传整个文件夹”的效果。兼容性方面webkitdirectory在Chrome 30、Firefox 50、Edge 79基于Chromium都可用Safari 11.1也可以。如果你需要兼容非常老的浏览器那只能退回到zip上传方案这个我在后面扩展部分会讲。在实际项目里我一般会这么判断是否支持const input document.createElement(input); input.type file; const supported webkitdirectory in input || directory in input;如果不支持就提示用户换现代浏览器或者走备用方案。2. 前端上传表单搭建让用户像选普通文件一样选整个目录2.1 一个实用的HTML模板核心就一行input typefile idfolderPicker webkitdirectory multiple /加上multiple是因为选目录后要接收多个文件虽然webkitdirectory属性本身已经隐含多选但显式写出来更保险也便于代码阅读。完整一点的页面结构可以是这样的!DOCTYPE html html langzh-CN head meta charsetUTF-8 title文件夹上传/title /head body input typefile idfolderPicker webkitdirectory multiple / button iduploadBtn disabled开始上传/button div idprogressArea/div script srcsite.js/script /body /html在site.js里关键是拿到文件列表后不要急着提交先展示给用户看一遍。因为一个文件夹可能有几十上百个文件有些可能是临时文件、缩略图缓存之类的用户需要有机会确认或过滤。我见过很多上传功能一选完目录就自动开始传结果用户发现把.DS_Store、Thumbs.db也传上去了服务端多了很多垃圾文件。2.2 读取文件夹内文件列表并过滤隐藏文件当用户选择了目录input.files返回的是一个FileList里面每个File对象都有webkitRelativePath。我们可以遍历并过滤const input document.getElementById(folderPicker); const fileList []; input.addEventListener(change, () { const files Array.from(input.files); fileList.length 0; for (const file of files) { const relativePath file.webkitRelativePath || file.name; const segments relativePath.split(/); // 过滤常见系统隐藏文件 const basename segments[segments.length - 1]; if (basename.startsWith(.) || basename Thumbs.db || basename desktop.ini) { continue; } fileList.push({ file: file, relativePath: relativePath, size: file.size }); } console.log(待上传文件数量, fileList.length); document.getElementById(uploadBtn).disabled fileList.length 0; });这里一个容易被忽略的细节webkitRelativePath的分隔符在浏览器里统一是/但到了Windows服务端Path.Combine用的是\所以我们需要在JS侧就把路径分隔符处理好或者在后端做统一转换。我习惯在后端统一转因为前端用户可能传\或/后端必须兼容。2.3 拖拽上传文件夹的另一种入口除了点击选择产品往往还会顺手要求“支持拖拽上传”。拖拽文件夹和拖拽文件的处理逻辑不太一样。如果是拖拽文件夹到页面DataTransferItem里有一个webkitGetAsEntry方法可以获取目录条目然后递归遍历const dropZone document.getElementById(dropZone); dropZone.addEventListener(dragover, (e) { e.preventDefault(); }); dropZone.addEventListener(drop, async (e) { e.preventDefault(); const items Array.from(e.dataTransfer.items); const collected []; async function traverseEntry(entry, rootPath ) { if (entry.isFile) { const file await new Promise((resolve, reject) entry.file(resolve, reject)); // 这里需要手动拼接相对路径 Object.defineProperty(file, webkitRelativePath, { value: rootPath file.name }); collected.push(file); } else if (entry.isDirectory) { const reader entry.createReader(); const entries await new Promise((resolve) { const allEntries []; function readBatch() { reader.readEntries((batch) { if (batch.length 0) { resolve(allEntries); } else { allEntries.push(...batch); readBatch(); } }); } readBatch(); }); for (const childEntry of entries) { await traverseEntry(childEntry, rootPath entry.name /); } } } for (const item of items) { const entry item.webkitGetAsEntry(); if (entry) { await traverseEntry(entry); } } // 此时collected里就是带完整相对路径的File对象 console.log(collected); });这段代码稍微复杂一点原因是webkitGetAsEntry不能直接拿到最终文件的完整路径需要自己递归拼接。但好处是拖拽体验确实比点选目录更顺滑用户从文件管理器里直接把文件夹甩到页面上就可以了。如果你的项目对拖拽要求不高可以只做点击选择拖拽留给后续迭代。3. C#后端接收与还原目录结构最关键的服务端设计3.1 ASP.NET Core模型绑定接收List 与根路径参数前端把文件一个个追加到FormData里每个文件的filename用webkitRelativePath这样服务端拿到的IFormFile.FileName就会是相对路径。前端核心代码如下async function uploadFiles() { const formData new FormData(); for (const item of fileList) { formData.append(files, item.file, item.relativePath); } formData.append(rootPath, uploaded); const response await fetch(/api/upload-folder, { method: POST, body: formData }); const result await response.json(); console.log(result); }注意formData.append(files, item.file, item.relativePath);的第三个参数就是文件名这里传完整相对路径。有的开发者会把相对路径放在另一个字段里然后自己维护文件名和路径的映射那样容易出乱子尤其是文件数量多时对不齐。C#端的Action定义[HttpPost(api/upload-folder)] public async TaskIActionResult UploadFolder( [FromForm] ListIFormFile files, [FromForm] string rootPath uploaded) { if (files null || files.Count 0) { return BadRequest(没有收到文件); } var savedCount 0; foreach (var file in files) { // 后面会补全路径校验和保存逻辑 } return Ok(new { savedCount, total files.Count }); }这里有一个经典坑ASP.NET Core的模型绑定在处理ListIFormFile时要求前端每个文件的字段名都叫files并且后端参数名和表单字段名必须一致。如果前端某处写成了fileList后端就绑定不到返回的文件数为0。3.2 路径安全检查防止目录穿越与非法路径这是整个功能里最重要、也最容易被忽略的一步。前端给的relativePath本质上是用户可控输入如果直接拼到服务器路径里就可以构造出../../../etc/passwd这类目录穿越攻击路径。我封装了一个安全合并方法private string SafeCombine(string rootPath, string relativePath) { // 先统一分隔符 relativePath relativePath.Replace(\\, /); // 去掉文件名的路径前缀只保留文件部分相对子目录 var normalized relativePath.Trim().TrimStart(/); // 检查是否包含非法路径字符 if (normalized.Contains(..) || normalized.IndexOfAny(Path.GetInvalidPathChars()) 0) { throw new InvalidOperationException(非法的相对路径); } var rootFull Path.GetFullPath(rootPath); var targetFull Path.GetFullPath(Path.Combine(rootFull, normalized)); // 确保目标路径依然在root路径内 if (!targetFull.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException(路径越界); } return targetFull; }几个细节说明一下检查..时不要只检查是否包含..字符串因为合法文件名里也可能有ab..c.txt。更严谨的做法是先Path.GetFullPath再判断前缀我在代码里同时做了起到双重保险。用StartsWith判断时要带上StringComparison.OrdinalIgnoreCaseWindows路径不区分大小写。Path.GetInvalidPathChars()只能过滤路径字符文件名里还可能包含Path.GetInvalidFileNameChars()比如、、:等保存前对文件名部分再过滤一次。一个相对路径可能会长这样myfolder/subdir/my(1).txt包含括号和空格这些是合法的不要误杀。3.3 落盘保存如何按相对路径逐级创建目录并写入文件拿到安全的目标路径后保存逻辑不复杂但要注意别用同步方法。ASP.NET Core里FileStream读写用异步更合适[HttpPost(api/upload-folder)] public async TaskIActionResult UploadFolder( [FromForm] ListIFormFile files, [FromForm] string rootPath uploaded) { if (files null || files.Count 0) { return BadRequest(没有收到文件); } var baseDir Path.Combine(Directory.GetCurrentDirectory(), rootPath); Directory.CreateDirectory(baseDir); var savedCount 0; var errors new Liststring(); foreach (var file in files) { try { var safePath SafeCombine(baseDir, file.FileName); var fileDir Path.GetDirectoryName(safePath); if (string.IsNullOrEmpty(fileDir)) { fileDir baseDir; } Directory.CreateDirectory(fileDir); await using (var stream new FileStream(safePath, FileMode.Create)) { await file.CopyToAsync(stream); } savedCount; } catch (Exception ex) { errors.Add(${file.FileName}{ex.Message}); } } return Ok(new { savedCount, total files.Count, errors }); }这段代码里最有价值的是错误收集逻辑。上传文件夹往往不是一次请求里只有一个文件而是几十上百个文件如果其中一个文件保存失败你不能把整个请求都回滚掉那样前面成功保存的文件也得跟着遭殃。更合理的做法是单个文件失败不影响其余文件最后返回失败列表让前端提示用户重传个别文件。当然如果你的业务要求原子性——要么全部成功要么全部失败——那就需要先保存到临时目录全部成功后再统一移动过去。这个取舍要看具体场景。3.4 保存过程中的异常处理与原子性关于原子性我额外提一个思路先把所有文件写入一个临时目录例如uploaded/tmp/{guid}/全部写完并校验数量一致后再移动到正式目录。var tempRoot Path.Combine(baseDir, tmp, Guid.NewGuid().ToString()); Directory.CreateDirectory(tempRoot); // 保存到tempRoot // 校验savedCount files.Count // 最后把tempRoot下的内容Move到正式目录这样即使中途崩溃也不会在正式目录留下半截文件。但要注意跨磁盘移动的问题如果临时目录和正式目录不在同一卷Directory.Move会失败需要改用递归复制再删除。我的建议是都放在站点根目录下一般不会跨卷。对于中小型文件两步式方案的内存压力也不大。如果是超大文件直接流式写入正式目录更实在因为临时目录同样要占一份磁盘空间和时间。4. 大文件夹上传的体验优化进度反馈、并发控制与服务端参数4.1 为什么一次打包上传文件夹不是明智选择很多人第一反应是前端把文件夹压缩成zip再上传后端解压。这种做法看起来简单但有个致命问题压缩需要时间大文件夹压缩期间用户只能干等后端解压还要面临zip炸弹压缩包内压缩率极高导致解压膨胀风险而且如果解压出恶意文件路径处理起来更麻烦。更关键的是压缩上传无法做到“边传边释放”的流式体验。文件一旦打包整个zip就是一个整体上传过程中任何一个字节出错都会导致整个包重传。而逐个文件上传则灵活很多单个文件失败可以自动重试不影响其他文件。所以逐文件上传更合适同时也要做一些优化。4.2 前端分批提交与进度条实现一次把所有文件都塞进一个FormData发出去文件少还行文件多了浏览器构建FormData会卡顿而且服务端内存压力巨大。分批提交是标准做法const batchSize 20; async function uploadInBatches() { const total fileList.length; let uploaded 0; const task document.getElementById(progressArea); for (let i 0; i total; i batchSize) { const batch fileList.slice(i, i batchSize); const formData new FormData(); for (const item of batch) { formData.append(files, item.file, item.relativePath); } formData.append(rootPath, uploaded); const response await fetch(/api/upload-folder, { method: POST, body: formData }); if (!response.ok) { throw new Error(批量${i}上传失败); } uploaded batch.length; task.textContent 已上传 ${uploaded}/${total}; } }这里用fetch的缺点是拿不到文件级别的上传进度只能拿到批次上传完成后的状态。想要实时进度条得用XMLHttpRequestfunction uploadBatch(formData) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload-folder); xhr.upload.addEventListener(progress, (e) { if (e.lengthComputable) { const percent Math.round((e.loaded / e.total) * 100); task.textContent 上传中${percent}%; } }); xhr.addEventListener(load, () { if (xhr.status 200 xhr.status 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error(xhr.statusText)); } }); xhr.addEventListener(error, () reject(new Error(网络错误))); xhr.send(formData); }); }注意xhr.upload.addEventListener监听的是上传阶段进度不是整个请求的进度。如果你需要一个总进度条就拿当前批次的加权值来算let totalLoaded 0; let totalSize 0; for (const item of fileList) totalSize item.file.size; // 在uploadBatch内部的progress回调里 totalLoaded previousLoaded e.loaded; totalPercent Math.round((totalLoaded / totalSize) * 100);这个逻辑简单但有效。需要你自己维护previousLoaded。4.3 服务端请求体大小限制与超时配置当你把文件分批后单个请求的体积就小了但服务端还是会有一个默认的请求体大小限制。ASP.NET Core里Kestrel的默认上限是30,000,000字节约28.6MB超过会直接返回413 Payload Too Large。配置Kestrelbuilder.WebHost.ConfigureKestrel(options { options.Limits.MaxRequestBodySize 1024 * 1024 * 200; // 200MB });如果部署在IIS后面还要设置web.config里的maxAllowedContentLength默认是30000000字节也要调大system.webServer security requestFiltering requestLimits maxAllowedContentLength209715200 / /requestFiltering /security /system.webServer超时设置也值得关注。默认Kestrel有一个MinRequestBodyDataRate如果客户端长时间不发送数据连接会被断开。上传大文件时如果用户网速慢很容易触发。对于内网工具直接把速率限制关掉或调低options.Limits.MinRequestBodyDataRate null;这个配置理解起来很简单null表示不限制最小传输速率避免慢速上传被中途掐断。但公网生产环境建议保留速率限制防止恶意占用连接。5. 实测中常见的坑及排查思路5.1 Request Entity Too Large看到413先查这两处有次测试同事反馈上传一个包含视频的文件夹直接失败浏览器控制台显示Failed to load resource: the server responded with a status of 413 (Request Entity Too Large)。排查链路是这样的先看是不是Kestrel限制。可以在启动日志里看有没有相关异常或直接临时把MaxRequestBodySize设成很大的值测试。如果项目托管在IIS再看web.config里的maxAllowedContentLength。还有一层是ASP.NET Core的[RequestSizeLimit]特性如果你在某个Controller或Action上打了这个特性也会覆盖全局配置。后来发现我们项目在Controller类上加了[RequestSizeLimit(10 * 1024 * 1024)]这是某次调试留下的忘了移除导致所有上传都不能超过10MB。移除后恢复正常。排查这类问题最快的办法是看响应头里的Server字段如果是Kestrel那就是Kestrel限制如果是Microsoft-IIS/10.0那更可能是IIS限制。这能帮你快速缩小范围。5.2 中文文件名乱码编码链路中的关键环节文件夹里经常有中文命名的文件比如需求文档.docx。上传后保存在服务器上变成求汇文档.docx这就是典型的编码问题。根因在客户端FormData中的文件名默认以UTF-8编码但服务端在解析multipart头时某些中间件会把文件名编码按ISO-8859-1解码。ASP.NET Core通常不会出错但如果你用了System.Net.Http或某些代理组件就可能在中间层被转坏。我的排查步骤是先用file.FileName直接打印看看是否乱码。如果乱码尝试手动转换var bytes Encoding.Latin1.GetBytes(file.FileName); var originalName Encoding.UTF8.GetString(bytes);这招在MultipartReader场景里经常有效。但更稳妥的做法是前端不要依赖FormData的文件名解析而是在表单里单独追加一个字段formData.append(fileNames, encodeURIComponent(item.relativePath));后端再用Uri.UnescapeDataString取回来var safeName Uri.UnescapeDataString(Request.Form[fileNames][i]);这样跳过了multipart文件名编码的坑代价是需要在处理时建立file.FileName和fileNames字段的对应关系。如果你不需要支持非标准浏览器直接用file.Name也可以。还有一个容易忽略的点保存文件后文件名的字节序列可能没问题但网页前端从服务器列表接口读数据时如果数据库或JSON没有正确声明charsetutf-8也会显示乱码。这属于另一层问题但经常被归到一个bug里。5.3 目录权限与路径过长引发的保存失败Windows服务器上如果站点池身份没有对你设置的uploaded目录的写权限Directory.CreateDirectory和FileStream会抛UnauthorizedAccessException。我在排查时喜欢用一个空文件测试curl -F filestest.txt;filenametest.txt http://localhost:5000/api/upload-folder如果连这个都失败那就是权限问题。通常给站点应用池用户加上对物理目录的Modify权限即可。路径过长是另一个隐蔽问题。Windows默认路径最大260个字符如果文件夹嵌套层级很深比如a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p.txt加上服务器物理路径前缀很容易超过限制。解决方案有两个方向把保存根路径设置得短一点比如直接用C:\uploads\而不是C:\inetpub\wwwroot\myapp\wwwroot\uploads\。在.NET中启用长路径支持在项目文件csproj里加PropertyGroup LongPathsEnabledtrue/LongPathsEnabled /PropertyGroup但启用长路径要求操作系统本身也开启了相应组策略不是所有服务器都支持。最保险的做法还是后端自动检测路径长度超长时用哈希替代部分目录名或者把深层目录结构折叠成扁平加批次号。对于一些内部工具来说保留完整目录树不是刚需扁平化存储反而更好做检索。6. 从文件夹上传到断点续传这套方案还能往哪个方向扩展6.1 思路一把二进制流交给对象存储上传文件夹功能上线后很快会遇到新的需求服务器磁盘空间不够或者需要做多节点共享。这时候直接把文件写到本地磁盘就不太够用更合理的做法是前端拿到文件后仍按相对路径逐个上传但后端不再做本地落盘而是转发到云存储。实现上并不复杂C#里可以用官方SDK的PutObjectAsync把IFormFile.OpenReadStream()直接流式推送。关键是路径要保持一致云存储的key就是相对路径。原来的SafeCombine校验改一下合成一个SDK规定的key字符串禁止..和非法字符即可。这样做还有个好处上传进度可以有效聚合因为云存储SDK通常自带进度回调还可以利用其分片上传能力天然支持大文件。比自己在服务器上写文件再同步到云要省事得多。6.2 思路二与后台压缩任务结合另一个常见的扩展是把上传上来的文件夹自动打成zip包给用户下载。这一步可以不做成即时压缩而是上传完成后的后台任务。后端可以在上传完成后把文件列表写入一个任务队列比如内存的ChannelT或者数据库待处理表后台Worker负责遍历目录并压缩。保证压缩过程不阻塞上传响应用户前端看到“上传成功”后立即有反馈后台再慢慢生成下载链接。这里有个经验如果文件夹很大压缩任务要做进度记录否则用户等了两小时还不知道进度体验就很差。此外文件夹上传还可以和删除、重命名、增量覆盖等功能联动。本质上你只要在服务器上维护好“根目录 相对路径”这套结构文件数再多也能像操作本地目录一样去管理。我自己在项目里把这套方案跑通之后最大的体会是文件夹上传并不神秘核心就两件半事。第一件前端利用webkitdirectory拿到文件和相对路径第二件后端安全地按相对路径落盘剩下半件是批量、进度和异常处理。把这几个点想清楚后续不管接云存储还是做断点续传都是水到渠成的事。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑