尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

大文件断点续传上传插件实战:切片、哈希与Web Worker

大文件断点续传上传插件实战:切片、哈希与Web Worker 两年前做后台管理系统被产品经理塞了一个需求客户要往系统里传 2GB 的项目视频包如果用普通 form 上传传输过程中 WiFi 闪断一次就得从头再来。客户在反馈群里说了句“这系统怎么连网盘都不如”产品经理转头就把需求丢给我。最后我交付的是一个支持断点续传的大文件上传 JS 插件解决方案核心思路一句话就能概括把大文件切成小分片记录哪些分片传成功了断了就从最后一个成功的分片后面继续传。这篇不是泛泛讲原理而是把插件从设计、编码到接线的完整过程复盘一遍。内容包括为什么要做切片而不是整体传、断点信息怎么保存最稳、Web Worker 在哈希计算里到底扮演什么角色、插件类怎么封装、服务端接口怎么配合以及我实际踩过的一些坑。适合准备把内部系统上传能力重做一遍的前端同学也适合刚接手文件上传模块、想搞懂切片续传的初级工程师参考。1. 为什么大文件上传绕不开断点续传1.1 一个 2GB 文件的上传困境传统做法input typefile拿到的文件直接塞进 FormData一次 POST 丢给后端。文件在 20MB 到 50MB 这个量级这套逻辑勉强能跑一旦文件涨到几百 MB 甚至 2GB问题就全冒出来了。首先是请求体太大带来的硬限制。Nginx 默认client_max_body_size只有 1MB后端 PHP 有upload_max_filesizeJava 有maxUploadSize不同环节都可能直接掐掉你的请求。就算这些配置都改了还有网关层、CDN、浏览器本身对长时间连接的超时策略在等着你。其次是网络不确定性。手机网络切换、Wi-Fi 信号抖动、代理超时、服务器重启每一个事件都可能让一个传输了半小时的请求突然中断。最关键的问题是传统上传一旦失败服务端可能只收到半个文件客户端却完全没有能力判断“已经传到了哪个字节”结果只能是全部重来。对大文件来说重传成本不是线性增长而是成倍放大。断点续传本质上就是把这个不确定的大请求拆成很多个状态明确的小请求每个小分片要么成功要么失败失败只需要重传那一小片。1.2 从 ActiveX 控件到纯 JS 插件的迁移和很多做过内部系统的同学一样我最早接触的大文件上传是 NTKO 这类 ActiveX 控件方案。控件能实现分块、进度监听、本地文件读取但问题同样明显需要安装客户端组件、只能在特定浏览器下运行、安全策略一收紧就“不能装载控件”。Windows 更新、浏览器升级、IE 停更任何一个变化都可能让整条上传链路报废。移动端和 WebView 场景更是直接不兼容。所以现在的方向很明确基于 HTML5 标准能力用纯 JS 插件替代控件。复制一个 JS 文件到项目里不用安装任何东西调几个方法就能获得断点续传能力。实际上只要是个能跑标准 Web API 的容器不管是 PC 浏览器、移动端 WebView还是自带脚本能力的浏览器环境这套方案都能复用。1.3 断点续传不只是“续传”还能顺带秒传和加速做切片方案还有一个意外收获可以同时实现秒传。上传前先给整个文件算一个唯一指纹比如 MD5发给服务端查询如果服务端已经存在同样指纹的完整文件那就不用传了直接跳到最后一步做关联或者复制。这个能力对大文件内部系统来说非常实用同一个安装包、同一个视频素材被不同部门反复上传的场景太常见了。分片上传同时还带来了并发加速的可能。多个切片同时上传能更充分地利用带宽。但这里要泼一盆冷水并发数不是越大越好我在第 2 节会专门说怎么定切片大小和并发数。2. 整体设计思路切片、哈希、记录三位一体2.1 分片大小到底怎么定两个经验公式切片大小和并发数没有标准答案但有两个约束必须考虑清楚。第一个约束是请求数量。请求总数等于Math.ceil(fileSize / chunkSize)。1GB 文件如果用 1MB 切片会产生 1024 个请求每个请求都有独立的首部和校验开销任何一个失败都可能触发重试服务端也要建 1024 次连接整体吞吐反而下降。所以分片不能太小。第二个约束是失败重传的代价。分片越大断点续传的粒度越粗一个分片失败重传的数据就越多。所以分片也不能太大。我常用的经验值100MB 以内的小文件用 2MB 分片并发 3100MB 到 2GB 用 5MB 分片并发 3 到 52GB 以上用 10MB 到 20MB 分片并发 3 到 6。另外要注意HTTP/1.1 下浏览器对同一个域名最多开 6 个并发连接你把前端并发调到 10实际还是 6 个连接在跑剩下的请求只会排队还增加了浏览器调度负担。HTTP/2 没有 6 连接限制但服务端的并发处理压力同样要纳入考量。还有一个容易忽略的点并发分片时要计算一下带宽极限。假设下行带宽 50Mbps约等于 6.25MB/s5MB 的分片理论上最少需要 0.8 秒传完并发 6 个也只是把队列往前推单个分片的物理传输时间不会因为并发而缩短。并发解决的是“网络延迟空窗期”和“服务端单请求处理时间”没有被充分利用的问题不是无脑提速。2.2 文件指纹hash 是断点续传的身份证要让服务端认识这个文件前端必须给文件算一个身份证也就是 hash。我用的是对整个文件做 MD5 的增量计算也有人用 SHA-1 或者 SHA-256。有人会问MD5 不是不安全吗这里要澄清MD5 作为加密算法确实已被攻破但在文件去重和完整性校验场景下它仍然是常见方案性能也足够。每次选择文件后前端先算完整文件的 hash然后带着 fileHash、fileName、fileSize 去服务端创建任务。服务端以 fileHash 为 key 存储任务状态返回 uploadId 和已经上传成功过的分片索引列表。如果所有分片都已经存在并且服务端已经有合并好的最终文件就直接返回秒传成功。这里有个细节我一开始吃过亏不要把文件名加进 hash 计算。同一个文件改个名字内容完全没变如果 hash 把文件名也一起算进去秒传就会失效断点续传也会因为“看起来不是一个文件”而重新上传。文件内容才是唯一标准name、size、lastModified 这些只作为辅助展示字段。2.3 断点信息放哪更稳本地记录 服务端核对断点信息不能只放前端也不能只放服务端。只放前端 localStorage浏览器清缓存、换设备、隐私模式一开记录全没了只放服务端前端每次都要重新拉取已上传分片遇到网络差的时候连状态都拿不到。我的做法是两层都放。前端用 IndexedDB 保存任务记录包含 fileId、uploadId、fileHash、分片索引集合、文件元数据。为什么不用 localStorage因为大文件分片可能有几百上千个一个分片索引就是一条记录localStorage 5MB 的上限很容易爆而且它是同步 API写大对象会卡主线程。IndexedDB 是异步的适合存结构化数据容量也大得多。但本地记录只能作为加速恢复的手段不能当作唯一真相。前端启动时先查服务端任务状态以服务端返回的已上传分片为准再和本地记录做合并去重。这样就算本地库被清了只要服务端分片文件还在依然能恢复续传。反向也是同理服务端如果因为磁盘清理把临时分片删了前端调查询接口就能发现该重传的重传不能臆想“之前传过就一定能续传”。3. 用 Web Worker 扛起哈希计算3.1 为什么计算哈希时页面会卡成 PPT第一次实现断点续传时我没上 Worker直接在主线程里用 FileReader 读文件算 MD5。2GB 文件读下来页面像死了一样滚动都掉帧用户点哪儿都没反应。原因是主线程被 CPU 密集的读取和哈希计算占满浏览器连渲染 UI 的空闲都没有。这个体验是不能接受的。文件上传往往在后台管理页面里用户上传的时候可能还要填别的字段、查看列表、切换菜单主线程一卡整个系统都像瘫痪了。解决方案是把哈希计算挪到 Web Worker 里。Worker 是浏览器后台线程没有 DOM 访问权限但可以处理文件读取、二进制计算这些 CPU 密集任务主线程只管接收结果。3.2 增量 hash 计算的 Worker 代码Worker 里不能一次把整个文件读进内存2GB 文件直接读成 ArrayBuffer内存直接爆掉。正确姿势是增量读取按切片大小一段一段读每读一段就 append 进哈希计算器同时把进度发回主线程。// hash-worker.js importScripts(https://cdn.jsdelivr.net/npm/spark-md53.0.2/spark-md5.min.js); self.onmessage async function (e) { const { file, chunkSize } e.data; const spark new self.SparkMD5.ArrayBuffer(); let offset 0; while (offset file.size) { const blob file.slice(offset, offset chunkSize); const buffer await blob.arrayBuffer(); spark.append(buffer); offset blob.size; self.postMessage({ type: progress, loaded: offset, total: file.size }); } self.postMessage({ type: done, hash: spark.end() }); };主线程这边用一个 Worker 实例接收进度const worker new Worker(hash-worker.js); worker.postMessage({ file, chunkSize: 5 * 1024 * 1024 }); worker.onmessage (e) { if (e.data.type progress) { updateHashProgress(e.data.loaded / e.data.total); } else if (e.data.type done) { const fileHash e.data.hash; worker.terminate(); startUpload(fileHash); } };注意这里blob.arrayBuffer()是较新的 API如果目标浏览器太老要退化成 FileReader 的 Promise 封装。我的兼容层代码一般是这样写的function readBlobAsArrayBuffer(blob) { return new Promise((resolve, reject) { const reader new FileReader(); reader.onload () resolve(reader.result); reader.onerror reject; reader.readAsArrayBuffer(blob); }); }3.3 要不要给上传也上 Worker我的建议网上有些方案会把上传请求也放进 Worker理由是“释放主线程”。但实际工程中上传本身是 IO 密集操作XHR 异步请求并不会阻塞主线程真正让页面卡顿的是二进制处理和计算。而且上传请求放在主线程有天然优势能直接拿到xhr.upload.onprogress的上传进度、能随时abort()取消、能方便地维护并发队列。如果在 Worker 里做上传还要处理请求中断、队列状态同步、事件消息转发复杂度翻倍收益却很有限。我的原则是CPU 密集的活交给 WorkerIO 密集的活留在主线程这是比较务实的取舍。真要给 Worker 加任务也是以后加“分片加密压缩预处理”这类计算任务而不是上传本身。4. 插件核心实现从 0 到 1 写一个 FileUploader4.1 插件接口设计先定事件再写类做可复用插件时我习惯先把对外接口定下来再写内部实现。上传插件的状态很多计算哈希中、创建任务中、上传中、暂停、恢复、合并中、成功、失败用 Promise 不是不行但事件风格更贴合这种多状态场景。我给了on和off方法同时保留了初始化配置里的回调业务方怎么方便怎么用。const uploader new FileUploader({ file: fileInput.files[0], chunkSize: 5 * 1024 * 1024, concurrency: 3, url: /api/upload, params: (chunk, meta) ({ uploadId: meta.uploadId, index: chunk.index }), headers: () ({ Authorization: Bearer xxx }), onProgress: (percent, loaded, total) {}, onSuccess: (result) {}, onError: (err) {} }); uploader.on(statusChange, (status) {}); uploader.start(); uploader.pause(); uploader.resume(); uploader.destroy();注意params和headers我设计成了函数而不是固定对象。因为上传分片需要动态带上 uploadId、分片索引这些只有运行时才知道的字段。如果是函数就能在每次请求前取到最新的上下文。4.2 任务创建、断点恢复与分片队列插件的内部流程我整理成了一条稳定链路选择文件 → 启动 Worker 算 hash → 创建/查询服务端任务 → 读取本地 IndexedDB 记录 → 过滤已上传分片 → 构建上传队列 → 并发调度 → 全部完成通知合并。关键代码里任务准备阶段是这样的async _prepare() { const fileHash await this._calcHash(); // Worker 计算见第 3 节 const localTask await this._loadLocalTask(fileHash); const res await this._request({ url: this.url /task, method: POST, data: { fileHash, fileName: this.file.name, fileSize: this.file.size, uploadId: localTask ? localTask.uploadId : undefined } }); const data res.data; if (data.status done) { return { skip: true, result: data.result }; } this.uploadId data.uploadId; this.uploadedSet new Set(data.uploadedChunks || []); return { skip: false }; }创建完任务后插件把分片队列构建出来已经存在于uploadedSet里的分片直接跳过。分片对象里除了index、start、end、blob之外我强烈建议保存chunkHash也就是这个分片自身的 MD5。服务端上传接口收到分片后可以再算一次分片 hash 做完整性校验有效规避“传了一半的网络包”这种问题。4.3 并发调度与暂停恢复并发调度不需要引第三方库手写一个简单的信号量就能满足核心是递归补位。每个分片上传结束后主线程的空闲并发数就空出来一个立刻从队首取下一个任务。async _next() { while (!this._stopped this._active this.concurrency this._queue.length 0) { const chunk this._queue.shift(); this._active; this._uploadChunk(chunk) .then(() { this.uploadedSet.add(chunk.index); this._persistLocalTask(); }) .catch((err) this._handleChunkError(chunk, err)) .finally(() { this._active--; if (!this._stopped) this._next(); }); } }暂停的实现要特别注意不是简单设置一个_stopped true就完了还要把当前正在飞行的请求全部 abort否则这些请求可能还在服务端写入分片导致状态混乱。所以_uploadChunk里我会把当前 XHR 实例放进_activeRequests数组_uploadChunk(chunk) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); this._activeRequests.add(xhr); xhr.upload.onprogress (e) this._emitProgress(e); xhr.onload () { this._activeRequests.delete(xhr); if (xhr.status 200 xhr.status 300) resolve(); else reject(new Error(HTTP xhr.status)); }; xhr.onerror () { this._activeRequests.delete(xhr); reject(new Error(Network Error)); }; xhr.onabort () { this._activeRequests.delete(xhr); reject(new Error(Aborted)); }; xhr.open(POST, this.url /chunk); // 写入参数 const formData new FormData(); formData.append(uploadId, this.uploadId); formData.append(index, chunk.index); formData.append(chunkHash, chunk.hash); formData.append(file, chunk.blob, this.file.name); xhr.send(formData); }); }这里顺便说一个关键决策上传分片用 XHR 而不是 fetch。因为fetch虽然用起来更现代但它没有内置的上传进度事件想要拿到上传进度得自己包 ReadableStream兼容性差、代码复杂。XHR 的upload.onprogress是现成的断点续传插件里我基本都选 XHR。4.4 进度计算与 UI 集成进度计算有个常见错误只数“已经成功上传的分片数量 / 总分片数量”。这样做的后果是最后一片上传完之前进度条会一直停留在 99%视觉上像卡住了一样。正确做法是把当前正在传输的分片的 loaded 字节也加进去。_emitProgress() { let uploadedBytes 0; for (const chunk of this._allChunks) { if (this.uploadedSet.has(chunk.index)) { uploadedBytes chunk.size; } else if (this._activeChunkProgress[chunk.index]) { uploadedBytes this._activeChunkProgress[chunk.index]; } } const percent Math.min(100, Math.floor((uploadedBytes / this.file.size) * 100)); this.emit(progress, percent, uploadedBytes, this.file.size); }剩余时间估算我建议用滑动窗口记录最近 5 到 10 秒内新增的已上传字节数算出一个平均速度再用剩余字节数除以平均速度。直接用瞬时速度做估算会飘得很厉害用户看到剩余时间一会儿 30 秒一会儿 5 分钟体验很差。5. 服务端配合接口协议、合并与校验5.1 四个接口定义好前端就成功了一半前端做得再好服务端接口设计不配合断点续传也跑不起来。我通常把服务端拆成四个接口每个职责单一接口作用关键参数返回创建/查询任务以 fileHash 查询是否已存在没有则创建fileHash, fileName, fileSizeuploadId, uploadedChunks, status上传分片接收单个分片并落盘uploadId, index, chunkHash, fileok分片校验可选核对分片完整性uploadId, index, md5ok / mismatch合并文件所有分片到齐后合并出最终文件uploadId, fileNamefinalUrl任务状态我建议用pending、uploading、merging、done、expired五档。前端启动时如果查到状态是merging那就不要重复上传而是轮询等待合并结果。如果查到done直接跳到秒传成功。这个状态机前后端约定清楚能避免很多边界问题。5.2 Node.js 最小服务端实现服务端我用 Node.js Express Busboy 做一个最小可运行版本重点展示分片落盘和合并的思路。临时目录结构按tmp/{uploadId}/{index}存储每个分片就是一个小文件。const express require(express); const busboy require(busboy); const fs require(fs); const path require(path); app.post(/api/upload/chunk, (req, res) { const bb busboy({ headers: req.headers }); const fields {}; let savePath ; bb.on(field, (name, val) { fields[name] val; if (fields.uploadId fields.index) { const dir path.join(TMP_DIR, fields.uploadId); fs.mkdirSync(dir, { recursive: true }); savePath path.join(dir, fields.index); } }); bb.on(file, (name, file) { // 幂等如果分片已存在且大小匹配直接忽略本次写入 if (savePath fs.existsSync(savePath) fs.statSync(savePath).size 0) { file.resume(); return; } file.pipe(fs.createWriteStream(savePath)); }); bb.on(close, () { res.json({ code: 0, data: { ok: true } }); }); req.pipe(bb); });合并接口的核心逻辑是按索引顺序把分片内容依次写入最终文件。为了安全我会先写入一个.tmp文件写完再 rename 成正式文件名避免合并过程中服务端重启留下半个最终文件。合并完成后再删除临时分片目录。app.post(/api/upload/merge, (req, res) { const { uploadId, fileName } req.body; const dir path.join(TMP_DIR, uploadId); const totalChunks fs.readdirSync(dir).length; const finalTmp path.join(FINAL_DIR, uploadId .tmp); const ws fs.createWriteStream(finalTmp); for (let i 0; i totalChunks; i) { const chunkPath path.join(dir, String(i)); if (!fs.existsSync(chunkPath)) { return res.status(400).json({ code: 1, msg: 分片 ${i} 缺失 }); } ws.write(fs.readFileSync(chunkPath)); // 练习用大批量建议用 stream pipeline } ws.end(() { const finalPath path.join(FINAL_DIR, fileName); fs.renameSync(finalTmp, finalPath); fs.rmSync(dir, { recursive: true, force: true }); res.json({ code: 0, data: { url: /files/ fileName } }); }); });上面的代码把整个分片读进内存再写文件对超大文件不友好真正要上生产的时候建议用stream.Transform或直接管道按顺序拼接。但作为理解合并过程的示例它足够直观。5.3 Linux 场景下的文件落地与转存在线程热度词里看到“linux 网络大文件上传一般采用哪种方式”这里多说一句。本文的 JS 上传插件解决的是“浏览器到 Web 服务端”这一段。如果服务端收完文件后还要转储到备份机、对象存储或者另一台应用服务器Linux 上常见的做法是scp、rsync --partial、SFTP或者直接挂载 NFS 后mv。rsync支持断点续传参数很适合同步大文件。但这些属于服务端和运维层的事了。前端上传插件没必要关心最终文件落在哪儿只要保证接口返回的语义清晰、文件完整即可。一个误区是有人想用浏览器直接去连 FTP 或者调 rsync现代浏览器没有这种能力也不应该这么干。统一走 HTTP 接口服务端内部再去做转存架构上最干净。6. 常见问题排查与避坑手册6.1 断点续传最容易踩的 5 个坑做这个插件的过程中我整理了一个高频问题速查表很多问题不是你代码逻辑写错了而是状态设计有漏洞。现象可能原因处理方式断点恢复后重复上传部分分片前端只信本地记录没有跟服务端核对启动时先查服务端 uploadedChunks进度到 90% 后卡住很久分片并发任务已经跑完服务端在合并大文件合并接口返回 merging 状态前端轮询展示“合并中”上传分片偶尔返回 500服务端写临时文件时冲突或磁盘满了上传接口做幂等先检查分片是否存在监控磁盘浏览器刷新后断点记录全丢IndexedDB 写入时机不对只在任务完成时保存每成功一个分片就持久化一次用 debounce 合并写秒传判定失败hash 计算把文件名、时间戳也加进去了hash 只看文件内容二进制6.2 弱网、移动端与网络切换弱网环境是断点续传的主战场也是问题最多的地方。移动端用户经常在 WiFi 和蜂窝网络之间切换切换瞬间 IP 变化链接断开正在上传的分片请求直接 error。遇到这种情况我建议前端先别急着报错而是做一个重试策略第一次失败等 1 秒重试第二次等 2 秒第三次等 4 秒最多重试 3 到 5 次采用指数退避避免服务端被打爆。同时监听navigator.onLine和window的online、offline事件。断网时自动暂停队列把状态置为 paused恢复网络后再调用任务查询接口更新一下服务端已上传分片然后继续上传。这个逻辑听起来简单但很实用能明显降低用户的挫败感。XHR 的 timeout 也要单独设置。默认没有 timeoutweak network 下请求挂在半空不回也不报错用户看到进度条一直不动心里很慌。我一般设 60 秒超时超时后走重试逻辑而不是直接 fail。6.3 浏览器兼容性哪些 API 必须打补丁这套方案依赖的 API 不少但我实际用到的主要是File、Blob.slice、XMLHttpRequest.upload、Web Worker、IndexedDB。现代浏览器基本都支持但有几个细节要留意。Blob.prototype.arrayBuffer()是个相对较新的 API旧版 Safari 和某些 WebView 不支持我在 Worker 代码里用 FileReader 做兼容。IndexedDB在隐私模式下可能不可用所以插件内部做了兜底如果打开 IndexedDB 失败自动降级为内存状态 服务端查询保证功能可用但刷新后本地记录丢失。crypto.subtle只能在安全上下文HTTPS 或 localhost里用如果用 SHA-256 计算 hash要确认部署环境满足条件MD5 库没有这个限制所以很多内部系统仍然选 MD5。还有一个兼容性细节是 Worker 里加载第三方库。上面代码用importScripts加载 SparkMD5 的 CDN 地址如果项目是内网部署CDN 不可达worker 会直接报错。这时要把 SparkMD5 文件下载到本地用相对路径 importScripts。6.4 我在实战里遇到的两个“玄学”第一个是“99% 卡死”。用户截图说进度条走到 99% 就不动了最后发现代码只统计了上传分片的完成数量忽略合并阶段。分片全部传完以后服务端要合并 2GB 文件这个过程需要几十秒甚至几分钟前端却已经不能通过上传事件感知进度了。解决方案是在合并接口返回merging状态时前端进入轮询模式每 2 秒查一次任务状态再把 UI 文案改成“文件合并中请稍候”。第二个是“同一分片被传了两遍”。排查发现是网络超时后前端自动重试但服务端没有做幂等判断同一个 index 的分片被写了两次后一次覆盖前一次如果两次写入的内容一致还好万一第二次传的是损坏数据整个文件就废了。所以上传分片接口必须做幂等先判断tmp/{uploadId}/{index}是否存在文件大小匹配就直接返回成功不再重复写盘。7. 插件接入、扩展场景与几点心里话7.1 快速接入现有项目插件本身不依赖框架所以接入方式很灵活。传统页面直接用 script 标签引入script srcfile-uploader.js/script script const uploader new FileUploader({ file: document.getElementById(fileInput).files[0], url: /api/upload, chunkSize: 5 * 1024 * 1024, concurrency: 3 }); uploader.on(progress, (percent) { document.getElementById(bar).style.width percent %; }); uploader.start(); /script在 Vue 或 React 里可以把插件封装成一个自定义组件选择文件、进度展示、暂停恢复按钮都由组件内部消化业务方只需要拿到最终的文件 URL。因为插件所有逻辑都是标准 ES 模块打包工具都能正常处理。如果目标环境是没有构建步骤的老系统直接把这个 JS 文件扔到静态资源目录window 上挂一个全局FileUploader类即可。有一点要注意插件内部用了Promise、Map、Set、IndexedDB等 API如果还要兼容 IE11 或更老的 WebView需要引入相应的 polyfill但现在的内部系统一般都已经切到 Chromium 内核浏览器这个负担不大。7.2 还能往哪些方向扩展这套基础架构做出来后后续扩展非常顺。常见的方向有第一对接云存储直传。分片上传接口不一定要经过自己的业务服务器可以让前端先从服务端换取对象存储的临时凭证分片直接传到 OSS、S3 或者自建的 MinIO然后在业务服务端只做任务状态记录和合并回调。这样带宽压力从应用服务器转移到对象存储对并发上传场景很有价值。第二做离线任务列表。利用 IndexedDB 保存多个上传任务把插件包装成类似“下载管理器”一样的东西支持任务队列、失败重试、断网自动暂停。对网盘类产品来说这和断点续传是天然配套的。第三分片数据处理。既然分片都已经切好了还能在 Worker 里对分片做压缩、加密、水印、截帧之类的预处理然后再上传。这时候 Worker 的价值就不仅限于算 hash 了而是真正的分片数据加工管道。7.3 几点心里话踩过几次坑之后我最大的感受是断点续传最怕“你以为传了但服务端没有”。一切状态都要以服务端最终确认为准前端本地记录只是加速恢复的缓存绝不能当作唯一依据。开发顺序上我建议先不要碰 UI不要碰并发优化先把“一个文件从上传到断网再到续传合并成功”的最小闭环跑通再去调并发数、做进度美化。如果重新让我做一遍这个插件第一件事不会是敲代码而是拿纸把“分片状态怎么定义、哪些状态由服务端确认、哪些状态允许前端缓存、合并失败怎么补偿”这四件事画清楚。协议不先定好后面所有代码都是在给自己埋雷。
返回列表