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

资讯详情

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

axios上传文件:multipart/form-data与Content-Type边界符详解

axios上传文件:multipart/form-data与Content-Type边界符详解 上周有个做后台管理系统的朋友跑来问我用 axios 发 post 请求上传文件到后端后端一直抛org.springframework.web.multipart.MultipartException请求根本进不到业务代码里。我盯着他发来的报错截图第一反应就是去看 Network 面板的 Request Headers果然他把Content-Type手动写成了application/x-www-form-urlencoded文件字节流根本没地方放后端自然解析不到 multipart 数据。这种问题太典型了已经见过好几个前端同学踩过。axios 配合 FormData 做文件上传看起来就是几行代码的事但里面涉及 multipart/form-data 这个编码格式的底层逻辑、前后端参数对齐、Content-Type 的边界符、甚至跨域预检请求任何一个环节没理顺都会出现前端觉得发了、后端觉得没收到的尴尬局面。这篇文章我会从前端开发者的视角把 axios 发送 post 请求上传文件的完整链路讲透为什么文件上传绕不开 multipart/form-data、FormData 的正确构造方式、后端以 Spring Boot 为例通常怎么接收、以及我在实际项目中踩过的高频坑和排查思路。适合刚接触前后端分离项目、想把手头的上传功能真正做扎实的开发者也适合需要和后端对齐接口时看得懂对方代码的情况。1. 为什么文件上传必须用 multipart/form-data从一次报错排查说起1.1 一次请求根本到不了业务代码的报错先把朋友当时的情况完整还原一下。他有一个 Vue 项目后端是 Spring Boot需求是上传 Excel 文件然后后端解析入库。他的代码大概是这样的import axios from axios const formData new FormData() formData.append(file, document.querySelector(#fileInput).files[0]) axios.post(/api/upload, formData, { headers: { Content-Type: multipart/form-data } })看起来是不是非常标准网上一搜 axios 上传文件十篇有八篇是这么写的。但就是这种标准写法害了不少人。他遇到的报错是后端直接抛 MultipartExceptionSpring 在进入 Controller 方法之前就把请求拦下来了连他自己写的日志都没打印。我让他打开浏览器的 Network 面板查看这次请求的 Request Headers截图发给我。问题一下就暴露了他确实设置了Content-Type: multipart/form-data但是后面没有boundary----WebKitFormBoundaryxxx这一段。关键就在这里。multipart/form-data 这个 Content-Type 本身只是一个声明它必须配合 boundary 参数才能正常工作。boundary 是分隔符用来切分请求体里各个字段和文件内容。你把请求头发成multipart/form-data却不带 boundary后端拿到请求体根本不知道该用什么字符串去分割里面的文件块自然直接报错。1.2 HTTP 请求体里的两种组织方式JSON 与 multipart要真正理解这个问题得回到 HTTP 请求体本身。一个 POST 请求的 body 其实就是一串字节服务器拿到这串字节后靠的就是请求头里的Content-Type来决定怎么拆这串字节。先看 JSON。接口调用最多的形式Content-Type: application/json {name:test.xlsx,size:10240}JSON 是纯文本格式适合传递结构化的简单数据优点是可读性强、解析快。但文件不是文本它是二进制数据。如果硬要把文件塞进 JSON最常见的做法是把文件转成 base64 字符串再放进去。这会有两个问题一是体积膨胀约 33%上传一张 10MB 的图变成 13MB 左右的字符串二是后端拿到这种数据后还需要额外解码、二次处理非常别扭。再看 multipart/form-data。它是专门为一个请求里同时包含多个不同类型字段设计的。一个典型的 multipart 请求体长这样Content-Type: multipart/form-data; boundary----WebKitFormBoundary7MA4YWxkTrZu0gW ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; namebizType user-avatar ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; namefile; filenameavatar.jpg Content-Type: image/jpeg 这里是文件的二进制原始字节 ------WebKitFormBoundary7MA4YWxkTrZu0gW--可以看到整个 body 被 boundary 分成多个 part每个 part 有自己的Content-Disposition里面带上了表单字段名。文件所在的 part 还会有 filename 和文件自己的 Content-Type。文件内容直接放原始二进制不做 base64 编码传输效率高。所以JSON 适合传结构化文本数据multipart 适合传表单数据 文件这种混合体。文件上传这个场景multipart/form-data 就是浏览器和服务器共同认可的标准方案没必要绕开它也绕不开。1.3 Content-Type 是前后端之间的合同再深一层Content-Type的本质是请求方给接收方的一份解析合同。你告诉后端我是 multipart/form-data后端就会按 multipart 的规则去拆你告诉它我是 application/json后端就会按 JSON 去解析。在 Spring Boot 里如果接口方法参数写的是RequestBody SomeDto dto那它期望接收 JSON 文本请求头必须是 application/json如果写的是RequestParam(file) MultipartFile file那它期望接收 multipart 格式请求头必须是 multipart/form-data。前后端如果对不上这份合同最常见的现象就是后端不报错但 file 参数是 null或者后端直接解析失败抛MultipartException、HttpMessageNotReadableException。这也就是为什么很多新手觉得我明明上传了文件后端却说没收到的根源——不是没收到是收到了但用自己的解析器拆不开。理解了这一层后面写代码就有的放矢了。2. axios FormData 上传文件的完整写法参数、边界值与进度监听2.1 三行代码跑通最简上传先给出一份最干净、最不会出错的写法import axios from axios const fileInput document.querySelector(#fileInput) const file fileInput.files[0] const formData new FormData() formData.append(file, file) formData.append(bizType, user-avatar) axios.post(/api/upload, formData)你没看错最关键的一点就是axios 传 FormData 对象时完全不需要手动设置 Content-Type。浏览器在底层执行XMLHttpRequest.send(formData)的时候会自动生成一个带有 boundary 的Content-Type并把整个 body 按 multipart 格式组装好。你手动设置反而容易画蛇添足。这里有几个容易忽略的细节。第一formData.append的键名必须和后端接口的参数名一致。比如后端接口是RequestParam(file) MultipartFile file那你前端就必须append(file, file)。你写append(files, file)后端拿到的就是 null。第二File 对象的来源不止input typefile一种。拖拽上传时可以从event.dataTransfer.files拿前端用 canvas 裁剪图片后可以把 Blob 直接 append 进去甚至可以用new File([blob], filename.png, { type: image/png })手动构造一个文件对象。FormData 的 append 方法接受 Blob 和 File 两种类型只要是二进制文件相关的对象都能作为 multipart 的一部分发出去。第三如果你用formData.append(file, file)上传时file 对象里已经有用户选择的文件名后端getOriginalFilename()拿到的就是这个文件名。如果你 append 的是 Blob浏览器会默认文件名可能是 blob这时候最好在 append 时手动指定文件名formData.append(file, blob, custom-name.png)2.2 为什么 Content-Type 不要手动设置我把这一点单独拿出来说因为它确实是上传文件出错率最高的一环。很多人在网上抄代码看到headers: { Content-Type: multipart/form-data }就顺手用上。这条代码在 axios 里是有效的但恰好会引发问题。原因前面讲过xhr 在 send(FormData) 时如果开发者没有自己设置 Content-Type它会自动补全为带 boundary 的完整值可你一旦手动设置了浏览器就会原样使用你给的这个值boundary 信息就丢了。后端拿到没有 boundary 的 multipart 请求等于拿到一个没有分隔符的压缩包根本解不开。所以我的建议非常简单// 推荐 axios.post(/api/upload, formData) // 不推荐 axios.post(/api/upload, formData, { headers: { Content-Type: multipart/form-data } })唯一需要设置 headers 的是你在 axios 实例的请求拦截器里统一注册了 Content-Type并且类型固定为 application/json 的情况。这时候需要在拦截器里做个判断这个我在第 4 章详细讲。2.3 超时、进度监听与常见回调坑文件上传通常比普通接口慢所以 axios 默认的timeout需要调大。我一般会给上传接口单独配置axios.post(/api/upload, formData, { timeout: 120000, onUploadProgress: (event) { if (event.total 0) { const percent Math.round((event.loaded / event.total) * 100) console.log(上传进度${percent}%) } } })onUploadProgress是 axios 暴露的上传进度回调底层对应的是 XMLHttpRequest 的upload.onprogress事件。event.loaded表示已经上传的字节数event.total是总字节数。这里有三个容易踩的坑一是总字节数是整个 multipart body 的大小包含了文件字节和所有额外表单字段所以进度到 100% 时往后会有一小段真空期其实是后端在处理文件。别在 100% 时立刻跳转页面或关闭 loading要等响应回来。二是不要以为进度到了 100% 后端就成功了。进度 100% 只代表浏览器已经把请求发完不代表后端已经处理完。特别是后端要做文件解析、写入数据库这种耗时操作时接口响应可能还会有一段延迟。所以正确做法是用接口响应来控制 UI而不是用上传进度控制 UI。三是如果你在 axios 配置里写了transformRequest注意别把 FormData 对象转成 JSON 字符串。axios 的transformRequest默认会对对象做序列化但 FormData 是特殊结构axios 内部是有判断的。你自己写了 transformRequest 再手动 JSON.stringify就等于把 multipart 格式毁了。3. 后端怎么接收 multipart以 Spring Boot 为例对齐参数与大小限制3.1 MultipartFile 与 RequestParam 的默认约定虽然读者主要是前端但了解后端怎么接收能帮你更快定位问题。Spring Boot 里最简单的一个上传接口长这样RestController public class FileController { PostMapping(/api/upload) public Result upload(RequestParam(file) MultipartFile file, RequestParam(value bizType, required false) String bizType) { // 保存文件到本地或对象存储 return Result.success(); } }这段代码的核心约定有两个。一个是RequestParam(file)里的 file必须和前端formData.append(file, file)的键名一致。Many 前端同学传append(uploadFile, file)后端写RequestParam(file)两边永远对不上。遇到这种问题最直接的排查方式就是打开 Network 面板看请求体里每个 part 的namexxx是什么再对照后端参数名。另一个是为什么接收文件用的是RequestParam而不是RequestBody。因为 multipart 请求里每个 part 本质上都是请求参数只是普通字段是字符串文件字段被 Spring 的MultipartResolver包装成了MultipartFile对象。它和 JSON 序列化到 DTO 是两套完全不同的机制。在 Controller 里拿到MultipartFile后常用的方法有String originalFilename file.getOriginalFilename(); // 原始文件名 long size file.getSize(); // 文件字节数 String contentType file.getContentType(); // 文件 MIME 类型 file.transferTo(new File(/data/upload/xxx.xlsx)); // 直接写入磁盘 InputStream in file.getInputStream(); // 拿到流做后续处理一个真实的项目后端不会只存文件通常还会把文件信息写入数据库把文件本身存到某个目录或对象存储然后返回文件的访问 URL。所以除了 file 参数往往还会带一些业务字段比如业务类型、上传人 ID、备注等。前端把这些字段用formData.append(bizType, xxx)传过去就行。如果业务字段比较多也有人喜欢把所有业务信息打包成一个 JSON 字符串 append 过去后端再用 ObjectMapper 解析这种方案也可以但字段结构要提前约定好。3.2 大小限制Spring Boot 的默认值与配置文件文件上传还有一个非常隐蔽的坑大小限制。Spring Boot 的 multipart 默认配置是单文件最大1MB单次请求最大10MB。如果你的文件超过 1MB后端会抛MaxUploadSizeExceededException。前端看到的表现是什么请求发出去后端 500 或 400响应没结构Network 面板里能看到请求是完整的。生产环境一般都需要调大spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MBmax-file-size是单个文件的上限max-request-size是整个请求体的上限。如果你支持多文件同时上传比如一次传 5 个文件每个 20MB那max-file-size设置 50MB 没问题但max-request-size如果只有 100MB5 个 20MB 的文件合起来已经 100MB很容易触发上限。所以这两个值需要一起规划。前端对应也应该做一层校验。一个合理的设计是在用户选择文件后立刻检查文件大小const MAX_SIZE 50 * 1024 * 1024 function checkFileSize(file) { if (file.size MAX_SIZE) { alert(文件不能超过 50MB) return false } return true }这样做的意义是避免用户选了一个 2GB 的文件前端傻乎乎地开始上传传到一半才发现后端拒绝。大文件传输浪费带宽不说体验还极其糟糕。3.3 多文件上传和文件附带业务字段的常用姿势先看多文件上传。前端有两种组织方式// 方式一循环 append 同名 file const formData new FormData() formData.append(file, file1) formData.append(file, file2) formData.append(file, file3) // 方式二append 数组实际上也是同名字段 for (const f of fileList) { formData.append(file, f) }后端对应写法PostMapping(/api/upload/multiple) public Result uploadMultiple(RequestParam(file) ListMultipartFile files) { for (MultipartFile file : files) { // 逐个处理 } return Result.success(); }注意后端这里是ListMultipartFile如果你只写MultipartFile file且前端同时 append 了多个同名 file后端一般只会拿到第一个或者直接报错。所以多文件上传时接口签名要提前定好。再提一个我项目里的习惯单个文件上传时如果前端想把文件名保底传给后端可以主动在 append 时指定formData.append(file, file, file.name || unknown.bin)这样即使某些浏览器异常导致原始文件名丢失后端仍然能拿到一个可用的文件名避免了保存文件时生成随机后缀但完全无法溯源的问题。4. 实际项目高频坑Content-Type 边界符、CORS 预检与 axios 二次封装4.1 排查链路手动写死 Content-Type 导致缺少 boundary我把这个问题单独列出来是因为它的排查链路非常经典几乎适用于所有上传相关的前后端联调问题。现象表现前端代码看起来完全正常FormData 构造没问题post 地址也没问题后端却一直报 MultipartException 或 InvalidContentTypeException业务代码根本执行不到。排查步骤通常是这样的第一步打开浏览器 Network 面板筛选出上传请求点开 Request Headers。重点看Content-Type这一项。如果它是multipart/form-data而没有后面的boundary----WebKitFormBoundaryxxx那问题基本就锁定在前端手动设置了 Content-Type。第二步去代码里搜索这次请求的 headers 配置。最常见的两种情况// 情况一直接在请求里写了 axios.post(/api/upload, formData, { headers: { Content-Type: multipart/form-data } }) // 情况二在统一封装的 request 拦截器里写死了 service.interceptors.request.use(config { config.headers[Content-Type] application/json return config })如果是情况一删掉 headers 配置即可。如果是情况二就需要在拦截器里对 FormData 做特殊判断。第三步验证修复。最直接的方式是重新上传再观察 Network 面板确认 Content-Type 变成了浏览器自动生成的带 boundary 的完整形式后端不再报错。有人可能问如果我不想删 headers自己拼一个带 boundary 的 Content-Type 行不行理论上是可行的但实际非常麻烦。因为 boundary 是一串随机的分隔字符串你手动生成后还必须保证请求体里每个 part 都用它来分割。你会发现这个复杂度和收益完全不成正比。老老实实交给浏览器自动生成才是正道。4.2 CORS 跨域中 multipart 的预检请求前后端分离项目里跨域是绕不开的话题。axios 上传文件时如果前端服务和后端服务不在同一个域名和端口下浏览器会先发一个 OPTIONS 预检请求服务端得正确响应才会放行真正的 POST 请求。multipart/form-data 请求属于非简单请求必然触发预检。很多开发者在本地联调时遇到的情况是上传请求在 Network 面板里看到一个 OPTIONS 请求失败或者被 CORS 拦截然后后面真正的 POST 根本没发出去。根源一般是后端 CORS 配置不完整比如Access-Control-Allow-Headers里没包含Content-Type或者Access-Control-Allow-Methods里没包含OPTIONS。在 Spring Boot 里较稳妥的全局 CORS 配置一般是允许所有来源开发环境并允许常见方法Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(List.of(*)); config.setAllowedMethods(List.of(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(List.of(*)); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }关于跨域我的个人建议是本地开发尽量用前端脚手架自带的代理。以 Vite 为例server.proxy把/api转发到后端服务浏览器里看到的请求是发给自己同源的服务根本不触发 CORS。生产环境再用 Nginx 统一做反向代理。这样前端代码里基本不需要关心跨域问题也少踩很多 CORS 的坑。4.3 封装好的 axios 实例怎么接文件上传拦截器里的隐形炸弹中大型前端项目里几乎不会直接用裸的 axios.post而是用一个统一封装好的 request 实例。这个实例会在请求拦截器里统一注入 token、设置基础 URL、统一处理错误状态。但正是这个拦截器很容易成为上传文件的隐形炸弹。典型的拦截器代码可能长这样const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use(config { config.headers[Authorization] Bearer ${getToken()} config.headers[Content-Type] application/json return config })如果照这个写法所有请求的 Content-Type 都会变成 application/json。然后你在业务里写const formData new FormData() formData.append(file, file) service.post(/api/upload, formData)因为拦截器已经把 Content-Type 强制改成了 application/jsonFormData 对象不会被正确处理。后端拿到 application/json 请求要么报错要么 file 参数为空。解决方案有两种。方案一给上传接口单独使用不经过该拦截器的 axios 实例但这样会丢掉统一 token 注入和统一错误处理不推荐。方案二在拦截器里做类型判断。推荐service.interceptors.request.use(config { if (config.data instanceof FormData) { return config } config.headers[Content-Type] application/json return config })注意这里我直接把Content-Type的设置放在了非 FormData 分支里。FormData 分支完全不设置交给浏览器自动带 boundary。这是最稳妥的。如果你使用请求级别的 headers 参数比如service.post(/api/upload, formData, { headers: { Content-Type: multipart/form-data } })由于 axios 内部对新 headers 的处理和拦截器是叠加的最终还是会覆盖默认值仍然可能缺少 boundary。所以上传文件的正确姿势永远是不手动设置 Content-Type让浏览器自动生成。5. 企业级选型参考直传对象存储、分片上传与端到端验证5.1 什么时候直接把文件发给后端什么时候走签名直传看到这里你已经能把前端 axios 上传文件到后端接口这个链路跑通了。但在真实的企业项目里还得考虑文件到哪里去的问题。小项目、内网系统、用户量不大文件直接发给后端后端写本地磁盘、共享存储或者挂载的 NAS完全够用。架构简单出了问题也好排查。但如果是互联网应用用户量大、文件量大把文件都打到应用服务器上会对后端造成很大压力。文件占用磁盘空间、备份困难、应用扩容时文件不跟随服务的生命周期一起迁移。这时候更常见的方案是前端签名直传对象存储也就是前端先调用后端一个接口比如/api/upload/token后端生成一个只允许上传到指定路径的临时签名 URL前端拿到签名 URL 后直接把文件 POST 到对象存储服务比如 OSS、S3、COS上传完成后前端把返回的文件 key 或 URL 发给后端后端记录到数据库。这个方案里前端发送的仍然是 multipart/form-data 请求只不过目标地址从自己的后端换成了对象存储而且表单字段里增加了签名、策略等参数。所以即使选了直传方案你对 multipart 的理解依然完全适用。这也解释了为什么技术面试总喜欢问如何用 axios 上传文件——因为它不只是库的用法还牵涉前后端交互规范、架构选型、异常处理这些相对深入的思考。5.2 大文件分片与断点续传的前端切入思路如果文件经常超过 2GB或者用户网络不稳定一次性通过 multipart 上传整个文件的体验会非常差。这时候就需要分片上传。思路是把大文件切成固定大小的块每个块单独 append 成 multipart 的 file 字段后端按序号接收并暂存等所有片传完后合并const CHUNK_SIZE 5 * 1024 * 1024 // 5MB 一片 function createChunks(file) { const chunks [] let offset 0 while (offset file.size) { const chunk file.slice(offset, offset CHUNK_SIZE) chunks.push(chunk) offset CHUNK_SIZE } return chunks } async function uploadChunks(file, fileHash) { const chunks createChunks(file) for (let i 0; i chunks.length; i) { const formData new FormData() formData.append(file, chunks[i], file.name) formData.append(fileName, file.name) formData.append(fileHash, fileHash) formData.append(index, i) await service.post(/api/upload/chunk, formData, { timeout: 60000 }) } await service.post(/api/upload/merge, { fileName: file.name, fileHash }) }这里file.slice是浏览器原生方法把 File 切成 Blob 分片。每个分片都走一次 multipart 上传请求后端把分片存起来等所有分片到齐后按 index 合并。断点续传则是在上传前先问后端哪些分片已存在跳过已传的。需要提醒的是分片上传复杂度明显高于单次上传不只前端要多写很多状态管理后端也要有对应的分片合并接口和临时存储。如果业务文件平均只有几 MB完全没必要上这套方案普通 multipart 上传才是最合适的。5.3 先用 Postman 和 curl 验证后端再回来调前端最后分享一个我调上传接口时特别管用的习惯当上传功能报错先别急着在前端代码里反复试先用 Postman 或 curl 确认后端接口本身是否正常。用 curl 测试最直接curl -X POST \ -F file/path/to/test.xlsx \ -F bizTypetest \ http://localhost:8080/api/upload-F参数表示以 multipart/form-data 形式提交file路径指定要上传的文件。如果这个命令能返回正常结果说明后端接口本身没问题问题大概率在前端如果这个命令也报错那问题就在后端配置或接口定义上可以带着 curl 的输出直接找后端同事。用 Postman 同理。新建请求切到 Body选 form-data添加一个 file 类型的字段选择文件再添加需要的普通文本字段点击发送。Postman 会自动生成带 boundary 的 multipart 请求头你可以清楚地看到请求发送前后的差异。这个习惯能帮你省下大量无意义的联调时间。前端和后端之间夹着一个浏览器浏览器本身又有缓存、拦截器、跨域各种因素用 curl 或 Postman 相当于绕开前端环境直接验证最底层的接口契约。契约没问题再去排查自己这边的代码定位速度会快非常多。这次写下来我自己最深的体会是文件上传这个功能表面上是axios 发个 post 请求实际上牵涉 HTTP 协议、前后端参数约定、浏览器行为、后端框架解析机制、跨域策略、请求封装架构这么多层。你在网上搜到的任何一段三行代码搞定上传都不能覆盖这么多内容。我自己也栽过跟头早期写上传功能时喜欢把 Content-Type 和 FormData 混在一起还以为是 axios 的 bug后来才明白这一切的起点不是 axios而是 multipart/form-data 这个东西到底是怎么定义的。最后再分享一个小技巧当你写好上传功能后可以故意把后端接口的 key 改错一次观察前端 Network 面板里请求体 part 的name和后端接收参数的对应情况这个过程能帮你彻底建立前后端通过字段名对齐的直觉。多练几次你就再也不会被明明传了文件后端却说没收到这种问题卡住了。
返回列表