从 HTTP 到存储:文件上传的底层原理
前端写文件上传,经常只记住三行代码:
js
const formData = new FormData()
formData.append('file', file)
await fetch('/api/upload', { method: 'POST', body: formData })
这三行能跑,但不解释任何原理。HTTP 并没有一个叫「文件上传」的独立功能。所谓上传,不过是:客户端把一段字节序列,按双方约定的格式塞进 HTTP 请求体;服务器再按同样的约定拆出来、校验、存起来。
网络层不关心这些字节是 PNG、MP4 还是 zip。文件的含义,是客户端、请求格式、服务端业务逻辑一起赋予的。
后面无论学分片、断点续传,还是对象存储直传,都建议先把这三个区别想清楚:
-
FormData不等于文件本身 -
multipart 的
boundary不等于 HTTP 消息边界 -
HTTP 传输完成,不等于文件已经持久化成功
还有一个更常见的混淆:Base64、Multipart、分片上传经常被放进同一张「上传方式」表里。它们看起来都像在回答「文件怎么传」,其实解决的是不同问题。本文会按三个相互独立的维度来讲:
| 维度 | 它在回答什么 | 典型概念 |
| --- | --- | --- |
| 数据表示层 | 文件内容在请求里长什么样 | 原始字节、Base64、Hex、文本 |
| 请求体组织层 | 整个 HTTP 请求体如何把文件和其它数据放在一起 | 直接请求体、JSON、multipart、自定义封装 |
| 传输策略层 | 一次传完,还是拆开传、边读边传 | 整体上传、HTTP chunked、分片、断点续传、流式上传 |
Blob、ArrayBuffer、FormData、Content-Type 和分片接口,分别落在不同层。先把层分开,后面的例子才不会互相打架。
先建立一张总图
一次典型上传,大致走这条链路:
text
浏览器 / 前端
选择文件、读取元信息、构造请求
│
▼
HTTP 请求头 + 请求体
│
▼
Web 服务器 / 后端
接收请求、解析请求体、校验文件
│
▼
本地磁盘 / 对象存储(S3、OSS、COS...)
这里还要分开三个概念:
| 层次 | 它负责什么 | 它不负责什么 |
| --- | --- | --- |
| HTTP | 如何表达请求、如何传输、如何给响应 | 不会自动把请求体存成文件 |
| 上传约定 | 例如 multipart/form-data 如何组织字段和文件 | 不会决定文件最终放哪 |
| 存储系统 | 文件最终落在磁盘还是对象存储 | 不理解业务上的「上传成功」 |
HTTP 不会自动理解文件名,也不会自动把字节写成 avatar.png。解析和存储,都是应用层自己做的。上面的三层模型,主要发生在「构造请求」到「服务端解析」这一段。
第一层:浏览器里的文件,究竟是什么?
用户点了「选择文件」之后
页面上通常是一个:
html
<input type="file" />
用户选中 avatar.png 后,浏览器不会立刻 把整个文件读进 JavaScript 内存。它先给页面一个 File 对象,表示「这段由浏览器管理的、不可变的二进制数据」,再附上文件名等元信息。
js
const input = document.querySelector('input[type="file"]')
input.addEventListener('change', () => {
const file = input.files[0]
console.log(file.name) // avatar.png
console.log(file.size) // 字节数
console.log(file.type) // 浏览器推断的 MIME 类型
})
File 继承自 Blob。常见属性是:
| 属性 | 含义 |
| --- | --- |
| name | 文件名,不含用户本地完整路径 |
| size | 文件字节数 |
| type | MIME 类型,例如 image/png |
| lastModified | 最后修改时间的时间戳 |
注意:file.type 不是安全校验结果。它可能为空,也可能只是根据扩展名推断出来的。不能凭它断定文件真的是 PNG。
文件如何变成字节
假设有一个极小的文本文件,内容是:
text
Hi
在 UTF-8 下,对应字节是:
text
48 69 0A
# H i 换行
图片、视频、压缩包也一样,本质都是字节序列,只是各自遵循不同的文件格式。浏览器拿到 File 之后,还没有「上传」。真正进入 HTTP 之前,先要决定:这些字节以什么形式出现在请求体里。
第二层:文件内容如何表示
这一层只关心一件事:文件数据本身,是原样放进请求,还是先变成文本。
原始字节传输
不需要文本编码。文件字节直接进入 HTTP 请求体,不必先转成 Base64 或十六进制。
| 子类型 | 说明 | 典型 Content-Type |
| --- | --- | --- |
| 通用二进制 | 告诉服务器「这是一段字节」 | application/octet-stream |
| 文件专用 MIME | 进一步声明具体格式 | image/png、application/pdf |
| 自定义二进制类型 | 由服务端定义专用格式 | 业务约定的类型 |
对 Hello 五个字节来说,请求体里就是:
text
48 65 6C 6C 6F
典型场景是直接上传单个文件。浏览器通常也不需要先把整个文件读成 JS 字符串,可以直接使用 File / Blob 提供的二进制数据。
文本编码传输
把文件字节转换成文本字符,再放入请求体。接口如果明确要求「字符串形式的文件内容」,就会走到这一层。
| 子类型 | 做法 | 体积变化 | 示例 |
| --- | --- | --- | --- |
| Base64 | 每 3 个字节通常编码为 4 个字符 | 约增加 33% | SGVsbG8= |
| 十六进制 Hex | 每个字节写成 2 个十六进制字符 | 约翻倍 | 48656c6c6f |
| 百分号编码 | 部分字节写成 %XX | 取决于内容 | Hello 或 %XX |
| 直接传文本 | UTF-8 文本文件直接作为请求体或 JSON 字段 | 基本不膨胀 | Hello |
Base64 很常见,但不是上传的默认路径。网页表单上传通常走原始字节;只有接口把文件当成字符串字段时,才需要先编码。
原始字节和文本编码,描述的是文件数据的表示方式。它们还没有回答:整个请求体里,除了文件,还怎么放用户名、文件名和其它业务参数。
第三层:HTTP 请求体如何组织
文件数据可以是原始字节,也可以是 Base64。接下来要决定:这些数据如何装进一个 HTTP 请求。
| 类型 | 子类型 | 说明 |
| --- | --- | --- |
| 单一数据体 | 直接二进制请求体 | 请求体直接放文件字节 |
| 单一数据体 | 直接文本请求体 | 请求体直接放文本 |
| 结构化数据体 | JSON | 文件内容或文件引用放在 JSON 字段中 |
| 结构化数据体 | multipart/form-data | 多个字段通过 boundary 组织 |
| 结构化数据体 | 自定义封装 | 按业务协议组织元数据与文件数据 |
网页表单上传最常见的是 multipart/form-data。直接传二进制也可以,但服务器必须另外知道:这段原始数据怎么识别、文件名从哪来。
为什么需要 multipart?
假设一次要提交用户名 jerry、头像 avatar.png、年龄 25。服务器必须知道:哪一段字节是用户名,哪一段是文件,哪一段是年龄。multipart 的思路很直接:给每个字段加边界和描述信息。
一个示意请求大概长这样:
http
POST /api/upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----UploadBoundary123
Authorization: Bearer your-token
------UploadBoundary123
Content-Disposition: form-data; name="username"
jerry
------UploadBoundary123
Content-Disposition: form-data; name="file"; filename="avatar.png"
Content-Type: image/png
<这里是 avatar.png 的原始二进制字节>
------UploadBoundary123--
真实请求里,文件部分是原始字节,不是上面这种文字占位符。注意:multipart 内部可以放原始字节,也可以放文本字段。所以它属于「请求体怎么组织」,不属于「文件是不是 Base64」。
| 请求头 | 作用 |
| --- | --- |
| Content-Type | 整个请求体采用什么格式 |
| Content-Length | 如果存在,表示整个 HTTP 消息体长度(字节) |
| Authorization | 常用于携带认证凭据 |
| Host | HTTP/1.1 中标识目标主机 |
其中最重要的是:
http
Content-Type: multipart/form-data; boundary=----UploadBoundary123
这句话不是 在说「我正在上传图片」,而是在说:我的请求体采用 multipart/form-data 格式,各个部分由指定的 boundary 分隔。对应规范是 RFC 7578。
Boundary 到底是什么?
如果请求头是 Content-Type: multipart/form-data; boundary=abc123,请求体里实际会出现:
http
--abc123
Content-Disposition: form-data; name="username"
jerry
--abc123
Content-Disposition: form-data; name="file"; filename="a.png"
[文件字节]
--abc123--
几个容易混的细节:
-
请求头里的
boundary=abc123是边界值 -
请求体分隔符要写成
--abc123 -
最后一个分隔符再多两个
-,表示 multipart 内容结束 -
分隔符前后还要遵循规范里的换行规则
如果文件内容碰巧包含 --abc123 怎么办?规范要求发送方选择一个不会和封装内容冲突 的 boundary。浏览器会自动生成它,前端一般不用自己设计。服务端也不是对整个请求体做一次字符串 split(),而是按 multipart 语法解析边界、Part 头部和二进制内容。
一个非常常见的坑:不要手动写 Content-Type
下面这种写法经常把上传写坏:
js
const formData = new FormData()
formData.append('file', file)
await fetch('/api/upload', {
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data',
},
body: formData,
})
原因是:浏览器需要在请求头里写入它实际生成的 boundary,例如:
http
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXYZ
请求体也必须使用同一个 boundary。你手动设了 Content-Type,却没带上正确的 boundary,服务器就可能解不开请求体。正确做法是把 Content-Type 交给浏览器:
js
await fetch('/api/upload', {
method: 'POST',
body: formData,
})
MDN 的 Using FormData Objects 也是这个思路。
文件字节在哪?不在请求头里
很多初学者会以为:文件被放进了 HTTP 请求头,或者 FormData 会把文件转成 JSON。实际上,文件数据主要在 **HTTP 请求体(Request Body) **。HTTP 并不要求请求体必须是文本。它可以是二进制、JSON、表单编码,也可以是 multipart。
HTTP 消息边界 ≠ multipart 边界
| 边界 | 它在回答什么 |
| --- | --- |
| HTTP 消息边界 | 整个请求体在哪里结束 |
| multipart boundary | 这一个请求体内部,各个 Part 从哪里切开 |
在 HTTP/1.1 里,消息体长度可以用 Content-Length 确定;有些请求也会用分块传输编码(Transfer-Encoding: chunked)表示「消息体传完了」。但 chunked 的传输分块,不是业务上的文件分片。它们已经进入第三维度:传输策略。一个 100 MB 文件,即使按 HTTP/1.1 chunked 发送,仍然可能只是一个完整的 multipart 文件 Part。HTTP/2、HTTP/3 有各自的帧机制,同样不会自动实现断点续传。
网络负责把字节送到对端;multipart 负责在一个请求体里切开字段;分片上传是应用自己设计的协议。
第四层:服务端如何从请求里抽出文件?
前端发出去的不是一个现成的 JavaScript File 对象。服务端拿到的是网络上传过来的 HTTP 数据,然后自己解析。常见四步:
-
接收 HTTP 请求:解析请求行、请求头,确定请求体怎么读
-
解析请求体:单一数据体就直接读字节;multipart 则按 boundary 找到各个 Part
-
校验文件:权限、大小、格式、业务规则
-
写入存储:落到磁盘、对象存储,或其他存储系统
如果请求体是 JSON + Base64,服务端要先 JSON 解析,再 Base64 解码,才能得到原始文件字节。如果是 application/pdf 的单一数据体,则请求体本身就是文件。解析方式和前面的「表示方式 + 组织方式」是对应的。
为什么后端需要 multer 这类中间件?
以 Node.js + Express 为例,前端提交:
js
const formData = new FormData()
formData.append('username', 'jerry')
formData.append('file', file)
await fetch('/api/upload', {
method: 'POST',
body: formData,
})
后端可以这样接:
js
import express from 'express'
import multer from 'multer'
const app = express()
const upload = multer({
dest: 'uploads/',
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB
files: 1,
},
})
app.post('/api/upload', upload.single('file'), (req, res) => {
console.log(req.body.username)
console.log(req.file)
res.json({ message: '上传成功' })
})
app.listen(3000)
这里:
-
upload.single('file')表示从名为file的 multipart 字段里提取一个文件 -
req.body.username是解析出来的普通字段 -
req.file是中间件整理好的文件元信息 -
dest: 'uploads/'指定落盘目录,具体存储方式由中间件配置决定
multer 不是 HTTP 协议的一部分,只是应用层的 multipart 解析器。没有它,后端也可以自己解析,但要正确处理 boundary、二进制、编码、异常请求和资源限制,复杂度会高很多。
req.file 是什么?
上传 avatar.png 后,req.file 可能类似:
js
{
fieldname: 'file',
originalname: 'avatar.png',
encoding: '7bit',
mimetype: 'image/png',
destination: 'uploads/',
filename: '8f3a...c21',
path: 'uploads/8f3a...c21',
size: 58231
}
这只是示意结构,具体字段取决于中间件版本和存储配置。更重要的是:req.file 通常不是完整图片的字节数组,而是一份描述对象。文件内容可能已经写入磁盘,也可能暂存在内存,或者交给了别的存储适配器。后端拿到它之后,才决定如何保存、重命名、检查内容、生成访问地址。
第五层:文件如何真正保存下来?
「上传成功」不等于「文件已经进数据库」。大多数系统会把文件内容 和业务元数据分开保存。
存到服务器本地磁盘
例如写入 /app/uploads/8f3a-c21.png。操作系统通过文件系统接口把字节写进对应文件。文件系统负责目录、文件元数据、磁盘空间和数据块。Node.js 里,概念上可以写成:
js
import { createWriteStream } from 'node:fs'
const output = createWriteStream('/app/uploads/avatar.png')
output.write(buffer)
output.end()
生产环境还要处理流错误、背压、权限、重名和写入完成事件。这里有一个很容易被忽略的区别:
**接收完成 ≠ 存储完成。 **
请求体的全部字节都到了,不代表文件已经可靠地写进最终位置。磁盘可能满了,写入可能失败,对象存储请求也可能报错。后端应该在必要的校验和存储都成功之后,再返回业务上的「上传成功」。
存到对象存储
大量图片、视频、附件,通常不会长期依赖单台 Web 服务器的本地磁盘。更常见的是对象存储,例如 Amazon S3、阿里云 OSS、腾讯云 COS。基本模型是:
-
一个 Bucket:存储空间
-
一个 Object Key :对象唯一标识,例如
avatars/user123/abc.png -
一份 对象内容:实际文件字节
-
一组 元数据:大小、内容类型等
两条常见路径:浏览器把文件传给后端,后端再上传到对象存储;或者后端签发临时凭证,浏览器直接把文件传到对象存储。第二种能减轻应用服务器的带宽压力,但权限、回调、一致性都要自己设计。
为什么数据库通常只存地址?
数据库里往往是这样一条记录:
json
{
"id": 101,
"userId": 123,
"fileName": "avatar.png",
"objectKey": "avatars/123/abc.png",
"size": 58231,
"contentType": "image/png"
}
objectKey 指向对象存储里的文件,而不是文件本身。好处很直接:文件内容和业务数据分离;文件可以通过 CDN 或对象存储分发;数据库不必承担大量图片、视频字节的存储和传输;权限、生命周期、删除策略、访问链接可以单独控制。数据库当然也能用 BLOB 直接存二进制,只是对很多大文件场景,对象存储更容易扩展。
第六层:传输策略,以及为什么大文件要换打法
前面两层决定了「文件长什么样、请求体怎么装」。这一层决定「怎么把这些字节送过去」。
| 类型 | 子类型 | 说明 |
| --- | --- | --- |
| 整体上传 | 单次请求上传完整文件 | 适合较小文件及普通场景 |
| 分块上传 | HTTP 分块传输编码 | HTTP/1.1 的消息传输机制,不等于文件分片 |
| 分片上传 | 文件拆成多个独立请求 | 常用于大文件、并发上传 |
| 可恢复上传 | 分片上传 + 进度记录 | 网络中断后可继续上传未完成部分 |
| 流式上传 | 边读取边发送 | 不一定需要先把整个文件载入内存 |
以 HTTP/1.1 over TCP 为例,数据路径可以理解为:
text
JavaScript / Fetch
传入 File、Blob 或 FormData
│
▼
浏览器网络实现
编码请求头和请求体,准备发送
│
▼
HTTP 协议层
组织 HTTP 消息,确定消息体边界
│
▼
TCP / IP / 网络链路
分段、传输、重组、保证可靠
│
▼
服务端网络栈和 HTTP 服务器
接收数据,交给应用处理
这是逻辑分层,不代表浏览器一定会先在内存里拼出一个巨大数组再一次性发出去。实际实现可以用缓冲区、分块读取、流式发送。TCP 也不认识「文件」,它只负责在连接上可靠地传输字节流。
为什么大文件需要分片、断点续传、秒传?
假设要上传一个 2 GB 视频。如果用最简单的单次 HTTP 上传,整个文件会作为一个请求的主体发出去。中途失败后,能不能从 73% 接着传,取决于你有没有设计额外的续传机制。**HTTP 本身不会因为请求失败,就自动记住上传进度。 ** 这就是业务层分片上传存在的原因。
文件 100 MB,每片 5 MB,前端可以拆成 20 片。JavaScript 的 Blob.slice() 可以引用文件的指定范围:
js
const chunkSize = 5 * 1024 * 1024
const totalChunks = Math.ceil(file.size / chunkSize)
for (let index = 0; index < totalChunks; index++) {
const start = index * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
// 把 chunk 作为独立请求上传
}
这段代码只演示如何切片,还没实现上传、重试和合并。一个 20 MB 文件按 5 MB 分片,就是:
| 分片 | 范围 |
| --- | --- |
| 0 | 0--5 MB |
| 1 | 5--10 MB |
| 2 | 10--15 MB |
| 3 | 15--20 MB |
每个分片都可以单独上传、校验、重试,最后再按正确顺序组合。slice() 通常不会立刻把整个分片复制成一个新的 JS 数组,它返回的是一个新的 Blob 视图。数据何时真正读取、复制,由浏览器实现决定。
分片上传通常需要哪些接口?
一套常见的应用层设计是:
| 接口 | 作用 |
| --- | --- |
| POST /uploads/init | 创建上传任务,返回 uploadId |
| PUT /uploads/:id/parts/:index | 上传指定分片 |
| POST /uploads/:id/complete | 校验全部分片并完成合并 |
典型流程:浏览器计算文件大小、分片数量,创建上传任务;服务端生成 uploadId,记录元信息和状态;浏览器逐片或并发上传分片;服务端保存各分片,并记录哪些已经成功;中断后,浏览器查询已完成分片,只补传缺失的;全部分片到齐后,服务端按索引合并,并做完整性校验;确认最终文件可用,再返回上传成功。这是应用自己设计的协议,不是浏览器自动提供的 HTTP 功能。
断点续传为什么能工作?
关键在于服务端保存了上传进度。例如:
text
uploadId: abc123
已完成分片: [0, 1, 2, 4, 5]
缺失分片: [3, 6, 7, 8, 9]
页面重新打开后,客户端可以查询这个状态,只上传缺失分片。如果服务端没有记录,客户端就无法可靠知道:哪些分片已经持久化,哪些还得重发。生产系统还要考虑:任务过期、分片重复提交、并发竞争、权限校验,以及服务端崩溃后的状态恢复。
秒传不是「什么都不传」
秒传通常不是凭空完成上传,而是服务端发现对应内容已经存在,可以直接复用。常见做法是:客户端计算文件的 SHA-256,把哈希交给服务端。
text
文件 A → SHA-256 → abcdef...
文件 B → SHA-256 → abcdef...
如果服务端确认已有相同内容,并且当前用户有权限引用它,就可以直接创建新的文件记录,不必重复传输全部字节。但哈希相同,本身不等于「可以安全复用」。还要结合可靠的哈希算法、已有对象的完整性校验、权限和业务规则。
同一个 PDF,四种完全不同的上传
分类是否站得住,最好用同一个文件来检验。假设要上传一份 report.pdf,下面四种组合都能完成这件事,只是三层维度的取值不同。
组合 A:原始字节 + 单一数据体 + 整体上传
http
Content-Type: application/pdf
[PDF 原始字节]
组合 B:原始字节 + Multipart + 整体上传
http
Content-Type: multipart/form-data; boundary=...
[文件字段 + PDF 原始字节 + 其他表单字段]
组合 C:Base64 + JSON + 整体上传
json
{
"fileName": "report.pdf",
"data": "JVBERi0xLjc..."
}
组合 D:原始字节 + 分片上传 + 可恢复传输
把 PDF 切成多个二进制片段,分别上传,最后由服务端合并或确认完成。
同一份文件,可以是原始字节,也可以先变成 Base64;可以单独占据整个请求体,也可以塞进 multipart 或 JSON;可以一次传完,也可以拆成多次请求。遇到新接口时,先问三句话就够了:文件在请求里是字节还是文本?请求体是单一数据还是结构化数据?这次是整体传、分片传,还是边读边传?
进度条为什么会骗人?
前端常要显示:
text
正在上传:65%
这个百分比,通常只表示:客户端已经发送、或上传组件已经报告的字节量 ,占待上传数据量的比例。真正要区分三个状态:浏览器已经发送了多少字节;服务器已经接收了多少字节;服务器已经成功校验并持久化了多少字节。这三个数字不一定相同。客户端可能已经把数据全部发出去了,服务端还在写磁盘,或者还在做病毒扫描。此时进度条到 100%,并不代表文件已经可用。传统浏览器上传里,相对可靠的客户端进度可以用 XMLHttpRequest:
js
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/upload')
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100)
console.log(`客户端上传进度:${percent}%`)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
console.log('服务器返回成功响应')
} else {
console.error('上传请求失败')
}
}
xhr.onerror = () => {
console.error('网络错误')
}
const formData = new FormData()
formData.append('file', file)
xhr.send(formData)
这只是浏览器报告的上传进度。如果要展示服务端真实处理进度,还需要状态查询接口,或其他进度通知机制。
从原理上看,上传最容易踩的坑
理解传输和存储之后,还要理解上传接口的安全边界。
1. 不能信任客户端提供的文件名
客户端完全可以把文件名改成:
text
../../config.json
如果服务端直接拿用户提交的文件名去拼接磁盘路径,就可能造成路径穿越。更稳妥的做法是:由服务端生成存储名(例如 UUID),原始文件名只作为经过校验的展示元数据保存。
2. 不能只检查扩展名或 MIME 类型
用户可以把任意内容命名为 avatar.png,也可以手动伪造 Content-Type: image/png。服务端需要按业务风险做格式识别、大小限制和必要的内容检查。对公开提供下载的文件,还要考虑恶意文件、浏览器内容嗅探、访问权限。RFC 7578 也明确提醒:multipart 文件可能携带任意可执行内容,必须采取安全措施。
3. 上传成功,不代表文件已经可以安全访问
一个完整的上传业务,往往要经过:身份认证与上传权限检查;请求体大小限制;文件格式及内容校验;文件持久化;数据库元信息写入;访问权限与下载地址生成。例如:文件已经写入对象存储,但数据库记录创建失败,就会留下一个没有业务引用的孤立文件。实际系统还需要失败补偿、清理机制,以及重复请求的幂等性。
把整条链路串起来
-
选择文件 :浏览器创建
File对象,提供大小、名称,以及访问二进制内容的能力。 -
决定表示方式:原样传输字节,还是先编码成 Base64 / Hex / 文本。
-
组织请求体:直接把数据放进请求体,或放进 multipart、JSON、自定义封装。
-
选择传输策略:一次传完、流式发送,或拆成多个可恢复的分片请求。
-
服务端解析 :按
Content-Type和约定格式抽出文件字节。 -
校验并存储:检查权限、大小和内容,再写入本地磁盘或对象存储。
-
返回业务结果:确认必要处理成功后返回响应。前端根据响应判断这次上传业务是否完成。
各层职责可以记成一张表:
| 层次 | 核心职责 |
| --- | --- |
| 浏览器 API | 获取并引用文件内容 |
| 数据表示 | 决定文件以原始字节还是文本进入请求 |
| 请求体组织 | 描述文件与普通字段如何放在一起 |
| 传输策略 | 决定整体上传、分片还是流式发送 |
| HTTP | 传输请求、表达消息边界与响应 |
| TCP / IP 等传输机制 | 在网络中传输字节 |
| 后端解析器 | 从请求体中提取文件 |
| 存储系统 | 持久化文件字节 |
| 业务逻辑 | 决定谁能上传、文件是否合规、何时算成功 |
写在最后
如果只记住一句,就记住这句:
FormData不等于文件本身;multipart 的 boundary 不等于 HTTP 消息边界;HTTP 传完也不等于文件已经存好。
再补一句分类口诀:
数据怎么表示、请求体怎么组织、传输怎么拆,是三件独立的事。Base64 不是 Multipart 的替代品,HTTP chunked 也不是文件分片。
把这几条边界分清楚,再去看 Blob、ArrayBuffer、对象存储直传和进度条,就不会再把「协议细节」和「业务设计」搅在一起。