WebUploader实战:多终端大文件目录上传与分片并发策略 做这个需求前我先说个背景两年前我接手过一套网盘系统改造被要求“在浏览器里选一个文件夹里面几万个文件、几百GB 的数据原汁原味传到服务器目录长什么样服务器上还得长什么样”。当时第一反应是这需求如果只用原生input typefile硬做十有八九会死在兼容性、内存和断点续传上。最后落地的方案就是用 JS 配合 WebUploader 来实现多终端大文件的目录结构上传。这篇内容我会把完整思路写下来覆盖为什么选 WebUploader、分片和并发怎么配、目录相对路径怎么取怎么还原、不同终端有哪些坑。后面所有代码都是我从项目里抽出来的可用版本不是演示 Demo直接改改就能跑。1. 文件夹上传这个需求难点到底在哪儿很多人刚开始觉得“上传文件夹”和“上传一堆文件”没啥区别无非遍历一下。实际动手才发现前端能拿到的信息天生就不是“文件夹结构”。1.1 浏览器只给你一个拍平的 FileList当你给input加上webkitdirectory属性用户选中一个目录后change事件拿到的files是一个全部展开平铺的一维列表。它不区分层级不告诉你哪个文件在哪个子目录下你拿到的只是每个 File 对象身上一个叫webkitRelativePath的字符串。比如你选了这样一个结构projects ├── docs │ └── readme.md └── src └── index.js浏览器最后给你的就是三样东西projects/docs/readme.mdprojects/src/index.js以及文件内容本身目录树要恢复唯一可靠的信息就是这个webkitRelativePath。如果哪一步你把这个字段丢了后面服务端根本不知道文件该放哪。所以整个目录结构上传的第一原则是前端收集文件时必须把相对路径和文件本体绑定在一起从头带到尾。1.2 为什么选 WebUploader 而不是自己用 H5 硬写原生 HTML5 本身能解决文件切片吗能Blob.slice谁都会调。但一个完整的大文件上传方案远不止切片这么简单分片序号、总分片数的维护并发上传队列怎么调度某个分片失败后的重试机制上传进度的整体计算旧浏览器没有 FormData、没有 File API 时怎么办这些逻辑自己写不是不行但要从零维护一套健壮的上传状态机工作量非常大。WebUploader 当年被大量项目采用核心不是它有多花哨而是它把上传任务当成了一个可暂停、可重试、可合并的状态队列来处理这些刚好是目录上传最需要的底座。尤其要注意 WebUploader 里分块上传的机制启动上传前每个文件会先进入队列然后按chunkSize切成一个个分片内部按threads参数控制同时发几个请求。分片请求之间互不依赖某个失败了不影响其他重传只重传失败的那一片。这种设计天然适合 GB 级以上大文件。1.3 没有 H5 支持的浏览器怎么办WebUploader 保留了 Flash 时代的兜底方案也就是swf参数。早年 IE8、IE9 不支持 FormData还能靠 Flash 通道传。但现在主流环境基本都支持 H5我建议把这个参数直接去掉或者仅在检测不到 File API 时启用。这里必须提醒一句Flash 通道对大文件的支持并不理想超 2GB 容易出各种莫名其妙的问题。现在还在纠结 IE 的项目更推荐用一个提示页引导用户换浏览器别在 Flash 上花时间。2. 初始化一份能扛大文件的上传配置配置是整个方案里最值得字斟句酌的部分。很多项目传小文件没感觉传大文件频繁崩问题几乎都出在初始参数拍脑袋。2.1 最小可用的 WebUploader 初始化先看一段我在生产环境用过的核心初始化代码var uploader WebUploader.create({ // 触发按钮 pick: #picker, // 拖拽区域 dnd: #dndArea, // 服务端接口 server: /api/upload, // 核心参数分片上传 chunked: true, // 每个分片 5MB chunkSize: 5 * 1024 * 1024, // 同时上传 3 个分片 threads: 3, // 队列里总共允许 5000 个文件 fileNumLimit: 5000, // 总大小限制这里放开到 100GB fileSizeLimit: 100 * 1024 * 1024 * 1024, // 单文件大小上限 fileSingleSizeLimit: 100 * 1024 * 1024 * 1024, // 允许同名文件重复上传 duplicate: true, // 关闭全文件 MD5 计算 md5: false, // 上传自动开始不配置 auto 则为手动 auto: false });2.2 分片大小、线程数怎么定很多人纠结 chunkSize 设多大。我的经验是4MB 到 8MB 是一个性价比很高的区间。太小几千个分片的请求数能把服务端 Nginx 连接打满太大单个分片失败后重传成本高且网络抖动时容易触发超时。具体怎么定可以按这个公式粗算期望并行吞吐 目标带宽比如 50Mbps ≈ 6.25MB/s单分片建议耗时控制在 1~3 秒如果带宽慢threads3、chunkSize5MB一轮出 15MB/s 传输量threads 也不宜开太高。并发请求不是免费午餐每个请求在服务端都要占连接、占临时文件句柄。本地千兆测 10 线程和 3 线程差距不大生产环境 3~5 是我的常用值。如果服务端带宽有限5 并发反而会把别人上传卡死。2.3 文件夹选择按钮的实现与坑WebUploader 自带的pick按钮默认只能选单个文件你需要额外绑定一个独立 input 并设置webkitdirectoryinput typefile idfolderPicker webkitdirectory multiple styledisplay:none /var folderInput document.getElementById(folderPicker); folderInput.addEventListener(change, function () { var files Array.prototype.slice.call(this.files); // 关键把 FileList 交给 uploader 接管 uploader.addFiles(files); // 清空 value保证下次选同一个目录也能触发 change this.value ; });这里有三个坑我逐一踩过第一清空 input.value 不能忘。如果不清空第二次选择同一个文件夹时change 事件可能不触发。第二不要对文件夹选择按钮设置 accept 扩展名过滤。目录里什么文件都可能出现一旦过滤部分浏览器会直接把整个目录的可见性都搞乱甚至让“选择文件夹”退化成“选择文件”。第三文件夹 input 只在部分浏览器有效。检测方式不要写死var supportsFolder webkitdirectory in document.createElement(input); if (!supportsFolder) { // 没能力选目录只能退化为多选文件 document.getElementById(folderPicker).removeAttribute(webkitdirectory); }3. 分片传输协议把相对路径、序号和断点续传穿起来配置跑通只是第一步。接下来最难的部分是每个分片请求发出去了服务端怎么知道这个分片属于哪个文件、排在文件哪个位置、最终该落到服务器哪个路径3.1 在 uploadBeforeSend 里把参数塞进分片请求WebUploader 在发送每个分片之前会触发uploadBeforeSend事件它允许你往请求里补充自定义数据。你需要在这里拿到文件对象的相对路径每次都带上function getRelativePath(file) { // WebUploader 内部封装 file.file 指向原生 File return file.relativePath || (file.file file.file.webkitRelativePath) || file.name; } uploader.on(uploadBeforeSend, function (file, data) { var rel getRelativePath(file); data.relativePath encodeURIComponent(rel); data.uuid file.uuid; // 每个文件一个 uuid方便分片归组 data.chunk file.chunk; // 当前是第几个分片 data.chunks file.chunks; // 总分片数 });需要注意不要用uploader.options.formData去当全局容器放relativePath。多线程并发时几个分片请求会同时读到同一个变量后写的会覆盖先写的最终服务端拿到一堆错乱的路径。正确做法就是上面代码里那样通过data参数按分片维度携带。有些文章里写用formData那是在单文件单线程的场景下没暴露问题一旦并发高必炸。3.2 服务端怎么落盘和合并分片前端发过来的每个分片本质上都是独立请求。服务端要做的分三步把分片按uuid存到临时目录存的时候按chunk编号区分等所有分片到齐后按编号从小到大拼接成完整文件再放到relativePath指定的目录用 Node.js 写一个很简明的合并逻辑const fs require(fs); const path require(path); const TEMP_ROOT path.join(__dirname, tmp); function mergeChunks(uuid, targetFile) { const tempDir path.join(TEMP_ROOT, uuid); const chunkNames fs.readdirSync(tempDir); // 按数字序号排序不能用字典序否则 10 会排在 2 前面 chunkNames.sort((a, b) Number(a) - Number(b)); const ws fs.createWriteStream(targetFile); let idx 0; function writeNext() { if (idx chunkNames.length) { ws.end(); return; } const chunkPath path.join(tempDir, chunkNames[idx]); const rs fs.createReadStream(chunkPath); // end: false 是关键保证多个分片能连续写入同一个写入流 rs.pipe(ws, { end: false }); rs.on(end, writeNext); rs.on(error, err { ws.destroy(err); }); } writeNext(); }{ end: false }这行最容易漏。如果不写第一个分片的流结束时会顺手把写入流也结束了第二个分片根本写不进去。我见过不止一次有人合并出来只有一个分片大小的文件十有八九就是这里的问题。3.3 分片校验要不要算全文件 MD5WebUploader 默认会给文件算一个 MD5 用于断点续传这种设计在小文件上很安全但大文件非常尴尬。一个 50GB 的文件如果先算完整 MD5普通电脑可能要等好几分钟期间页面看起来就是卡死。所以我在生产环境的初始化里直接把md5关掉靠的是分片本身的分片号来校验完整性。合并后再对完整文件做一次抽样或整体校验。如果有条件用 SparkMD5 配合 Web Worker 计算绝对不要在主线程上算超大文件。4. 目录结构重建前端怎么算相对路径后端怎么还原这一节是整个需求的核心价值也是网上写得最少、最含糊的部分。必须单独展开讲。4.1 webkitRelativePath 的正确读取时机原生 File 对象有一个webkitRelativePath属性。它只在文件是通过目录选择或拖拽文件夹进来时才存在单独选文件时是空字符串。在 WebUploader 里file.file才是原生 File所以读取顺序我写的是file.relativePath优先这个属性是 WebUploader 自己的封装可能不存在不存在就取file.file.webkitRelativePath。读了之后还有一个容易被忽略的处理统一斜杠方向。Windows 下某些浏览器老版本会给反斜杠\Linux 下全是正斜杠/。如果后端是 Linux直接拿反斜杠去path.join会创建一个文件名带反斜杠的垃圾目录。所以前端一定要先做一次归一化function normalizeRelPath(rel) { return String(rel).replace(/\\/g, /); }4.2 路径清洗与安全校验服务端接到的relativePath是前端传上来的永远不可信。一定要防住../../../../etc/passwd这类路径穿越。后端落地前我建议做一个清洗函数function safeRelativePath(rel) { let r decodeURIComponent(rel || ); // 统一斜杠 r r.replace(/\\/g, /); // 去掉开头的 / r r.replace(/^\//, ); // 把 /../ 和 /./ 去掉 const parts r.split(/).filter(p p p ! .); let depth 0; const clean []; for (const p of parts) { if (p ..) { if (clean.length 0) clean.pop(); } else { clean.push(p); } } return clean.join(/); }注意这里的顺序必须先在 URL 层面编码传输后端再解码。如果前端不 encoderelativePath里带#、?、的中文文件名会把整个请求参数截断或污染。这也是很多目录上传项目传某些文件始终失败的原因。4.3 同名覆盖为什么最容易翻车目录上传里同一级目录下可能出现两个同名文件比如assets ├── icon.png └── icon.png这在文件系统里本身不允许但你会遇到的是另一种问题不同目录下的同名文件合并时互相覆盖。所以服务端重建目录时mkdir必须用递归方式写入时不能直接fs.writeFileSync(target)一锤子。我的做法是先按相对路径创建所有父目录写入前检查目标文件是否已存在如果存在且两个文件不是同一个任务就自动追加(1)、(2)这样目录结构和服务器文件系统的完整性能兼顾不会因为一个冲突导致整批上传失败。5. 多终端表现差异与处理策略标题里的“多终端”不是装饰词。同一套代码在普通 PC、笔记本、iPad、手机浏览器里的行为差异能让你半夜被用户电话叫醒。这一节说几个我实际遇到过的终端差异。5.1 手机 Safari 和内置浏览器的实际表现苹果生态里iOS 上的 Safari 对webkitdirectory的支持比较晚老版本根本不认。即使用户手机浏览器支持微信内置浏览器也有自己的脾气很多版本会把input webkitdirectory当成普通文件选择目录选择入口直接消失。针对这种情况我的降级方案是检测到supportsFolder为 false 时UI 上把“上传文件夹”按钮隐藏换成“上传多个文件”然后提示用户选择某个压缩包。目录结构功能在移动端不强求这样反而更符合移动端操作习惯。5.2 大文件上传时的内存水位与 UI 卡顿大文件切片本身用的是Blob.slice它不会把文件整体复制进内存所以切片操作本身不重。真正的内存在哪里有很多人一上来就给每个文件生成预览缩略图、在队列里渲染大图列表几万个文件同时渲染 DOM浏览器直接崩。我的经验是上传文件夹时默认不生成缩略图不加文件预览。只渲染文件路径字符串、文件大小、上传进度条这三样。进度条也要上虚拟列表或者分页渲染一次只在 DOM 里放前 100 条滚动时动态替换。否则一个包含 3 万文件的目录队列列表本身就会成为新的性能瓶颈。另一个容易忽略的点是手机浏览器对多并发的限制。移动端网络不稳定并发高了以后连接大量排队看起来进度条卡住。我建议根据终端做降级var isMobile /Android|iPhone|iPad/i.test(navigator.userAgent); if (isMobile) { uploader.option(threads, 1); }5.3 Electron/桌面壳跨终端的另一条路如果目标终端包含 Windows 客户端可以考虑用 Electron 包一层 H5 壳。Electron 里webkitdirectory表现和 Chrome 基本一致目录选择很顺。但 Electron 还有更原生的做法通过dialog.showOpenDialog直接拿到用户选择的目录句柄再用 Node 端fs遍历目录把文件路径和相对路径统一交给上传模块。这条路的优势是目录信息极其完整不受浏览器 File 对象限制代价是上传模块要同时适配浏览器端和 Node 端共用一套分片和重试逻辑。跨终端项目如果已经到了“必须稳定可靠”的阶段这条路线值得认真考虑但时间紧的话还是优先把浏览器端做稳因为需求量最大的永远是 PC 浏览器。6. 上线之后最常踩的五个坑我把这个方案上线后遇到过的五个高频问题整理成一份清单。排查顺序基本按出现概率从高到低排。现象根因处理方式服务端收到的文件名全是blob或undefinedWebUploader 默认字段名是file但某些封装服务端用了自定义字段初始化时显式指定fileVal: file或后端按实际字段解析大文件传到 100% 后没有合并结果分片合并时漏了{ end: false }或者合并后未回调合并逻辑用上面代码段里的流式写法合并完成再清理临时目录上传过程中页面卡死开启了大文件全文件 MD5 计算md5: false大文件改用 Web Worker SparkMD5部分中文文件名乱码或截断relativePath未做 URL 编码前端encodeURIComponent后端一次性decodeURIComponent目录里超过几千个文件时排序错乱分片合并按字典序排序文件名排序必须用Number(a) - Number(b)每一个坑背后都有真实事故。最严重的一次是在生产环境上传 80GB 数据因为分片排序用字典序合并出来的文件内容错乱用户下载解压时才发现损坏。从那以后我把合并逻辑放在服务端独立模块里并加了一个“整合后大小必须等于所有分片大小之和”的强校验不等就自动重新合并。还有一个容易被忽略的经验临时分片一定要有清理策略。如果用户上传到一半关掉页面服务端临时目录会剩下一堆没合并的分片。我是在合并成功或者超时 24 小时后统一清理否则跑上一个月磁盘迟早被半成品填满。另外再分享一个小技巧uploadBeforeSend里带上客户端生成的uuid后前端可以把每个文件的分片进度存一份到localStorage。用户意外刷新页面重新选择同一个文件时先查一下服务端临时目录有没有已传分片有就从下一个分片开始传。这比重新传整个大文件体感好太多实现成本也低。