拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WebUploader实战:多终端大文件目录上传与分片并发策略

WebUploader实战:多终端大文件目录上传与分片并发策略

做这个需求前,我先说个背景:两年前我接手过一套网盘系统改造,被要求“在浏览器里选一个文件夹,里面几万个文件、几百GB 的数据,原汁原味传到服务器,目录长什么样,服务器上还得长什么样”。当时第一反应是:这需求如果只用原生<input type="file">硬做,十有八九会死在兼容性、内存和断点续传上。最后落地的方案就是用 JS 配合 WebUploader 来实现多终端大文件的目录结构上传。

这篇内容我会把完整思路写下来,覆盖为什么选 WebUploader、分片和并发怎么配、目录相对路径怎么取怎么还原、不同终端有哪些坑。后面所有代码都是我从项目里抽出来的可用版本,不是演示 Demo,直接改改就能跑。

1. 文件夹上传这个需求,难点到底在哪儿

很多人刚开始觉得“上传文件夹”和“上传一堆文件”没啥区别,无非遍历一下。实际动手才发现,前端能拿到的信息天生就不是“文件夹结构”。

1.1 浏览器只给你一个拍平的 FileList

当你给<input>加上webkitdirectory属性,用户选中一个目录后,change事件拿到的files是一个全部展开平铺的一维列表。它不区分层级,不告诉你哪个文件在哪个子目录下,你拿到的只是每个 File 对象身上一个叫webkitRelativePath的字符串。

比如你选了这样一个结构:

projects ├── docs │ └── readme.md └── src └── index.js

浏览器最后给你的就是三样东西:

  • projects/docs/readme.md
  • projects/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 秒
  • 如果带宽慢,threads=3、chunkSize=5MB,一轮出 15MB/s 传输量

threads 也不宜开太高。并发请求不是免费午餐,每个请求在服务端都要占连接、占临时文件句柄。本地千兆测 10 线程和 3 线程差距不大,生产环境 3~5 是我的常用值。如果服务端带宽有限,5 并发反而会把别人上传卡死。

2.3 文件夹选择按钮的实现与坑

WebUploader 自带的pick按钮默认只能选单个文件,你需要额外绑定一个独立 input 并设置webkitdirectory:

<input type="file" id="folderPicker" webkitdirectory multiple style="display: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 服务端怎么落盘和合并分片

前端发过来的每个分片本质上都是独立请求。服务端要做的分三步:

  1. 把分片按uuid存到临时目录
  2. 存的时候按chunk编号区分
  3. 等所有分片到齐后,按编号从小到大拼接成完整文件,再放到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 分片校验:要不要算全文件 MD5

WebUploader 默认会给文件算一个 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 层面编码传输,后端再解码。如果前端不 encode,relativePath里带#、?、&的中文文件名会把整个请求参数截断或污染。这也是很多目录上传项目传某些文件始终失败的原因。

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。用户意外刷新页面重新选择同一个文件时,先查一下服务端临时目录有没有已传分片,有就从下一个分片开始传。这比重新传整个大文件体感好太多,实现成本也低。

返回列表