☰
前端大文件上传与断点续传:从基础到工程落地
2026/10/17 15:20:12 网站建设 项目流程

做前端这几年,我跟文件上传打过太多交道。早期写上传功能,一个input标签配一个FormData,后端接口一收,任务就算完了。后来开始做大文件传输,问题复杂度直接翻倍:几十 GB 的素材包、弱网环境下的视频文件、随时可能中断的浏览器会话……如果还是用最简单的整包上传思路,用户等来的大概率只有一个崩溃的进度条。

这个标题列的“浏览器 API / 网络请求 / 文件处理”不是三个孤立的技术点,而是做上传功能时必须同时打通的三个层面:文件怎么从磁盘取出来、请求怎么发出去、数据怎么组织和恢复。这篇文章我打算从一个完整的上传模块出发,把基础上传和断点续传两套方案逐步拆开讲。适合已经能写一些前端业务,但上传这块只停留在“能跑”阶段的人,也适合后端同学想了解前端上传时到底做了什么处理、要跟你们对什么样的接口。

写的时候我不会只贴代码然后丢一句“自己看”,每一段都会交代清楚“为什么这么写”,包括进度事件为什么用XMLHttpRequest而不是Fetch、切片大小为什么要控制在特定范围、hash 计算为什么不能直接在主线程跑。这些都是实际开发里绕不开的细节,纸上谈兵解决不了。

1. 整体方案设计:先想明白“传文件”这件事的边界

1.1 从“能传”到“传得稳”,差的其实就是状态管理

如果你只需要上传一张头像图片,那基础上传足够,代码量不超过 20 行。但一旦文件体积上来,问题就不是“能不能传”,而是“传一半断了怎么办”“网速波动怎么处理”“用户等太久想放弃怎么办”。

我习惯在动手前先列几个需求问题:

  • 支持的最大文件大小是多少?如果超过 2GB,浏览器内存能扛住吗?
  • 网络抖动时,是让用户重新选择文件,还是自动重试失败的分片?
  • 上传中断后,用户刷新页面,能不能从断点继续?
  • 服务端有没有可能已经存在相同文件?能不能通过 hash 直接跳过上传,实现秒传?

这些问题不需要全部在第一天解决,但方案设计时最好留好扩展空间。我见过不少项目最初只做了基础上传,后面紧急加断点续传,结果前端的文件上传模块全部推倒重写,后端也要跟着改接口,代价非常高。

1.2 技术选型:XHR、Fetch、FormData,到底应该怎么组合

先说结论:基础上传用XMLHttpRequest或者Fetch都可以;涉及上传进度、断点续传、并发分片,我会优先选择XMLHttpRequest作为底层传输载体。

原因很直接:Fetch的Request对象虽然能接收ReadableStream作为 body,但浏览器对fetch的上传进度支持至今没有一个统一标准。你想拿到“已上传字节数 / 总字节数”,唯一靠谱的做法是用XMLHttpRequest的upload.onprogress事件。虽然XHR写起来不如fetch优雅,但它在这个场景下就是最稳的选择。

FormData则是前端构造 multipart 请求体的标准方式。你把Blob或File直接append进FormData,浏览器会自动生成带 boundary 的请求体,不需要手动处理Content-Type。这是一个非常容易踩坑的点:如果你手动给请求设置了Content-Type: multipart/form-data,又忘了boundary,服务端解析就会失败。

这里我总结一下三个 API 各自的上传职责和选用场景:

API适用场景优点限制
XMLHttpRequest基础上传、大文件分片、需实时进度进度事件完善;可中断;兼容性最好写法偏传统,需要自己封装 Promise
Fetch小文件、简单 POST 上传、无需进度写法简洁,原生 Promise上传进度支持缺失;中断需要AbortController
File API + FormData任何浏览器端文件上传的“数据组装”负责把Blob/File变成可发送的格式纯前端浏览器能力,不能直接发送 JSON 混合 body 之外的二进制

实际项目里,我通常是这么分工的:文件读取、切片、hash 计算全部走 File API;上传动作走 XHR 封装;如果只是接一个第三方对象存储的直传接口且不需要进度条,那才考虑用fetch。

2. 基础上传:先把浏览器文件处理机制吃透

2.1 File、Blob 和 ArrayBuffer:浏览器里的“文件”到底是个什么东西

做上传的第一步,是理解你拿到的文件对象是什么。<input type="file">触发change事件后,event.target.files返回的是FileList,里面每个元素是一个File对象。File继承自Blob,所以Blob拥有的能力File全都有——比如slice()、size、type、arrayBuffer()、stream()。

Blob表示的是一段不可变的二进制数据,它并不关心内部是文本还是图片,也不存在“读取完会销毁”的概念。你拿到的blob.slice(start, end)返回的是原数据的一个“视图”,底层内存并没有复制整份数据,这对大文件切片很友好:切 1000 个分片不会导致内存爆炸。

还有一个概念值得反复强调:上传时发送的是FormData,而不是直接发送File。很多新手直接把file放进JSON.stringify,然后当成普通字符串 POST 给后端,这是不行的。后端接口一般会通过@RequestParam("file") MultipartFile file或类似的 multipart 解析器来接收,而FormData的编码格式正是 multipart/form-data。

2.2 基础上传的实现:用 XHR 写一个带进度的上传函数

基础上传直接给代码。这个函数我会反复用在后面的分片场景里,先把它做稳:

function uploadFile(file, targetUrl, options = {}) { return new Promise((resolve, reject) => { const formData = new FormData(); // 注意:文件名放在服务端约定好的字段上,不一定要叫 file formData.append('file', file, file.name); const xhr = new XMLHttpRequest(); xhr.open('POST', targetUrl, true); // 需要 UI 进度条时,监听 upload 的 progress 事件 xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); options.onProgress && options.onProgress(percent, e); } }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { try { const data = JSON.parse(xhr.responseText); resolve(data); } catch (err) { resolve(xhr.responseText); } } else { reject(new Error(`Upload failed, status: ${xhr.status}`)); } }); xhr.addEventListener('error', () => { reject(new Error('Network error during upload')); }); xhr.addEventListener('abort', () => { reject(new Error('Upload aborted')); }); xhr.send(formData); }); }

几个容易被忽略的点:

  • xhr.upload和xhr是两个不同的事件源。upload上的progress表示请求体上传进度;xhr上的progress表示响应下载进度。对上传场景来说,加在哪个对象上,区别很大。
  • 服务端返回的可能是 JSON,也可能是纯文本。load事件里最好做好解析兼容,别直接假设一定是 JSON。
  • FormData的字段名一定要和后端约定一致。我经历过一次后端MultipartFile file对应files字段的惨案,整个上传接口一直报空指针,排查了半天才发现是字段名错了。

2.3 不用 Fetch 行不行?说说 Fetch 上传方案的取舍

如果你只是传一个小文件,用fetch确实清爽:

const formData = new FormData(); formData.append('file', file); const response = await fetch('/api/upload', { method: 'POST', body: formData, });

注意这里我没有手动设置Content-Type。前面已经提过,fetch和XHR都会在设置FormData作为 body 时自动补充boundary,一旦你手写了Content-Type,反而会破坏这个行为。

fetch的上传进度可以通过ReadableStream配合TransformStream自己统计,比如把File切成一段段流式写入,但我实测下来这个方案在兼容性和代码可维护性上都不如XHR好用。除非你的项目有非常强的流式上传需求,否则我为进度条选型时还是老实用 XHR。

3. 断点续传:拆解原理、切片与并发控制

3.1 断点续传的本质:把“传整个文件”变成“传一组分片”

断点续传不会魔法般地降低网络错误概率,它的核心思路是减小“失败单元”。整个文件是一块 2GB 的石头,中途断了就前功尽弃;切成 2000 块 1MB 的小石头,每块独立上传,失败重传的成本就从“重传 2GB”变成“重传 1MB”。

断点续传的整体设计通常是这样的链路:

  1. 前端读取文件,按固定大小切片;
  2. 计算整个文件的唯一标识(hash),用于后续秒传判断和分片命名;
  3. 询问服务端:这个文件传过没?哪些分片已经传过?
  4. 只上传缺失的分片;
  5. 所有分片传完后,通知服务端触发合并,或者由服务端按约定在最后一个分片上传后自动合并。

服务端是否需要做合并操作,取决于你的接口设计。一种常见做法是:前端传切片时带上fileHash和chunkIndex,服务端把每个分片存到一个临时目录;所有分片完成后再由前端调用/merge接口,服务端按chunkIndex顺序拼接文件。另一种做法是服务端每收完一块分片就追加写入目标文件,前端不需要单独的合并步骤。我后面会以“服务端提供上传分片、查询分片、合并分片三个接口”的方案来演示,这是目前实践中最清晰、也最容易被团队接受的模型。

3.2 文件切片与 hash 计算:不是小事,别在主线程蛮干

切片的代码其实非常简单:

// 常见选择:1MB 或者 2MB const CHUNK_SIZE = 2 * 1024 * 1024; function createFileChunks(file, chunkSize = CHUNK_SIZE) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); chunks.push({ index: chunks.length, size: end - start, blob: file.slice(start, end), }); start = end; } return chunks; }

切片大小怎么定?经验值在 1MB 到 5MB 之间。太小会让 HTTP 请求数量暴增,服务端频繁建连反而更慢;太大会让重传代价变大,断点续传的优势就打了折扣。弱网环境我倾向 1MB,内网环境可以放大到 5MB 甚至 10MB。如果项目里有自动重试机制,每个分片最多重试 2~3 次,那分片大小影响的是整个上传过程的最差情况耗时。

文件唯一标识的 hash 计算,是断点续传里“续传”和“秒传”的依据。最简单的方式是直接用SparkMD5在浏览器里计算整个文件,但大文件直接算整包会让页面卡死,尤其是移动端。我推荐分块增量计算:每读取一片,就调用spark.append(ArrayBuffer),最后spark.end()得出整个文件的 hash。

// 用 SparkMD5 做增量 hash 的简化示例 async function calcFileHash(file, chunkSize = CHUNK_SIZE) { return new Promise((resolve, reject) => { const spark = new SparkMD5.ArrayBuffer(); let start = 0; const reader = new FileReader(); const processChunk = () => { if (start >= file.size) { resolve(spark.end()); return; } const blob = file.slice(start, start + chunkSize); reader.onload = (e) => { spark.append(e.target.result); start += chunkSize; processChunk(); }; reader.onerror = reject; reader.readAsArrayBuffer(blob); }; processChunk(); }); }

如果你追求更极致的性能,可以把 hash 计算放到 Web Worker 里跑,主线程完全不阻塞。我对超过 500MB 的文件都会建议走这个方案,不要让用户在看进度条之前先对着卡顿页面等十几秒。

3.3 并发控制:不是分片越多就越快

把 2GB 文件切成 1000 片后,你当然不会一次性发 1000 个请求。浏览器对同域名的并发连接数有限制,一次性全发出去只会造成请求排队和资源竞争,服务端压力也会陡增。

我常用的并发模型是一套“任务队列 + 并发数上限”的控制:维护一个待上传分片队列,同时最多跑 N 个请求(我习惯取 3~5),请求完成后再从队列里补一个分片继续。

这个实现我不贴完整代码,核心思路拆开来说就很清晰:

  • 每个分片的状态优先级是:pending等待中 →uploading上传中 →success/failed。
  • 开始时从队列头部取出 N 个pending分片,分别发起上传。
  • 每个分片完成后,尝试从队列中取出新的pending分片补位。
  • 当所有分片都进入success,并且没有pending分片时,调用合并接口。

并发数不是越大越好。我在本地环境测试过 10 并发和 3 并发,最终文件合并一样不会乱,但 10 并发时服务端临时文件和建连压力明显更高。如果团队后端性能一般,我建议先按 2~3 并发起步,上线后再按监控数据调整。

4. 断点续传核心实现:从前端接口设计到完整落地

4.1 准备三个接口:上传分片、查询已传分片、合并分片

做断点续传,前端不能只会切文件,还要跟后端约定一套完整的接口协议。下面是我推荐的最小可用协议,也是我做过几个项目后沉淀下来的结构。

接口方法核心参数作用
检查文件POST/checkfileHash返回文件是否已存在、已上传分片索引列表
上传分片POST/uploadfileHash,chunkIndex,file接收单个分片,返回该分片是否成功
合并分片POST/mergefileHash,fileName,totalChunks服务端将所有分片按索引合并

/check接口还可以承担“秒传”职责:服务端发现文件已经完整存在时,前端直接提示上传完成,不再传任何分片。这在高频重复上传相同资源的场景里非常有用。

4.2 核心流程编码:查询、补传、合并一条龙

我写一个更贴近真实使用的类,把状态和流程都放进同一个地方,方便复用。

class ChunkedUploader { constructor({ file, fileName, fileHash, chunkSize = 2 * 1024 * 1024, concurrency = 3 }) { this.file = file; this.fileName = fileName; this.fileHash = fileHash; this.chunkSize = chunkSize; this.concurrency = concurrency; this.chunks = this.createChunks(); this.pendingQueue = [...this.chunks]; } createChunks() { const chunks = []; let start = 0; while (start < this.file.size) { chunks.push({ index: chunks.length, size: Math.min(this.chunkSize, this.file.size - start), blob: this.file.slice(start, start + this.chunkSize), status: 'pending', }); start += this.chunkSize; } return chunks; } }

查询服务端已传分片后,把pendingQueue过滤一遍,只保留服务端不存在的分片:

async syncWithServer() { const res = await fetch('/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileHash: this.fileHash }), }); const data = await res.json(); this.uploadedChunks = new Set(data.uploadedChunkIndexes || []); this.pendingQueue = this.chunks.filter((chunk) => !this.uploadedChunks.has(chunk.index)); return { needUpload: this.pendingQueue.length > 0, isUploaded: data.isUploaded === true, }; }

然后运行一个带并发限制的队列。每个分片的请求体准备方式跟基础上传一致,只不过多加了fileHash和chunkIndex这两个字段:

async runUpload() { const workers = []; for (let i = 0; i < this.concurrency; i++) { workers.push(this.workerLoop()); } await Promise.all(workers); } async workerLoop() { while (this.pendingQueue.length > 0) { const chunk = this.pendingQueue.shift(); await this.uploadChunk(chunk); } } async uploadChunk(chunk) { chunk.status = 'uploading'; const formData = new FormData(); formData.append('fileHash', this.fileHash); formData.append('chunkIndex', chunk.index); formData.append('file', chunk.blob, `${this.fileName}.part${chunk.index}`); await uploadFile(formData, '/upload'); chunk.status = 'success'; }

这里有个容易被忽略但必须处理的点:分片文件名要保证不会跟服务端其他文件混淆。我会用${fileName}.part${chunkIndex}这种格式,如果项目里怕文件名重复导致覆盖,前端甚至可以直接把分片文件命名为${fileHash}-${chunkIndex}。

全部完成后调用合并接口:

async merge() { await fetch('/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileHash: this.fileHash, fileName: this.fileName, totalChunks: this.chunks.length, }), }); }

这套结构运行起来的真实顺序是:算 hash → 调/check→ 决定传哪些分片 → 并发传缺失分片 → 全部成功 → 调/merge。其中任何一个分片失败,我要么自动把这个分片重新放回队列末尾等待重试,要么直接中断整个流程把错误抛给用户操作层,具体取舍取决于你对用户体验的目标定位。

我在实际项目里见过一个很典型的坑:前端并发上传完成后,误以为所有分片顺序一定正确,直接把分片顺序打错了,结果合并后的文件损坏。所以分片请求体里一定带上chunkIndex,而不要在文件名或者 URL 里隐式依赖顺序。这也解释了为什么FormData里哪怕多传一个字段,都不算多余。

4.3 秒传是怎么实现的

秒传不是“传得很快”,而是“不用传”。原理非常简单:前端先算出fileHash,调用/check,服务端查一下有没有相同 hash 的文件。如果有,直接返回“文件已存在”,前端展示“上传成功”。这个能力对内部系统很重要,比如用户反复上传同一个产品视频,存储成本和带宽都能省下一大截。

但要注意 hash 碰撞和恶意伪装的问题。正式的秒传方案,服务端不能只信任前端传上来的 hash,安全要求高的话应该抽检一个或多个分片的内容做二次校验。这个属于“上线后迭代”的范畴,初期可以先按只信任fileHash来设计。

5. 实操中的糟心事:问题定位、性能与兼容性经验

5.1 上传中断、重复上传、合并损坏,这三类问题怎么排查

上传功能上线后,用户反馈的问题往往高度集中,我把高频问题整理成了一张排查表,按我的经验排了优先级:

现象可能原因排查思路
上传到某个百分比后直接失败分片请求超时或服务端临时目录权限不够看 Nginx 错误日志;确认分片上传接口的超时时间;检查临时目录空间
刷新页面后继续传,进度条从 0 开始/check接口返回的已上传分片索引不完整核对前端是否过滤pendingQueue,服务端是否真正持久化了分片索引
合并后文件打不开、损坏分片丢失或顺序错乱校验每个分片上传时的chunkIndex是否连续;比对服务端临时文件大小是否和前端切片一致
秒传失效,永远走完整上传hash 不一致服务端可能是用整个文件内容算 hash,前端是增量算法,对齐同一个 hash 规则
浏览器崩溃或内存暴涨切片时一次性读取了过多ArrayBuffer把 hash 计算放到 worker;限制并发;避免在 UI 里保存全部分片 Blob 引用

断点续传会引入一个基础上传没有的状态管理负担:fileHash、分片索引、合并状态都是持久化数据。如果服务端只把“已上传分片索引”存在内存里,一旦服务重启,前端查/check就查不到历史分片,续传自然失效。我见过不少项目在这个环节踩坑,所以我强烈建议后端把这些状态落到数据库或对象存储里,而不是靠进程内存。

5.2 性能优化与兼容性:把能预判的问题提前做掉

大文件上传的性能瓶颈,往往不在网络传输,而在前端内存和 CPU。拿一个 4GB 的文件举例,如果切片是 2MB,那就是 2000 个分片对象;如果每个分片对象都持有blob引用,主线程又同时把它们全部读成ArrayBuffer,内存立刻爆炸。

我的几条实用经验:

  • 分片对象只在真正要上传时从Blob切割,不要提前把全部分片都slice成新的File对象存放在数组里。需要slice可以保留起止位置信息,用时再切。
  • hash 计算全程使用FileReader配合增量追加,不要一次性把整个文件readAsArrayBuffer。
  • Web Worker 里算 hash,主线程只接收最终的 hash 字符串。
  • 上传进度条不要每个分片都更新一次 DOM,用节流或者一次性计算整体进度后按毫秒级刷新即可。

兼容性方面,我现在的经验基线是:现代浏览器优先使用标准 File API 和 FormData;如果项目还要支持非常老的环境,比如十年前就停止更新的浏览器,那分片和进度方案都要准备降级——这种场景我通常建议产品经理直接放弃断点续传功能,因为投入产出比不划算。

5.3 自动重试和用户中断:断点续传里最后两块拼图

自动重试的逻辑不难,但容易写飘。我建议对每个分片设置一个小型重试计数器,单分片失败后延迟 1 秒、2 秒、4 秒做指数退避重试,最多重试 3 次,超过直接让整个上传失败。这样在网络抖动的场景里,用户体验是非常明显的提升。

用户主动中断的处理也很容易被忽略。如果后端已经收到了一半分片,用户关掉页面,这部分临时文件会一直留在服务端。所以我建议服务端给临时分片设置过期清理机制,常见做法是记录分片上传的最后活跃时间,超过 24 小时或 48 小时就自动清理。前端则可以在beforeunload或页面卸载时,主动调用一次取消或暂停接口,把当前进度通知服务端,方便下次续传时从/check接口直接拿到增量信息。

有一次我在做跨平台的媒体素材上传时,把上面这套断点续传完整落地,生产环境的用户反馈里“上传失败需要重来”的工单数量几乎消失。说句实话,第一次调通合并接口、看着浏览器把 2GB 文件分成 1000 片再一片一片传完再拼好的时候,确实挺有成就感的。

再分享一个小技巧:排查分片问题时,不用总是盯着代码逻辑,直接在浏览器 DevTools 的 Network 面板过滤/upload请求,看payload表单数据里的chunkIndex和返回状态码,基本能一眼定位是前端传错、服务端没存、还是网络中断。这个土办法比加一堆日志高效得多。

6. 延伸一下:这些能力还能用在哪些场景

文件上传这一整套能力并不只服务“上传”本身。分片、hash、并发控制、进度管理、容错重试,这些抽象能力可以直接复用到其他前端任务里:日志上报的时候用队列控制发送频率;在线编辑器保存大文档时用增量同步;音视频转码任务需要前端给服务端提交分片素材时,这套代码几乎可以原样移植。网上讨论的很多方案看似复杂,真正落地后你会发现核心设计就是“拆分、标识、重试、合并”这八个字。基础抓牢了,遇到再花哨的需求也能拆成你能控制的几个小块。

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

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

立即咨询