news 2026/9/11 8:29:32

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大文件断点续传上传插件实战:切片、哈希与Web Worker

两年前做后台管理系统,被产品经理塞了一个需求:客户要往系统里传 2GB 的项目视频包,如果用普通 form 上传,传输过程中 WiFi 闪断一次,就得从头再来。客户在反馈群里说了句“这系统怎么连网盘都不如”,产品经理转头就把需求丢给我。最后我交付的是一个支持断点续传的大文件上传 JS 插件解决方案,核心思路一句话就能概括:把大文件切成小分片,记录哪些分片传成功了,断了就从最后一个成功的分片后面继续传。

这篇不是泛泛讲原理,而是把插件从设计、编码到接线的完整过程复盘一遍。内容包括:为什么要做切片而不是整体传、断点信息怎么保存最稳、Web Worker 在哈希计算里到底扮演什么角色、插件类怎么封装、服务端接口怎么配合,以及我实际踩过的一些坑。适合准备把内部系统上传能力重做一遍的前端同学,也适合刚接手文件上传模块、想搞懂切片续传的初级工程师参考。

1. 为什么大文件上传绕不开断点续传

1.1 一个 2GB 文件的上传困境

传统做法<input type="file">拿到的文件直接塞进 FormData,一次 POST 丢给后端。文件在 20MB 到 50MB 这个量级,这套逻辑勉强能跑;一旦文件涨到几百 MB 甚至 2GB,问题就全冒出来了。

首先是请求体太大带来的硬限制。Nginx 默认client_max_body_size只有 1MB,后端 PHP 有upload_max_filesize,Java 有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 分片,并发 3;100MB 到 2GB 用 5MB 分片,并发 3 到 5;2GB 以上用 10MB 到 20MB 分片,并发 3 到 6。另外要注意,HTTP/1.1 下浏览器对同一个域名最多开 6 个并发连接,你把前端并发调到 10,实际还是 6 个连接在跑,剩下的请求只会排队,还增加了浏览器调度负担。HTTP/2 没有 6 连接限制,但服务端的并发处理压力同样要纳入考量。

还有一个容易忽略的点:并发分片时,要计算一下带宽极限。假设下行带宽 50Mbps,约等于 6.25MB/s,5MB 的分片理论上最少需要 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-md5@3.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 密集的活交给 Worker,IO 密集的活留在主线程,这是比较务实的取舍。真要给 Worker 加任务,也是以后加“分片加密压缩预处理”这类计算任务,而不是上传本身。

4. 插件核心实现:从 0 到 1 写一个 FileUploader

4.1 插件接口设计:先定事件,再写类

做可复用插件时,我习惯先把对外接口定下来,再写内部实现。上传插件的状态很多:计算哈希中、创建任务中、上传中、暂停、恢复、合并中、成功、失败,用 Promise 不是不行,但事件风格更贴合这种多状态场景。我给了onoff方法,同时保留了初始化配置里的回调,业务方怎么方便怎么用。

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();

注意paramsheaders我设计成了函数,而不是固定对象。因为上传分片需要动态带上 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里的分片直接跳过。分片对象里除了indexstartendblob之外,我强烈建议保存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

任务状态我建议用pendinguploadingmergingdoneexpired五档。前端启动时如果查到状态是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 上常见的做法是scprsync --partial、SFTP,或者直接挂载 NFS 后mvrsync支持断点续传参数,很适合同步大文件。

但这些属于服务端和运维层的事了。前端上传插件没必要关心最终文件落在哪儿,只要保证接口返回的语义清晰、文件完整即可。一个误区是有人想用浏览器直接去连 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.onLinewindowonlineoffline事件。断网时自动暂停队列,把状态置为 paused;恢复网络后再调用任务查询接口,更新一下服务端已上传分片,然后继续上传。这个逻辑听起来简单,但很实用,能明显降低用户的挫败感。

XHR 的 timeout 也要单独设置。默认没有 timeout,weak network 下请求挂在半空不回也不报错,用户看到进度条一直不动,心里很慌。我一般设 60 秒超时,超时后走重试逻辑,而不是直接 fail。

6.3 浏览器兼容性:哪些 API 必须打补丁

这套方案依赖的 API 不少,但我实际用到的主要是FileBlob.sliceXMLHttpRequest.uploadWeb WorkerIndexedDB。现代浏览器基本都支持,但有几个细节要留意。

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 src="file-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类即可。有一点要注意:插件内部用了PromiseMapSetIndexedDB等 API,如果还要兼容 IE11 或更老的 WebView,需要引入相应的 polyfill,但现在的内部系统一般都已经切到 Chromium 内核浏览器,这个负担不大。

7.2 还能往哪些方向扩展

这套基础架构做出来后,后续扩展非常顺。常见的方向有:

第一,对接云存储直传。分片上传接口不一定要经过自己的业务服务器,可以让前端先从服务端换取对象存储的临时凭证,分片直接传到 OSS、S3 或者自建的 MinIO,然后在业务服务端只做任务状态记录和合并回调。这样带宽压力从应用服务器转移到对象存储,对并发上传场景很有价值。

第二,做离线任务列表。利用 IndexedDB 保存多个上传任务,把插件包装成类似“下载管理器”一样的东西,支持任务队列、失败重试、断网自动暂停。对网盘类产品来说,这和断点续传是天然配套的。

第三,分片数据处理。既然分片都已经切好了,还能在 Worker 里对分片做压缩、加密、水印、截帧之类的预处理,然后再上传。这时候 Worker 的价值就不仅限于算 hash 了,而是真正的分片数据加工管道。

7.3 几点心里话

踩过几次坑之后,我最大的感受是:断点续传最怕“你以为传了,但服务端没有”。一切状态都要以服务端最终确认为准,前端本地记录只是加速恢复的缓存,绝不能当作唯一依据。开发顺序上,我建议先不要碰 UI,不要碰并发优化,先把“一个文件从上传到断网再到续传合并成功”的最小闭环跑通,再去调并发数、做进度美化。

如果重新让我做一遍这个插件,第一件事不会是敲代码,而是拿纸把“分片状态怎么定义、哪些状态由服务端确认、哪些状态允许前端缓存、合并失败怎么补偿”这四件事画清楚。协议不先定好,后面所有代码都是在给自己埋雷。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 8:26:32

如何彻底卸载数字人工具HeyGem.ai:三层清理法一次讲清

如何彻底卸载数字人工具HeyGem.ai&#xff1a;三层清理法一次讲清 【免费下载链接】Duix-Avatar &#x1f680; Truly open-source AI avatar(digital human) toolkit for offline video generation and digital human cloning. 项目地址: https://gitcode.com/GitHub_Trendi…

作者头像 李华
网站建设 2026/9/11 8:24:25

CesiumJS 导出:从场景截图到数据导出的保姆级教程

CesiumJS 导出&#xff1a;从场景截图到数据导出的保姆级教程 【免费下载链接】cesium An open-source JavaScript library for world-class 3D globes and maps :earth_americas: 项目地址: https://gitcode.com/GitHub_Trending/ce/cesium 你用 CesiumJS 搭好的三维场…

作者头像 李华
网站建设 2026/9/11 8:22:56

堆的基本存储:完全二叉树与数组的天然映射

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 8:19:52

Bruno API 调试:装完 5 分钟发出你的第一个请求

Bruno API 调试&#xff1a;装完 5 分钟发出你的第一个请求 【免费下载链接】bruno Opensource IDE For Exploring and Testing APIs (lightweight alternative to Postman/Insomnia) 项目地址: https://gitcode.com/GitHub_Trending/br/bruno Bruno 是一款开源 API 客户…

作者头像 李华