WebUploader目录上传与断点续传完整方案:前端改造+服务端落盘

发布时间:2026/10/11 3:38:52
WebUploader目录上传与断点续传完整方案:前端改造+服务端落盘
最近接了个需求用户要一次选中整个项目目录传上去传到服务器还得保持原来的文件夹层级传一半断网或者手滑关掉页面过会儿再选一次目录能接着传不重复劳动。这个需求拆开其实就是两件事让 WebUploader 借助 HTML5 的能力支持文件夹目录结构上传再补上目录维度下的断点续传。WebUploader 本身分片、并发、进度条都现成但默认只是处理扁平的多个文件目录的层级信息和“每个文件传到了哪个分片”都得自己补。先把结论放这儿这事不用改 WebUploader 源码也不用换上传组件。核心思路是给上传队列里的每个文件额外绑定一个相对路径字段再通过 WebUploader 的钩子机制把分片状态和服务端对接起来让“哪个任务、哪个文件、已传哪几个分片”都变成可查询的状态。这篇文章适合那些已经能把 WebUploader 跑起来、但被目录结构和断点续传卡住的同学。我会按“缺什么 → 底层机制 → 前端改造 → 服务端落盘 → 实测踩坑”的顺序把完整方案和能直接抄的代码都过一遍。1. WebUploader 默认传不了目录缺的到底是什么1.1 现成能力盘点WebUploader 这个老牌组件在 jQuery 时代就是上传界的常青树它已经把很多脏活干完了多文件队列管理、并发控制、分片上传、实时进度条、拖拽粘贴、图片预览甚至压缩还有一个 Flash 兜底方案。对“选几个文件传上去等结果”这种常规需求开箱即用基本不用写什么逻辑。但目录上传这件事默认组件是真的没接住。你可以打开 WebUploader 官方示例试试拖一个文件夹进去正常情况下得到的是一堆拍平的文件就算用 input 选择文件夹组件也只认识 File 列表不认识“这个文件来自哪个子目录”。要在服务端还原出“项目A/文档/UI设计/首页.png”这种层级得在入队时把路径信息保留下来并且让路径跟着分片请求一起走到服务端。1.2 目录结构这个需求卡在哪我那时候把需求拆了一下发现真正的难点有四个选择器不支持目录。HTML5 的 input 默认只能挑文件想要挑整个文件夹得给 input 挂webkitdirectory属性而且这个属性要挂到 WebUploader 内部动态生成的那个 input 上不是随便写个 HTML 就完事。路径信息入队即丢。就算用了目录选择底层 File 对象身上的webkitRelativePath也只是浏览器给的一个属性WebUploader 默认不会把它塞进上传请求里服务端收到的还是孤零零的文件名。续传状态没有目录维度。WebUploader 自带的分片续传本质是“单个文件传了一半下次重传时跳过已有分片”。它不认识“这个文件属于某个目录树的哪个位置”也不知道“这整批目录文件里哪些传完了、哪些没传完”。刷新之后怎么办。页面一刷新File 对象就没了内存里的队列也全没了。所谓断点续传在目录场景下只能做成“用户重新选择同一个目录客户端拿着路径和 MD5 去服务端对账跳过已传文件、只补缺失分片”。1.3 总体思路用两个字段把目录“串”起来我最终定的方案是引入两个额外字段taskId代表一次目录上传会话relativePath代表文件在目录树中的位置。断点续传的最小判断单元就是(taskId, relativePath, chunkIndex)三元组。前端负责收集目录、算出每个文件的 MD5、把taskId和relativePath注入每个分片请求服务端负责按taskId/relativePath落盘分片文件并提供 check 接口告诉前端“这个文件完整没有、已有哪几个分片”。整个过程 WebUploader 的队列、进度条、并发控制、失败重试全都能复用我们只是把目录的“身份信息”补齐了。2. 分片、MD5 与断点粒度目录续传的三个底层概念2.1 分片请求默认长什么样想扩展 WebUploader 的分片续传先得知道分片请求里到底带了什么。开启chunked: true之后WebUploader 会把每个文件按chunkSize切成若干个 Blob逐个发 POST 请求。一个默认的分片请求FormData里大体是这些字段字段含义示例file当前分片的 Blob 内容二进制chunk当前分片索引从 0 开始3chunks该文件总分片数12chunkSize每个分片的字节数2097152name文件名首页.pngsize文件总大小25165824服务端拿到这些字段之后要做的其实很简单把第chunk片存成{index}.part等chunks个分片全部到齐再按索引顺序合并成完整文件。分片的真正价值在于“重传成本最小化”——断点后不需要重传整个大文件只补缺失的那几片就行。看到这里你应该已经意识到目录续传要做的就是在这些默认字段之外再把taskId和relativePath一起带过去。服务端根据这两个字段决定把分片写到哪个目录树的哪个文件下。2.2 MD5 在续传里到底扮演什么角色MD5 在这个方案里不是用来做安全校验的它就是个“内容指纹”是客户端和服务端对账的凭证。完整流程是文件入队后先算整个文件的 MD5上传第一个分片前拿 MD5 问服务端“这个文件是不是已经完整了”如果完整直接跳过整个文件这就是大家常说的“秒传”如果不完整服务端查一下这个 MD5 对应的记录把已有分片索引列表返回给前端前端只补缺失分片。这里有个容易被忽略的细节MD5 必须基于整个文件算不能用文件名代替。因为目录场景下用户经常改文件名、复制文件名字完全不可靠。而 MD5 加文件大小加修改时间基本能确定“这是不是同一个文件”。2.3 目录场景的断点粒度单文件续传回答的是“这个文件传了一半还差哪些分片”目录续传要回答三层问题整批任务里哪些文件传完了、哪些文件只传了一半、某个文件具体差哪几个分片。所以服务端的存储模型必须按文件组织分片而不是整个任务一个大包。我给每个文件建一个独立的 parts 目录里面放0.part、1.part这样的分片文件。某个文件的 parts 目录里文件数量到了chunks就把它们合并成最终文件。这样做的好处是check 接口可以按relativePath精确回答每个文件的状态恢复流程天然就是文件级的。3. 前端改造目录选择、路径注入与钩子拦截3.1 让目录选择器挂到 WebUploader 上WebUploader 的pick组件会在内部动态生成一个隐藏的 file input所以不能直接在 HTML 里写webkitdirectory要等 uploader 创建完之后再去操作那个 input。var currentTaskId null; var uploader WebUploader.create({ pick: { id: #dirPicker, multiple: true }, swf: /static/Uploader.swf, // 旧浏览器兜底目录上传必须走HTML5 server: /api/upload, chunked: true, chunkSize: 2 * 1024 * 1024, threads: 3, duplicate: true, // 允许重复文件入队目录重选时要靠它 auto: false }); // 关键一步等组件ready后给内部input挂上webkitdirectory uploader.on(ready, function () { $(#dirPicker input[typefile]) .attr(webkitdirectory, webkitdirectory) .attr(directory, directory); });这里有两个细节。第一ready之后再去挂属性是没问题的因为 input 已经渲染在 DOM 里了属性对下一次选择生效挂完之后浏览器弹出的就是目录选择器选中的是整个文件夹。第二duplicate: true必须开这是为断点续传准备的——用户刷新页面后重新选同一个目录文件名字可能一模一样不开这个选项文件压根不会重新入队。3.2 在 fileQueued 里把 relativePath 存下来目录选中后浏览器会给每个 File 对象一个webkitRelativePath属性形如项目A/文档/UI设计/首页.png。这个属性在 Chrome、Edge、Firefox 里都能拿到是目录结构还原的基础。但 WebUploader 不会自动保留它需要在fileQueued事件里手动存到文件对象上。uploader.on(fileQueued, function (file) { var raw file.source || file.file; file.relativePath raw.webkitRelativePath || raw.relativePath || file.name; file.taskId currentTaskId; file.fileMd5 null; });注意最后那个|| file.name的兜底逻辑很重要。如果用户用的是普通文件选择而不是目录选择webkitRelativePath是空字符串这时候退化成文件名整个方案依然兼容普通多文件上传。另外旧版本 WebUploader 里原始 File 对象可能挂在file.source上新版本有的挂在file.file上所以代码里写了两个候选。3.3 MD5 先算好再入队缓存与计算策略MD5 计算是整个方案里最耗时的部分。我直接用 SparkMD5 的 ArrayBuffer 模式按 2MB 分块读文件增量更新摘要避免一次把大文件全部读进内存。function computeFileMd5(file) { return new Promise(function (resolve, reject) { var blobSlice File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; var chunkSize 2 * 1024 * 1024; var chunks Math.ceil(file.size / chunkSize); var currentChunk 0; var spark new SparkMD5.ArrayBuffer(); var reader new FileReader(); reader.onload function (e) { spark.append(e.target.result); currentChunk; if (currentChunk chunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror function (err) { reject(err); }; function loadNext() { var start currentChunk * chunkSize; var end start chunkSize file.size ? file.size : start chunkSize; reader.readAsArrayBuffer(blobSlice.call(file, start, end)); } loadNext(); }); }为了让“重新选目录”那次对账更快我把算过的 MD5 缓存到了 localStorage缓存键用size lastModified relativePath三者的组合。这么设计是因为同一目录再次选择时没改动过的文件这三个值都不变MD5 可以直接取缓存省掉一大轮计算。缺点是这个策略偏保守文件被复制粘贴后lastModified变化会导致缓存失效需要重新算但换来的是结果一定准确值得。3.4 通过钩子实现跳过逻辑WebUploader 提供了一套插件注册机制可以拦截文件上传和分片上传两个时机。我注册了before-send-file和before-send两个钩子前者在文件开始上传前询问服务端“整个文件是否需要传”后者在每个分片发送前询问“这个分片是否已存在”。WebUploader.Uploader.register({ before-send-file: beforeSendFile, before-send: beforeSend }, { beforeSendFile: function (file) { var deferred WebUploader.Deferred(); if (!file.fileMd5) { computeFileMd5(file.source || file.file).then(function (md5) { file.fileMd5 md5; cacheFileMd5(file, md5); checkFile(file).then(function (result) { file._existChunks result.existChunks || []; if (result.uploaded) { deferred.resolve(false); // 整个文件已存在跳过 } else { deferred.resolve(); // 放行开始传分片 } }); }); } else { checkFile(file).then(function (result) { file._existChunks result.existChunks || []; if (result.uploaded) { deferred.resolve(false); } else { deferred.resolve(); } }); } return deferred.promise(); }, beforeSend: function (block) { var deferred WebUploader.Deferred(); var file block.file; // block.chunk 是当前分片索引 if (file._existChunks file._existChunks.indexOf(block.chunk) -1) { deferred.resolve(false); // 这个分片已经有了跳过 } else { deferred.resolve(); } return deferred.promise(); } });钩子返回的 Promise 语义是resolve()放行resolve(false)跳过。跳过既不会报错也不会影响进度统计WebUploader 会把被跳过的分片 / 文件当作已完成处理。不过不同版本的 WebUploader 对resolve(false)的处理略有差异建议你在自己的版本里先打个日志验证一次确认跳过后uploadSuccess是正常触发的。checkFile是个普通的 jQuery POST把taskId、relativePath、fileMd5、file.size发给服务端function checkFile(file) { return $.post(/api/check, { taskId: file.taskId, relativePath: file.relativePath, fileMd5: file.fileMd5, totalSize: file.size }); }同时还要在uploadBeforeSend事件里把taskId和relativePath手动塞进每个分片请求。这一步漏了的话服务端根本不知道分片该往哪儿写前面全白做。uploader.on(uploadBeforeSend, function (block, data) { data.taskId block.file.taskId; data.relativePath block.file.relativePath; data.fileMd5 block.file.fileMd5 || ; });4. 服务端配合按路径落盘、分片校验与自动合并4.1 三个接口一张表前端改完服务端要接住这些信息。我按最小可用原则设计了三个接口其中合并逻辑直接并进了 upload 接口的最后一个分片分支里少一次额外请求。接口入参返回调用时机POST /api/checktaskId, relativePath, fileMd5, totalSize{ uploaded, existChunks }每个文件首分片发送前POST /api/upload分片文件 taskId, relativePath, chunk, chunks, ...{ ok }每个分片发送POST /api/mergetaskId, relativePath{ ok }可选我直接并入 upload 最后一分片服务端存储目录结构是这样设计的uploads/ {taskId}/ 项目A/文档/需求说明.docx.parts/ 0.part 1.part 项目A/文档/需求说明.docx 合并后的最终文件 项目A/源码/app.js.parts/ ...4.2 落盘规则与路径安全relativePath是前端传来的字符串直接拼接进文件系统路径是非常危险的操作。万一有人传一个../../etc/passwd过来路径穿越可就出大事了。所以服务端第一步必须做白名单校验。const path require(path); const UPLOAD_ROOT path.join(__dirname, uploads); function safeJoin(taskId, relativePath) { const normalized path.normalize(path.join(taskId, relativePath)); const full path.join(UPLOAD_ROOT, normalized); const rootWithSep path.join(UPLOAD_ROOT) path.sep; if (full.indexOf(rootWithSep) ! 0) { throw new Error(非法路径); } return full; }这个函数把taskId和relativePath拼成一个绝对路径然后检查它是不是真的在UPLOAD_ROOT目录下。只要full不以uploads根目录开头直接拒绝。实际项目里还可以再叠一层黑名单把..、空字节、以/开头的绝对路径统统拦掉。别嫌这一步多余目录上传接口一旦裸奔等于给系统开了个文件写入后门。4.3 合并时机、空文件与并发竞态分片上传接口用 multer 接收分片内容后按索引写入 parts 目录然后检查该文件的分片是否全部到齐到齐就合并。我用的 Node.js 示例大致是这样的const express require(express); const fs require(fs); const path require(path); const multer require(multer); const app express(); const upload multer({ storage: multer.memoryStorage() }); app.post(/api/upload, upload.single(file), (req, res) { const { taskId, relativePath, chunk, chunks, size } req.body; const chunkIndex parseInt(chunk, 10); const totalChunks parseInt(chunks, 10); const base safeJoin(taskId, relativePath); const partDir base .parts; const finalFile base; // 空文件没有分片单独处理 if (parseInt(size, 10) 0 totalChunks 0) { fs.mkdirSync(path.dirname(finalFile), { recursive: true }); fs.writeFileSync(finalFile, ); return res.json({ ok: true }); } fs.mkdirSync(partDir, { recursive: true }); fs.writeFileSync(path.join(partDir, chunkIndex .part), req.file.buffer); // 分片数到了就合并 const parts fs.readdirSync(partDir); if (parts.length totalChunks) { // 防止并发请求触发重复合并 if (fs.existsSync(finalFile)) { fs.rmdirSync(partDir, { recursive: true }); return res.json({ ok: true }); } fs.mkdirSync(path.dirname(finalFile), { recursive: true }); const ws fs.createWriteStream(finalFile); for (let i 0; i totalChunks; i) { ws.write(fs.readFileSync(path.join(partDir, i .part))); fs.unlinkSync(path.join(partDir, i .part)); } ws.end(); fs.rmdirSync(partDir); } res.json({ ok: true }); });合并前检查finalFile是否存在是为了防并发竞态——两个分片请求同时到达都发现 parts 数量够了如果不检查就会重复触发合并逻辑。这里用单进程同步判断就够了多实例部署的话还要考虑分布式锁或者用“合并后写个标记文件”的办法。4.4 恢复判定逻辑check 接口是整个断点续传的“大脑”它要回答两个问题文件是否已完整存在、如果没完整已有哪几个分片。app.post(/api/check, express.json(), (req, res) { const { taskId, relativePath, fileMd5, totalSize } req.body; const base safeJoin(taskId, relativePath); const partDir base .parts; // 完整文件存在且大小一致 → 秒传 if (fs.existsSync(base) fs.statSync(base).size parseInt(totalSize, 10)) { return res.json({ uploaded: true, existChunks: [] }); } // 空文件处理 if (parseInt(totalSize, 10) 0 !fs.existsSync(base)) { fs.mkdirSync(path.dirname(base), { recursive: true }); fs.writeFileSync(base, ); return res.json({ uploaded: true, existChunks: [] }); } // 已有分片列表 const existChunks []; if (fs.existsSync(partDir)) { fs.readdirSync(partDir).forEach((name) { const idx parseInt(name.replace(/\.part$/, ), 10); if (!isNaN(idx)) { existChunks.push(idx); } }); } res.json({ uploaded: false, existChunks }); });校验分片是否真的有效时别只看数量。理想情况下还要比对每个 part 文件的大小是不是等于chunkSize只有最后一个分片可以小于chunkSize。如果只数数量不做大小校验服务端一旦有残留的损坏分片合并出来的文件就是坏的。我这套简单方案里没做分片级 MD5实际生产环境如果网络很糟糕可以给每个分片也加一个 MD5 字段服务端校验不过就丢弃返回“请重传”。5. 断网恢复实测与五个绕不开的坑5.1 一次完整恢复复盘我把这套东西跑通之后特意做了个“拔网线实验”。场景是选了一个包含 12 个文件、共约 2.3GB 的目录传到 40% 的时候直接断开网线。WebUploader 的分片请求失败后队列报错文件状态变成error这是预料之中的。网络恢复后用户重新选同一个目录整个恢复链路是这样的目录重新入队12 个文件全部进入 WebUploader 队列MD5 缓存命中 9 个文件的指纹其余 3 个重新计算每个文件走beforeSendFile钩子向/api/check对账有 5 个文件已经合并完成check 返回uploaded: true直接跳过剩下 7 个文件的existChunks被填进file._existChunksbeforeSend钩子对每个分片判断已有的分片resolve(false)跳过缺失的分片正常上传最后一个分片到齐后服务端自动合并服务端目录树完整还原。整个过程除了第一次失败时报错用户不需要任何额外操作。我在实际项目里发现这种“文件级秒传 分片级补传”的组合对大目录体验提升非常明显尤其是那些照片、视频、压缩包混合的目录重传成本几乎可以忽略。5.2 坑一并发线程别贪多WebUploader 的threads控制同时上传的分片数量。我一开始图快设成 6结果发现 Chrome 对同一域名的并发连接数有限制多余的请求全在排队上传速度不升反降。实测 WebUploader 配threads: 3是最稳的既能喂饱带宽又不会把服务器打满。另外注意文件大小和分片的关系文件小于chunkSize时WebUploader 只会生成一个分片也就是一个请求传完。所以目录里如果全是小文件请求数约等于文件数threads不用调大如果都是大文件分片多threads调大反而容易触发浏览器连接瓶颈。这个平衡点建议在自己环境下压一下再定。5.3 坑二MD5 计算卡住上传队列几十个文件逐个算全量 MD5每个 1GB 的文件可能要花十几秒而且是在主线程里跑页面会明显卡顿用户体感就是“点了开始没反应”。我踩过这个坑之后做了两件事一是把 MD5 计算挪到 Web Worker 里computeFileMd5只是给 Worker 发消息不占主线程。二是在beforeSendFile里先返回一个未决的 Promise同时给文件打上“校验中”的标记这样队列不会阻塞其他文件界面也能提示用户当前在计算指纹。如果不想上 Worker还有一个折中方案对超大文件只取头部 2MB、中间 2MB、尾部 2MB 合并算 MD5。这种抽样指纹碰撞概率极低速度能快一个量级。但要注意抽样 MD5 的缓存键里必须带上文件总大小否则两个不同文件可能算出同一个指纹。5.4 坑三浏览器兼容与降级webkitdirectory属性在 Chrome、Edge、Firefox 上都能正常工作但 Safari 的支持一直不太稳定有时候挂上属性也不会弹目录选择器。这种环境我建议直接降级不让用户选目录改成多选文件服务端按“无目录层级”的方式落盘或者干脆弹一句“请使用 Chrome 上传目录”。还有一个隐蔽问题拖拽文件夹时WebUploader 的 dnd 插件拿到的dataTransfer.files是拍平的文件列表目录结构同样会丢。要支持拖拽上传目录得自己处理dataTransfer.items通过webkitGetAsEntry递归枚举目录树。这里给个思路function walkEntry(entry, dir, onFile) { if (entry.isFile) { entry.file(function (file) { file.relativePath dir ? dir / file.name : file.name; onFile(file); }); } else if (entry.isDirectory) { var reader entry.createReader(); reader.readEntries(function (entries) { entries.forEach(function (e) { walkEntry(e, dir ? dir / entry.name : entry.name, onFile); }); }); } }拿到带relativePath的 File 数组后用uploader.addFiles(files)手动加进队列后面走同一套逻辑。5.5 坑四重复选择、特殊文件名与过期清理最后这几个问题看着小实际都很要命。重复选择不触发。用户第一次选完目录第二次再选同一个目录浏览器认为 input 的 value 没变不触发 change 事件。解决方法是每次选择后手动清空 input 的值再配合duplicate: true让文件重新入队。我在目录选择后加了这么一行$(#dirPicker input[typefile]).val();特殊字符。relativePath里经常有空格、中文、括号、#号。上传请求走 FormDataWebUploader 会自动编码不用担心传输层但服务端落盘前要统一处理编码我实践里是保持原始字符串、只做白名单校验别在中间环节乱用decodeURIComponent否则容易编解码不一致导致路径对不上。过期任务清理。断点续传意味着服务端会留下大量没传完的taskId目录。如果不清磁盘迟早被撑爆。我在前端把taskId存在 localStorage 里全部文件上传成功后调一个接口清掉服务端对应目录同时服务端每周跑一次定时任务删除创建时间超过 7 天的未完成任务目录。这样断点续传的“点”才能一直有效不至于存了一大堆没用的半成品。这套方案在我这边的内部项目已经跑过几轮最大的体会是目录结构续传不是一个单点技术而是把“目录”翻译成taskId relativePath两个维度之后让 WebUploader 原有的队列、分片、重试、进度全部复用起来。先别急着上大目录拿几十个文件的小目录把 check、upload、merge 三个接口的时序调通再叠加 MD5 缓存逐步放量会稳很多。最后分享一个提升体验的小细节check 接口除了返回每个文件的分片状态顺手统计并返回一个“任务内已完成文件数”前端就能在恢复时给用户一个“本次续传已跳过 N 个文件”的提示。别小看这一句话用户对“是不是真的在续传”的感知完全不一样了。