大文件与目录结构上传的完整方案:分片、并发调度与服务端重建

发布时间:2026/9/9 9:36:43
大文件与目录结构上传的完整方案:分片、并发调度与服务端重建 先说结论Web端做“大文件 目录结构”上传真正麻烦的不是大文件本身而是“目录结构”这四个字。单个大文件我们早就有成熟的分片校验方案但目录一进来你就得面对递归遍历、层级记录、海量小文件并发、服务端目录重建这一连串和“结构”强相关的问题。这篇文章我把之前在多个项目里反复踩过的坑、验证过的方案完整梳理一遍从浏览器端怎么拿目录、怎么分片、怎么并发到服务端怎么合并、怎么重建目录全部按可落地的标准来讲适合正在做上传功能或者打算重构上传模块的前端、全栈同学参考。1. 整体设计与思路拆解先把目录结构上传这个需求拆开看它其实包含三个核心子问题如何拿到完整的目录树、如何高效传输这些文件、如何在服务端还原目录。第一个问题相对好办现代浏览器提供了两个入口input webkitdirectory和window.showDirectoryPicker()。前者兼容性最好后者属于 File System Access API能拿到目录句柄但生态限制比较明显。传输问题才是大头几百个小文件和几个大文件混在一个目录里你不能用同一套策略处理小文件走普通 multipart大文件必须分片而要把这些上传行为统一调度就得有一个任务队列。服务端还原目录是另一个容易翻车的地方。很多方案是在前端把相对路径拼到文件名里上传服务端收到后按路径创建目录。这个思路本身没问题但各种细节比如路径分隔符处理、非法字符、Windows 和 Linux 兼容性、并发创建目录的竞态都能折腾出事故来。这篇文章的核心思路就是统一用一个“上传任务描述结构”来驱动整个上传流程前后端都围绕这个结构工作而不是靠零散的无脑逻辑拼凑。实际项目中我一般把整体方案拆成下面几个模块把职责拆清代码才可能写得干净收集模块 - 前置处理模块 - 调度模块 - 传输模块 - 服务端落盘模块每个模块的职责边界非常明确模块职责关键点收集模块读取目录结构汇总文件列表递归遍历、相对路径生成前置处理模块计算哈希、判断是否秒传大文件哈希建议用 Web Worker调度模块分配上传任务、启停并发并发数控制、优先级控制传输模块实际发送数据分片、FormData、进度回传服务端落盘模块接收数据、合并分片、重建目录原子性、临时文件清理想清楚这些模块再写代码后面维护的时候会舒服得多。没有规划就直接开写等目录层级第五层、文件数量上千的时候前端卡死就是必然结果。1.1 目录结构上传 vs 普通多文件上传很多人会把“目录上传”和“多文件上传”混为一谈这是第一个需要澄清的认知偏差。多文件上传时前端拿到的就是一个FileList每个文件是独立的没有父子关系。目录上传则不同你拿到的是一棵树文件节点散布在不同层级的目录节点之下。这两者的差异会直接传导到服务端落盘逻辑。多文件上传服务端只需要循环保存目录上传则必须让“文件落盘”和“目录生成”保持顺序或做到幂等。举一个现实场景用户选了一个src/components/common/Button.tsx如果前端传的是一个纯文件名Button.tsx服务端就只能把所有深度不同的Button.tsx打到一个目录里文件互相覆盖这显然不行。所以前端必须在收集阶段就把相对路径算对后端才能无损还原。目录上传的另一个隐性问题是文件数量。一个中大型前端项目目录node_modules算不上随便一个 src 目录就有上千个文件。如果用户把整个工程文件夹拖进来文件数可能瞬间到几万个这时候连前端 UI 列表的渲染策略都要重新设计不能用几万个 DOM 节点硬刚。1.2 为什么大文件和目录要组合考虑单文件大或者单目录文件多单独处理都不算太难。但目录里同时包含几十MB的大文件和几十KB的小文件以及几千个文件时情况就完全不同了。主要有三个矛盾点分片粒度无法统一给每个小文件也做1MB分片会白白增加请求数量和合并压力传输效率反而下降。必须对文件大小做分类。并发调度需要综合平衡一个大文件切了100个分片如果只看分片数量去分配并发小文件可能永远排在后面导致关键节点迟迟传不完。失败重试的粒度不同小文件失败可以整文件重传大文件失败却要从失败分片续传重试策略必须能区分两种场景。所以正确的做法是先按文件大小分层再按目录结构建立统一的任务描述。比如小于10MB的文件走整传大于10MB的进入分片逻辑每个任务都有自己的状态和优先级调度模块按队列来跑。这样大文件不堵车、小文件不孤单目录层级还能保持。2. 前端目录读取与文件收集目录收集是整个流程的地基这个环节做不好后面全白搭。我先讲两种读取方式再把相对路径生成和数据结构设计的细节铺开。2.1 目录读取的两种主流方式第一种是input typefile webkitdirectory。这个属性从 Chrome 11 就有了兼容性极广结构也简单input typefile webkitdirectory iddirPicker /dirPicker.addEventListener(change, (e) { const files Array.from(e.target.files); // files[i].webkitRelativePath 就是相对路径比如 src/components/Button.tsx });webkitRelativePath是这里的主角它直接返回目录内文件的相对路径前端不用自己拼非常省事。缺点是它只存在于基于 WebKit 的浏览器Chrome、Edge、OperaFirefox 早期版本里会有兼容问题新版本也逐渐对齐了这个行为。Safari 的部分老版本对webkitRelativePath的支持是“可能拿不到完整路径”这个要格外留神。第二种是 File System Access API 的showDirectoryPicker()const handle await window.showDirectoryPicker(); const rootHandle handle; async function walk(dirHandle, basePath ) { const results []; for await (const entry of dirHandle.values()) { if (entry.kind file) { const file await entry.getFile(); results.push({ name: entry.name, path: basePath ? ${basePath}/${entry.name} : entry.name, size: file.size, file }); } else if (entry.kind directory) { const subDir await entry.values ? entry : entry; // 注意递归 const children await walk(entry, ${basePath}/${entry.name}); results.push(...children); } } return results; }这种方式能拿到FileSystemFileHandle手里有真正的“句柄”可以做后续的流式读取在文件夹数据量极大时表现更好。但兼容性问题是硬伤目前只在 Chromium 系浏览器里可用Safari 和 Firefox 都还在路上。实操建议是优先用webkitdirectory兜底检测到window.showDirectoryPicker存在时再给它一个增强入口。对绝大多数后台管理系统来说webkitdirectory已经够用showDirectoryPicker更多是给需要句柄做增量更新的场景准备的。2.2 相对路径的生成与递归细节用webkitRelativePath时路径自动就有但有些场景比如用户从拖拽区直接拖了一个文件夹进来DataTransferItem此时webkitGetAsEntry()拿到的是FileSystemEntry你得自己递归拼路径。递归时的输出结构我强烈建议不要用一个扁平的数组直接开干保留一个树形结构会大大方便后续 UI 展示。举一个我在项目中用得很顺的数据结构{ name: my-project, type: directory, path: , children: [ { name: src, type: directory, path: src, children: [ { name: index.ts, type: file, path: src/index.ts, size: 128, file: File对象 } ] } ] }这里的关键点是path字段。注意我不建议直接用\因为服务端可能是 Linux也建议路径中不要带首尾斜杠。统一用/作为分隔符并且把path存成“相对根的相对路径”服务端拿到它可以直接path.join(baseDir, relativePath)。递归读取几万个文件的另一个隐患是一次性把所有文件都读取出来内存占用会非常恐怖。这里的“文件”其实只是 File 对象引用数据本体还没读但 File 对象本身、路径字符串、树节点的对象引用也是不小的开销。如果目录特别大可以考虑按目录节点惰性读取但大多数 CDN 管理后台场景一次读完完全可接受。真遇到百万级文件的需求前端根本不该承担这个量级应该让用户在服务端解压或同步。2.3 拖拽上传时的目录识别拖拽上传是 Web 端标配能力尤其在做工程化管理后台时用户习惯直接把整个项目文件夹拖进来。用DataTransferItem可以拿到条目dropZone.addEventListener(dragover, (e) e.preventDefault()); dropZone.addEventListener(drop, async (e) { e.preventDefault(); const items Array.from(e.dataTransfer.items); const entries items.map(item item.webkitGetAsEntry()).filter(Boolean); // 对每个 entry 做递归解析 });注意拖拽目录和选择目录有一个体验差异拖拽进来时目录的最外层名字是用户本地文件夹的名字这个外层名字到底要不要作为顶层目录需要给用户一个选项。我习惯默认“保留顶层目录名”因为大多数用户拖文件夹进来是期望完整还原的。如果用户只是想取里面的某些子目录他可以先拖子目录这个交互逻辑由前端自行控制即可。这里还有一个易错点webkitGetAsEntry拿到的 entry 上也有isFile、isDirectory属性但只有createReader()这个方法能异步读取目录内容且一次最多读 100 条需要循环readEntries直到返回空数组function readAllEntries(reader) { return new Promise((resolve) { const all []; function read() { reader.readEntries((entries) { if (entries.length 0) { resolve(all); } else { all.push(...entries); read(); } }); } read(); }); }这个细节特别重要官方不让一次返回全部是有底层设计考量的不写递归循环就会漏文件还是那种隐蔽的漏。3. 大文件分片与哈希计算的优化方案目录问题解决之后回到大文件。分片本身很简单Blob.prototype.slice()就好了。真正的难点在分片前要不要计算哈希怎么算才不卡死页面。3.1 分片策略与大小选择分片大小不是拍脑袋定的它主要取决于两个因素网络质量和服务端限制。常规建议是 1MB 到 10MB 之间具体怎么选我一般按下面逻辑企业内网或网络质量很好分片可以设成 5MB 或 10MB减少请求次数减轻服务端合并压力。弱网场景移动办公、跨地区分片设成 1MB 或 2MB失败重传的粒度更细体感更好。服务端如果限制了请求体大小比如 Nginxclient_max_body_size设置成 100m分片必须远小于这个限制一般取 1/10 到 1/4 都比较安全。实现分片逻辑要注意最后一个分片的大小不一定等于 chunkSize循环切分时需要取Math.min(chunkSize, file.size - start)。同时File 对象本身可能是大文件切出来的每个 Blob 都是一个独立的数据视图它们之间的数据是共享底层文件句柄的不会真的复制整个文件内容到内存里这点不用担心。3.2 哈希计算为什么要用 Web Worker计算整文件哈希是为了实现“秒传”。用户把同一个文件再传一遍时服务端比对哈希直接返回“已存在”省下成百上千兆的流量和时间这对重试场景是巨大的体验提升。但问题来了一个 2GB 的文件用crypto.subtle.digest循环切片计算 SHA-1在主线程上跑页面基本会卡成幻灯片因为切片、读取、计算都是 CPU 密集且异步回调密集的操作。此时 Web Worker 是唯一正确的解法。我在项目中一般用spark-md5这个库它在 Worker 里用增量方式计算// worker.js importScripts(spark-md5.min.js); self.onmessage (e) { const { file, chunkSize } e.data; const spark new SparkMD5.ArrayBuffer(); let currentChunk 0; const chunks Math.ceil(file.size / chunkSize); const reader new FileReader(); function readNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); reader.readAsArrayBuffer(file.slice(start, end)); } reader.onload (e2) { spark.append(e2.target.result); currentChunk; self.postMessage({ type: progress, percent: currentChunk / chunks }); if (currentChunk chunks) { readNext(); } else { self.postMessage({ type: done, hash: spark.end() }); } }; reader.onerror (err) self.postMessage({ type: error, error: err.message }); readNext(); };这里的关键是readAsArrayBuffer它会把整个分片读进内存所以一次只读一个分片不要让多个分片同时在内存里堆积Worker 内存就会保持平稳。还有一种更高效的方案File.prototype.stream()配合 TransformStream可以在不整体读入数组缓冲的情况下边读边哈希。但ReadableStream在 Worker 里的兼容性要提前验证如果你的目标平台是 Chromium 系可以尝试否则还是 FileReader 稳。3.3 秒传与断点续传的前置查询哈希算出来以后流程是先调服务端接口查询这个哈希是否存在存在就直接秒传不存在再询问“这个文件之前传过哪些分片”拿到已传分片列表前端跳过这些分片。这一步能用上multipart/form-data吗答案是不能。哈希查询和分片位置查询都属于“控制面”请求用普通 JSON 接口更合适。我在项目里一般是GET /api/file/status?hashxxxsizexxx响应格式{ exist: false, uploadedChunks: [1, 2, 3, 7, 8], chunkSize: 5242880 }前端拿到uploadedChunks初始当前分片索引集合时就跳过这些已有的分片。这样刷新页面、断网、手动暂停都是同一套续传逻辑不会引入新的复杂度。可以理解为所有上传状态都收敛在“已传分片列表”这一个数据源上。4. 并发上传调度与性能优化文件收集完了哈希算完了接下来就是真正往服务端推数据的过程。这里想要又快又稳核心就是控制并发、避免内存和网络风暴、失败自动重试。4.1 并发数的选择和动态调节并发数是一个经典的权衡问题。并发太低大文件上传慢用户发火并发太高浏览器把上千个请求同时发出去轻则网络混乱重则服务端直接被压垮还可能触发浏览器对同域名的连接数限制HTTP/1.1 下每个域名最多6个连接。我的经验值浏览器端上传并发数控制为3 到 6比较合理。如果服务端是普通的单机应用我习惯定为 3如果服务端走的是 OSS 这类高可用存储可以放宽到 6。不要盲目调高并发高并不代表总吞吐一定高反而因为分片变小导致 TCP 拥塞控制无法有效利用带宽。动态调节是进阶玩法。利用navigator.connection.downlink可以估算当前下行带宽但在上传场景中参考意义有限因为上传走的是上行带宽而且这个 API 并不返回上行指标。更实用的方案是用“每轮完成时间”去做自适应统计最近 10 个分片的平均耗时如果耗时减少就尝试增加一个并发如果耗时增加就减少一个并发。简单说像 TCP 拥塞窗口那样做“慢启动”实测在弱网环境下效果很明显。4.2 用 Promise 封装一个通用并发池不管你是用 axios 还是 fetch底层都建议封装一个并发调度器。这个调度器决定了你能多优雅地实现暂停、恢复和任务优先级。下面这个是我常用的小而美的版本class UploadScheduler { constructor(limit 3) { this.limit limit; this.queue []; this.running 0; this.paused false; } add(task, priority 0) { return new Promise((resolve, reject) { this.queue.push({ task, resolve, reject, priority }); this.queue.sort((a, b) b.priority - a.priority); this.next(); }); } next() { if (this.paused || this.running this.limit || this.queue.length 0) return; this.running; const { task, resolve, reject } this.queue.shift(); Promise.resolve(task()) .then(resolve) .catch(reject) .finally(() { this.running--; this.next(); }); } pause() { this.paused true; } resume() { this.paused false; for (let i 0; i this.limit; i) this.next(); } }这个调度器的好处是任务本身是 Promise你可以随时往队列里加任务通过priority字段可以做到小文件优先、目录浅层优先、当前可视区域优先等各种策略暂停的时候已经在飞行中的请求不会被取消但新的请求不会发出配合AbortController才能做到真正全链路暂停。在并发池里还要给每个分片配上“重试次数”的概念。我一般用retryCount字段标记请求失败时如果重试次数没到上限就重回队列超过上限就把错误上报并把整个任务标记为失败由用户决定是重试还是放弃。网络瞬时抖动导致的失败是很正常的别一失败就把整个上传终止这种情况是最让用户抓狂的。4.3 小文件多目录场景的性能优化目录上传场景里文件数量多但单个文件小这种“小而多”的情况和“大而少”完全不同。此时分片已经没意义真正的性能瓶颈在HTTP 连接建立和断开的开销FormData 序列化的开销UI 列表更新导致的渲染开销对于小文件我建议直接整文件上传不要切片。并且尽量复用 HTTP 连接开启keep-alive。但这还不够如果小文件数量到了一两千一个文件一个请求就算并发 6排队时间也让人崩溃。更激进但有效的办法是“小文件打包”前端把同一目录下的一批小文件合并成一个包服务端解包后按原始相对路径落盘。打包格式用简单的二进制封包或 ZIP 都可以但要求服务端配合复杂度会上升。如果不想引入打包复杂度优先在 UI 层下功夫。文件列表用虚拟滚动只渲染可视区域进度信息通过“目录面板聚合”展示不用每个文件都刷新 DOM。我见过很多系统文件一多就卡不是上传卡而是 DOM 渲染直接卡死这一点必须提前设计。4.4 大文件分片并发时进度如何精确计算进度计算看上去简单已传大小除以总大小。但把目录、大文件、小文件混在一起时就得定一个统一的计算口径。我的做法是基于“文件占比权重”而不是“已传分片数”。为什么因为大文件和小文件的体量差异太大如果按分片数量平均计算传完1000个小文件可能只占了“50%”的分片数但数据量只有 50MB而大文件 100 个分片就占了 50%数据量却有 500MB。这个进度明显失实。所以进度公式是整体进度 Σ(每个文件已传字节数) / Σ(每个文件总字节数)即使某文件还没开始它的总字节数也要计入分母。这样用户看到的上传百分比和实际网络吞吐就是相符的不会被“分片数量”欺骗。5. 目录结构还原与断点续传的实践前端部分讲得差不多了这一章重点讲服务端如何处理分片、如何还原目录以及断点续传的完整闭环。5.1 服务端分片合并与临时文件管理分片上传的服务端逻辑一个最稳的模型是每个文件的每个分片都先落到临时目录等所有分片到齐后再合并。临时目录的命名必须能唯一标识一次上传会话我一般用fileId userId 时间戳来拼防止不同用户、不同时刻上传相同文件时数据互相污染。Node.js 服务端示例Express 风格中一个分片上传接口大致长这样app.post(/api/upload/chunk, async (req, res) { const { fileId, chunkIndex, totalChunks, relativePath } req.body; const file req.file; // 来自 multer 等中间件 const tempDir path.join(UPLOAD_DIR, fileId); await fsp.mkdir(tempDir, { recursive: true }); const chunkPath path.join(tempDir, ${chunkIndex}.part); await fsp.rename(file.path, chunkPath); // 注意这里要定期清理残留临时文件 res.json({ ok: true }); });合并分片时有一个容易踩坑的问题直接读所有.part文件名再按名字排序如果分片数超过 10字符串排序就会出错10.part排在2.part前面。所以文件名必须用“补零后的索引”或直接记录在元数据里并用数字排序。合并完成后要立刻清理临时目录。如果中途合并失败最好把已合并的部分也删除避免半成品被误当成完整文件使用。同时建议组件一个定时任务把超过 24 小时还没完成合并的临时目录清理掉防止恶意上传把磁盘填满。5.2 目录结构在服务端如何重建目录重建的输入就是前端传的relativePath。我在设计时会把每个文件的相对路径放在multipart/form-data的自定义字段里服务端落盘时这样处理const safePath path.normalize(relativePath).replace(/^(\.\.(\/|\\|$))/, ); const absolutePath path.join(ROOT_UPLOAD_DIR, safePath); await fsp.mkdir(path.dirname(absolutePath), { recursive: true }); await fsp.rename(tempFilePath, absolutePath);这里有几个安全细节必须注意必须做path.normalize和路径穿越防护防止../../../etc/passwd这类恶意路径。必须让最终绝对路径始终在指定的根目录内一旦path.resolve(absolutePath)不等于path.join(ROOT_UPLOAD_DIR, ...)的预期结果就直接拒绝。文件重名时要有明确的覆盖策略。我默认是“服务端追加时间戳后缀”避免直接覆盖用户已有文件。服务端目录数量很多时每次mkdir都递归调用可能会造成性能问题。实际上mkdir({ recursive: true })在路径已存在时开销不大但如果是非常多层的目录可以考虑在内存里缓存一次“已创建目录集合”减少重复mkdir系统调用。这块优化对 8000 个小文件分散在 500 个目录里的场景有明显帮助。5.3 断点续传的完整闭环断点续传之所以能实现完全依赖于前面提到的“文件状态查询”能够返回已传分片列表。前端逻辑是计算哈希查询文件状态。如果存在直接秒传。如果不存在获取已传分片列表。并发上传缺失分片。全部完成后通知服务端合并文件并重建目录。这里面有一个隐藏问题如果目录里有 50 个文件其中 20 个已经传完了另外 30 个是新文件或只传了一半。前端重试时不能又重新把所有文件传一遍而是要先对目录内每个文件做状态查询得到每个文件的“已完成维度”再把未完成的文件加入队列。这个“目录级续传”才是真正符合用户心智的也是我做上传体验优化的得意之处。另外断点续传的“断点”不只是在网络断开时触发用户手动暂停也必须支持。暂停时调度器把未开始的任务丢回队列但不再执行在途请求通过AbortController取消已传分片的状态已经在服务端记录了。这样恢复时只需重新走状态查询不需要重新上传任何已完成的部分这个闭环逻辑稳定可靠。5.4 秒传的边界场景秒传能成立的前提是哈希碰撞极低且服务端信任哈希结果。但有个边界场景必须考虑两个不同的文件碰巧哈希一致虽然概率很低或者文件确实一致但大小不同。为了让秒传更可靠我一般会把哈希 文件大小联合作为唯一标识两个条件都满足才秒传。这样就规避了绝大部分碰撞场景也避免用户传了一个恰好同哈希的不同内容文件。另外一个常见坑是秒传接口可以拿到文件已存在但目录里其他文件还没传完。此时秒传只对当前文件生效不能把整个目录标记为完成。目录完成的判定条件只有一个——目录内所有文件都已经达到“已上传”状态。6. 常见问题与排查技巧实录最后把我在实际项目里遇到的典型问题整理成速查表。这些问题在原理上都不算复杂但每一个都曾经在线上坑过我写出来让大家少走弯路。问题现象根本原因解决方法上传几千个文件时页面卡死DOM 渲染过多或 JS 主线程被大对象操作阻塞列表虚拟滚动文件解析与哈希计算搬到 Worker文件传了几秒后连接被断开服务端网关超时时间太短调大超时时间或改用分片 断点续传合并后的文件无法打开分片合并顺序错误检查 sort 是否用了数字排序而不是字符串排序有部分文件丢失递归读取目录时readEntries只调用了一次循环读取readEntries直到返回空数组目录层级乱掉前端相对路径拼接时多加了根目录名明确path字段的定义写单元测试上传 2GB 文件时页面内存暴涨哈希计算时一次性读取了整个文件使用分片 FileReader 逐个读取放 Worker 中执行暂停后恢复上传进度回退前端没有记录已传分片集合状态查询接口返回已传分片列表前端跳过多个相同文件名互相覆盖忽略了目录路径只存了文件名服务端用相对路径重建目录不要用 file.name 直接命名6.1 跨域配置与网关超时文件上传通常涉及跨域。如果部署在不同域名下CORS 配置不只是加几个响应头那么简单。分片上传场景里浏览器会先发一个OPTIONS预检请求如果网关对这个预检请求返回超时或 4xx后续上传请求会全部失败而且浏览器控制台里看到的是 CORS 错误排查时很容易被误导。实测遇到最多的情况是开发环境一切正常上线后上传偶尔失败报错信息五花八门。最后发现是网关层对OPTIONS请求的缓存配置不当导致预检频繁超时。处理办法很简单在网关层对OPTIONS请求直接返回 204同时把Access-Control-Max-Age设成 86400让预检请求减少到一天一次。网关超时方面如果服务器用 Nginx 做反向代理proxy_read_timeout和client_max_body_size这两个参数要重点关注。前者默认 60 秒如果分片上传慢很容易超时后者默认只有 1MB不调大就连单个分片都传不过去。6.2 网络闪断与失败重试策略网络闪断是上传功能绕不开的宿命。我的重试策略分三层第一层单分片重试分片失败后自动重试最多重试 3 次间隔指数退避1s、2s、4s。第二层文件级重试如果某个文件有分片始终失败该文件标记为失败但不要影响目录里的其他文件上传等用户手动触发该文件重传。第三层整体恢复网络恢复后用户点击“继续”系统重新查询所有未完成文件的状态从断点续传。这里有个细节指数退避不是空谈它真的能有效减少服务端瞬时压力。想象一下弱网环境下 100 个分片同时失败重试如果没有退避服务端瞬间被打爆有了退避重试请求会分散开来服务端压力大幅度缓解成功率反而更高。6.3 前端兼容性清单写上传功能前先确认一下目标浏览器的兼容情况。我把关键能力整理成一张表方便你在技术选型时对照能力Chrome/EdgeFirefoxSafariwebkitdirectory支持支持部分支持webkitRelativePath支持支持部分支持FileSystem Access API支持不支持不支持File.stream() 在 Worker支持较新版本支持不支持Blob.slice支持支持支持AbortController支持支持支持如果你的用户群体主要用 Chrome 系可以放心用 File System Access API 增强体验但上传基础功能还是建议走webkitdirectory。Safari 用户遇到目录层级丢失时大概率就是webkitRelativePath没拿到完整路径可以加一个运行时检测拿不到就主动降级为“仅让用户选择多文件”。6.4 实测下来最有效的三个优化细节最后再分享三个在真实项目中收益明显的优化细节都是排查过现场、调过线上数据之后留下的心得。第一个是请求合并。如果目录下有大量几十KB的小文件把它们统统打成 ZIP 或二进制包上传服务端解包落盘性能提升可能是数量级的。前端压缩用JSZip库即可但需要注意压缩过程的 CPU 占用量文件数量多时也建议放到 Worker 里去跑。如果不做打包至少也要保证小文件的请求走 keep-alive避免反复建立连接。第二个是UI 反馈策略。上传大目录时用户最关注的不是每个小文件的进度而是“整个任务到底还要多久”。我给上传面板设计了三层反馈顶部的整体进度条、中间目录节点的聚合进度某个目录下的文件传了多少、底部的实时速度与剩余时间。这个“目录聚合进度”是专门配合目录结构上传做的比普通的多文件上传 UI 信息密度高很多用户也不会焦虑。第三个是服务端分片磁盘写入优化。如果并发上传 6 个分片同时到达每个分片都用fs.rename或fs.writeFile直接落盘磁盘 IO 压力会很大。我的做法是先用内存缓冲区积攒几个分片再批量写入。或者在文件量巨大的场景下把临时分片目录放在高速磁盘如 SSD合并完成后再把最终文件迁移到冷存储盘。这个细节对 NAS、对象存储网关类应用特别有用。整个大文件目录上传方案涉及的技术点很分散但核心其实就一条主线把目录还原成结构化的任务列表把大文件拆成可独立重试的分片再靠一个统一的调度器把这些任务平稳地并发跑完。前端的读取与哈希、后端的合并与重建都只是这条主线上不同环节的配套。只要这一条主线想清楚了无论你是用 React、Vue还是服务端用 Node、Java、Go都能照着搭出一套稳定可靠的上传体系。