☰
WebUploader目录上传与断点续传完整方案:前端改造+服务端落盘
2026/10/11 3:38:47 网站建设 项目流程

最近接了个需求:用户要一次选中整个项目目录传上去,传到服务器还得保持原来的文件夹层级,传一半断网或者手滑关掉页面,过会儿再选一次目录,能接着传,不重复劳动。这个需求拆开其实就是两件事:让 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 开始3
chunks该文件总分片数12
chunkSize每个分片的字节数2097152
name文件名首页.png
size文件总大小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[type=file]') .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,这是预料之中的。

网络恢复后,用户重新选同一个目录,整个恢复链路是这样的:

  1. 目录重新入队,12 个文件全部进入 WebUploader 队列;
  2. MD5 缓存命中 9 个文件的指纹,其余 3 个重新计算;
  3. 每个文件走beforeSendFile钩子,向/api/check对账;
  4. 有 5 个文件已经合并完成,check 返回uploaded: true,直接跳过;
  5. 剩下 7 个文件的existChunks被填进file._existChunks;
  6. beforeSend钩子对每个分片判断,已有的分片resolve(false)跳过,缺失的分片正常上传;
  7. 最后一个分片到齐后服务端自动合并,服务端目录树完整还原。

整个过程除了第一次失败时报错,用户不需要任何额外操作。我在实际项目里发现,这种“文件级秒传 + 分片级补传”的组合,对大目录体验提升非常明显,尤其是那些照片、视频、压缩包混合的目录,重传成本几乎可以忽略。

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[type=file]').val('');

特殊字符。relativePath里经常有空格、中文、括号、#号。上传请求走 FormData,WebUploader 会自动编码,不用担心传输层;但服务端落盘前要统一处理编码,我实践里是保持原始字符串、只做白名单校验,别在中间环节乱用decodeURIComponent,否则容易编解码不一致导致路径对不上。

过期任务清理。断点续传意味着服务端会留下大量没传完的taskId目录。如果不清,磁盘迟早被撑爆。我在前端把taskId存在 localStorage 里,全部文件上传成功后调一个接口清掉服务端对应目录;同时服务端每周跑一次定时任务,删除创建时间超过 7 天的未完成任务目录。这样断点续传的“点”才能一直有效,不至于存了一大堆没用的半成品。


这套方案在我这边的内部项目已经跑过几轮,最大的体会是:目录结构续传不是一个单点技术,而是把“目录”翻译成taskId + relativePath两个维度之后,让 WebUploader 原有的队列、分片、重试、进度全部复用起来。先别急着上大目录,拿几十个文件的小目录把 check、upload、merge 三个接口的时序调通,再叠加 MD5 缓存逐步放量,会稳很多。

最后分享一个提升体验的小细节:check 接口除了返回每个文件的分片状态,顺手统计并返回一个“任务内已完成文件数”,前端就能在恢复时给用户一个“本次续传已跳过 N 个文件”的提示。别小看这一句话,用户对“是不是真的在续传”的感知完全不一样了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询