ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

客户端文件上传全链路实践:分片续传、进度控制与排错指南

客户端文件上传全链路实践:分片续传、进度控制与排错指南

我对客户端文件上传的第一印象,来自一个内部系统的附件模块。那时功能已经开发完,本地把各种后缀的文档传了个遍,一切都正常。部署到完整链路之后,用户隔三差五反映“文件传不上去”,而我这边看不到任何业务日志,浏览器控制台只有一个笼统的网络错误。后来抓包才发现,请求还没到业务后端,就在中间层被一个请求体大小限制拦截了。这事对我的影响挺大:客户端文件上传不是“前端选出文件、交给HTTP请求”就结束,它是一条从本地磁盘、客户端内存、网络层、接入层到服务端落盘的完整链路。这篇文章我把这条链路从头到尾拆一遍,涵盖基础表单上传、进度控制、分片断点、联调排错和测试埋点,希望能让正准备做上传功能的同学少走点弯路。

1. 先看清链路:从File对象到服务端落盘

1.1 一次上传要经过的七个环节

分析一次上传,链路通常不止“前端POST + 后端接口”两部分:

  1. 选择文件:input 或拖拽都会返回 File 对象。File 本质上是 Blob 子类,带 name、size、type 等元数据;拖拽场景下还需要额外处理 DataTransfer 里的文件列表。
  2. 客户端组装:根据协议把文件放进 FormData,转成二进制流,极端情况下才用 base64。这一步决定请求体编码,也直接决定内存占用。
  3. 请求发送:浏览器按指定方法打开连接,写入请求头,包括 Content-Type、Content-Length 或 Transfer-Encoding;如果是 multipart,浏览器会自动生成 boundary。
  4. 中间层传递:大型业务的统一接入层会判断请求体大小、连接超时、缓冲区规格。它出问题时一般分两种——直接拦截拒绝,或者静默截断,后者最难排查。
  5. 服务端接收:语言框架把 multipart 解析成临时文件或内存字段,同时做大小、类型、权限校验。
  6. 存储落盘:落地到本地磁盘、对象存储或分布式文件系统。落盘动作决定最终下载 URL、一致性校验和清理任务。
  7. 后续处理:回传地址,触发异步任务,比如缩略图生成、文档解析、病毒查杀等。

这个链路里的每一环都用得上。我通常把它们列成一张表,方便跟后端、运维同学对齐责任边界:

环节客户端关注点服务端关注点
选择文件size/type 预检、内存占用接口权限
组装请求体是否合法解析方式
发送超时、进度、取消连接保持
中间层错误响应是否被识别限制与超时时长
接收响应语义、幂等临时文件存储
落盘回传地址是否正确存储策略与清理
后续处理用户可见文案异步任务状态

1.2 为什么“本地正常、线上失败”如此普遍

本地联调通常是直连后端,没有中间层,没有统一超时,也没有文件大小限制。最有利于隐藏问题的环境,反而就是我们自己的电脑。线上链路每多一跳,就多一类限制。

遇到过的“玄学上传失败”,往往就来自这些差异:

  • 本地文件小,生产环境上传的是几十 MB 的压缩包;
  • 本地超时按快网设置,线上链路带宽低,请求被客户端先掐断;
  • 中间层返回的是默认错误页,状态码却是 200,导致客户端误以为请求成功,随后在 JSON 解析时报错;
  • 文件本身存在但不完整,服务端校验总大小时才发现数据缺块。

另外一个误区是“服务端会告诉我结果”。服务端能告诉你的,只有它已经处理完的那部分;没到达服务端的请求,只能靠客户端、中间层日志和网络排查工具来判断。所以我每次做完上传功能,都会先把这个文件在开发者工具的 Network 面板里完整过一遍。上传排错几乎都是“跨层追踪”,这一点从一开始就要记住。

2. multipart/form-data 表单上传与字段约定

2.1 multipart 为什么是默认选项

表单的 enctype=multipart/form-data 之所以是上传默认方案,是因为 HTTP 把整块内容看成一个长字节流,用 boundary 分隔不同字段。它适合文件与普通参数混排,不要求服务端预先知道文件总长度;浏览器负责把各片段排好格式,服务端按照 boundary 切分就能拿到每个部分。

理解这个格式,可以等价看成:我们手工构造 FormData 时,其实就是在填一份 multipart 文本——先写字段名、文件名、内容块头,空一行,再写入文件字节,然后用同 boundary 分隔下一段。这个格式看着简单,却很容易踩坑。所以我从不让前端自己拼 multipart 字符串,尤其涉及中文文件名、二进制流、换行符时,跨平台差异会让人崩溃。交给浏览器自带能力或者客户端上传库处理,才是正路。

2.2 FormData 的高频用法与容易忽略的细节

单文件上传的最小代码是这样:

const formData = new FormData(); formData.append('file', fileInput.files[0]); formData.append('bizType', 'avatar'); fetch('/upload', { method: 'POST', body: formData });

这段代码里有三个细节经常被忽略:

  • 我没有手写 Content-Type。fetch 或 XHR 检测到 FormData,会自动带上multipart/form-data; boundary=...。如果手动设置Content-Type: multipart/form-data,服务端很可能因为缺少 boundary 而解析失败,表现为后端拿不到字段。
  • 文件名可以用第三个参数修改:formData.append('file', file, 'newName.xlsx')。这能统一服务端看到的文件名,比另传一个字段更可靠。
  • 多文件使用input type="file" multiple,配合循环formData.append('files', item),后端按相同 name 取数组。

2.3 文件名与字符集:中文文件名为什么容易挂

中文文件名在部分服务端框架里会出现乱码,原因是 multipart 的 filename 字段编码方式不统一。现代浏览器会在 Content-Disposition 里同时给出filename和filename*,后者用 UTF-8 编码,带 RFC 5987 标准的filename*=UTF-8''...形式。服务端如果只解析旧式filename,拿到很可能是乱码。

我的经验是:前端主动统一文件名。例如上传前用formData.append('file', file, encodeURIComponent(originalName)),或者定义一个单独的字段存原始文件名,让服务端优先读filename*。更省心的方式是前端约定只传一个临时文件名,如timestamp_random.ext,真正的业务名放到字段或数据库里,避免在传输层跟编码较劲。

2.4 服务端联调前先对齐的三个约定

很多问题不是代码不会写,而是两边契约没说清。我习惯在开发前用一张简短清单对齐:

  • 字段名是什么,是否允许多文件;
  • 大小上限与单个请求超时时间;
  • 成功返回的 URL 与失败返回的错误码结构。

也建议服务端文档直接写“客户端请用 multipart”,不要在 JSON 里放 base64。base64 会把文件内容膨胀约 33%,而且整个文件要读进内存,达到几十 MB 后页面会明显卡顿。multipart 是常规武器,base64 是少数场景的无奈备选。

3. 可控上传:XHR 和 fetch 的进度、取消、重试

3.1 监听上传进度的正确打开方式

如果要做进度条,目前最稳妥的方案还是 XHR 的 upload 事件,而不是 fetch 的常规 Response。示例:

const xhr = new XMLHttpRequest(); xhr.open('POST', '/upload'); xhr.upload.onprogress = (event) => { if (!event.lengthComputable) return; const percent = Math.round((event.loaded / event.total) * 100); renderProgress(percent); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { success(JSON.parse(xhr.responseText)); } else { fail(new Error(`HTTP ${xhr.status}`)); } }; xhr.onerror = () => fail(new Error('network error')); xhr.send(formData);

这里的 loaded 单位是字节,但它的语义是“已被请求体消费的数据量”,不一定等于网络实际发送量。浏览器和底层网络栈可能会有缓冲,导致进度先快速涨到一个值,然后停住不动。这是正常现象,不是 bug。

要注意:进度条显示 100%,只代表客户端已经把请求体完整交给网络栈,不代表服务端已经处理完。如果服务端还要做校验、写盘、异步压缩,接口响应还没返回,用户就会看到“传完了但按钮还在转”。所以在 UI 上,我会把进度条明确处理成“传输进度”,而不是“处理进度”;最终状态以接口返回为准。

3.2 中断请求与局部重试:别把整个文件重传

临时取消或网络断开后,最简单做法是重新发送整个请求。小文件没什么问题,但大文件重传代价很高。更好的模式是:

  • 每次上传生成一个 uploadId;
  • 如果服务端支持分片,客户端按片管理状态;
  • 其中一片失败,进入指数退避队列,而不是立刻并行重发;
  • 整个会话中断后,再次开始时先查询服务端已收到哪些片,只补缺失的。

AbortController 触发中断后,底层连接并不是瞬间释放,有时浏览器会等一段时间。如果立刻重新上传同一个大文件,可能触发并发连接限制,表现成“连续失败几次又好了”。所以我一般会在取消后加一个数百毫秒的冷却期,再允许用户重试。

3.3 fetch 的流式上传思路

fetch 没有原生上传进度事件,但可以用 ReadableStream 作为请求体,在自己的流里统计写入字节数,实现手动进度。大致是这样:

let sent = 0; const stream = new ReadableStream({ start(controller) { const reader = file.stream().getReader(); function push() { reader.read().then(({ done, value }) => { if (done) { controller.close(); return; } sent += value.byteLength; controller.enqueue(value); onSent(sent / file.size); push(); }); } push(); } }); fetch('/upload', { method: 'POST', body: stream, duplex: 'half' });

这种写法适合需要精确节流、自定义加密或模拟弱网的场景。但它依赖浏览器和服务器对请求体流式的兼容程度,不是所有环境都支持。如果没有把握,直接回退到 XHR 是最省事的。

4. 大文件上传:分片、并发与断点续传的思路

4.1 什么时候该分片,分片大小怎么定

分片不是标配。一两 MB 的合同附件、用户头像,完全没有必要。我一般这样判断:

  • 目标环境有明确的单请求体大小上限;
  • 上传超时很短,文件却经常很大;
  • 用户常处在移动端弱网环境;
  • 文件经常到几百 MB,中断重传代价明显。

尤其当单个文件超过了中间层限制时,分片是绕过“单请求体大小上限”的有效手段。

分片大小可以参考一个粗算公式:

单分片大小 = 预估带宽(Mbps) × 单请求容忍秒数 ÷ 8

例如预估带宽 5Mbps,容忍 30 秒,那么单片约 18MB,取整 10MB 更稳。实际按 10MB 切片时,最后一小片会小于 10MB,这是正常的。

并发数我一般控制在 3 到 6,不会开太多。并发太高不仅受浏览器连接数限制,还会让服务端临时目录同时写几十个文件,普通磁盘反而变慢。我的经验是:用户实际网络不比本地回环,宁可少并发、多队列、有重试。

4.2 切片、编号与连续写入的幂等方案

前端切片的本质是定位文件片段,而不是复制。File.slice 返回一个 Blob 引用,不会立即把全部数据读进内存,这很重要。示例:

const CHUNK_SIZE = 10 * 1024 * 1024; const chunks = []; for (let start = 0; start < file.size; start += CHUNK_SIZE) { const end = Math.min(start + CHUNK_SIZE, file.size); chunks.push({ index: chunks.length, blob: file.slice(start, end), size: end - start }); }

服务端保存时,推荐以 uploadId 做临时目录,分片命名为 index.bin,合并时按 index 排序后流式拼接。写入每个分片后,接口立即返回“该片已接收”。合并任务需要一个状态机:等待、合并中、完成、失败。

合并动作要保证幂等:如果同一个 index 分片重复上传,服务端不应该追加两份,而应先判断该 index 记录是否存在,存在则直接返回成功。并行分片到达时,可能出现 index 1 到了、index 0 还没到的情况,这不影响最终结果,只要全部按槽位写,合并前统一排序即可。

4.3 断点续传的关键:已上传片列表要还给客户端

客户端本地记录哪些片成功,只是“假”断点续传,因为换一个浏览器或清缓存就失效。完整的语义可以这样设计:

  • 客户端生成 uploadId,携带文件名、大小、总分片数;
  • 服务端用 uploadId 维护接收状态;
  • 上传中断后,客户端调用查询接口,拿到已存在的 index 列表;
  • 客户端过滤掉已上传片,只重发缺失片;
  • 全部完成后,服务端触发合并,并校验总大小。

还需要约定过期时间。服务端不可能无限保存临时分片,一般 1 到 24 小时未完成就会清理。过期后客户端再续传时,要能识别并重新创建 uploadId。

4.4 秒传功能的边界与客户端哈希计算

秒传的本质是文件指纹去重。客户端先把文件算一个哈希,服务端查询指纹库;如果已存在,直接给 URL,跳过上传。

实现时要注意:对超大文件算完整哈希可能很耗时。所以不要在主线程同步执行,使用异步计算,并给用户显示“正在识别文件”。对几百 MB 的文件,完整 MD5 或 SHA-1 可能要好几秒甚至更久,这时候可以做抽样哈希或者先用大小、前缀、后缀组合判断,再落到完整哈希比对。

不是每个项目都需要秒传。如果文件量不大、用户重复上传概率低,引入指纹库反而增加复杂度。我通常只在文件库产品、资料归档场景里启用。

5. 联调期容易被卡住的问题:请求体、超时与进度异常

5.1 一张症状对照表,帮你少试错

我把遇到过的上传问题整理成了表格,联调时可以直接对照:

症状常见根因处理方向
网络错误,页面没有任何接口状态码接入层在连接写满前断开请求,或连接超时先抓包看是否发出请求;查中间层日志与超时配置
返回 413请求体超过接入层限制修改限制或改成分片上传
返回 200 但 JSON.parse 报错接入层返回了自定义错误页或 HTML 提示看响应头 Content-Type,识别“伪成功”
上传进度长时间 0%请求体没实际发出,或 total 不可计算检查 buffer 数据,调小分片大小
服务端拿到文件比本地小,但客户端显示成功上传被截断,服务端只接受部分数据比对 Content-Length 与文件大小,服务端做完整性校验

有个案例很典型:某项目的接入层在超过限制后会返回一个文本错误页,状态码却是 200,导致 XHR 进入 onload,又因为 JSON 解析失败抛异常。当时前端日志只记到“接口 200”,怎么想都不对劲。排查时我把请求复制出来,在命令行工具里重放,才发现返回的主体是 HTML,才意识到是接入层拦截,而不是业务后端返回 200。

这个教训让我养成习惯:只要状态码与预期不符,先看响应体内容,不要默认接口就是后端业务逻辑。

5.2 排障顺序:先确认“发没发出去”,再查“收没收到”

具体调试顺序可以参考:

  1. 在开发者工具 Network 面板里找到上传请求,确认 URL 和 Method。
  2. 看 Request Headers 里是否有 Content-Length 或 Transfer-Encoding。
  3. 看 Payload,FormData 里是否真的带有文件块内容。
  4. 看 Status Code 和 Response,尤其关注内容类型是 JSON 还是 HTML。
  5. 把请求复制成命令行格式,在终端里不带额外设置地重放一次,比较能否复现。

对于超时设置,前端超时应该大于期望的最大分片传输时间,不能因为“默认 30 秒”就觉得合理。一个总量大、带宽低的环境,30 秒只够传很小的文件。在放弃前,至少给用户一次“重试并续传”的机会。

6. 上传功能的端到端测试与可观测性

6.1 测试别只靠“选一个大文件”

把不同网络和环境下的上传都试一遍,比单测一个大文件有用得多。我常用的方法:

  • 在开发者工具的网络限速里选慢速网络,测试断线时重试是否符合预期;
  • 用临时 Blob 在内存中生成测试文件,避免每次在磁盘上找文件。例如new Blob([new Uint8Array(5 * 1024 * 1024)]);
  • 部署后做一个合并结果校验,把本地 SHA-256 与服务端最终文件 SHA-256 对齐,能及时发现传输截断、编码污染等问题;
  • 对分片组件,刻意把并发数调成 1 再调成 8,对比耗时和内存曲线,找到适合环境的并发数。

测试时还要留意浏览器内存。大文件切片虽说是引用,但并发请求太多时,每个分片都会在内存里被读写,内存曲线如果持续上涨,就要降低并发数或改用串行发送。

6.2 该埋哪些点:传输耗时、吞吐量、失败码

上传功能上线后,用户反馈“慢”,需要量化才能定位。我一般会埋这几个点:

  • 用户选择文件时刻 selectedAt,开始上传时刻 startAt;
  • 每条分片或整包请求的发送字节数、耗时、状态码、重试次数;
  • 页面会话的总耗时,以及实际吞吐率。吞吐率可以用文件字节数 / 总传输耗时近似估算,这个值会偏保守,因为包含服务端处理和等待时间,但做趋势对比足够;
  • 失败分布:取消、超时、JSON 解析错误、网络错误、HTTP 非 2xx 分别计数。不上量的时候,根本不知道哪一类问题最频繁。

上报本身不能干扰主上传流程。我在项目里会把日志先推进内存队列,页面空闲时再统一上报;如果上传本身就失败,至少还能留下这批日志,方便后续追查。

6.3 上线前给自己留一份检查清单

我的个人习惯是,每接一个新的上传需求,先跟项目方对齐四个数字:最大文件大小、单请求超时、并发分片数、重试次数上限。然后先把基础原型跑通,再考虑加入进度条、断点续传和秒传。顺序反了,很容易在谈优化的时候,连最基本的“服务端到底有没有收到”都没确认。

这块做的内容多了以后,你会慢慢形成自己的清单:上传前校验、请求体格式、进度展示、中断恢复、响应语义、日志埋点、合并一致性。这份清单比任何框架都可靠。毕竟客户端文件上传的难点从来不在“发一个请求”,而在链路里的每一个“为什么”——为什么用 multipart、为什么进度会停、为什么本地正常线上失败、为什么分片要幂等。把这些想明白,遇到新环境也只是换一层壳而已。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进