ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

axios文件上传实战:从multipart/form-data到进度监控

axios文件上传实战:从multipart/form-data到进度监控 1. 为什么文件上传绕不开 multipart/form-data以及为什么必须用 POST先说一个我面试前端时必问的问题GET 和 POST 到底有什么区别很多人张口就是“GET 把参数放在 URL 上POST 放在请求体里”这句话对但只说对了一半。真正决定两者差异的是语义和传输方式的限制。GET 的 URL 有长度上限虽然各大浏览器和服务器标准不太一样但一般超过 2KB 到 8KB 就会出现不可预知的问题。你想想一张手机拍出来的照片动辄 3MB、5MB就算把文件内容转成 Base64 塞到 URL 里那字符串长度直接爆炸服务器日志都会刷屏。更重要的是GET 是幂等请求语义上应该是“查询”而不是“提交”你用 GET 传文件中间任何一层缓存都有可能把请求截胡文件内容被缓存到 CDN 或者浏览器缓存里后面就乱套了。所以文件上传从协议设计上就必须走 POST。那 POST 的请求体里用什么格式来承载文件这就要说到 Content-Type 了。常见的 POST 请求体格式大概有三种Content-Type用途特点application/json纯 JSON 字符串适合结构化的普通数据不能直接放文件application/x-www-form-urlencoded表单键值对表单默认格式键值用 连接适合短文本multipart/form-data多部分混合数据可以同时携带文本字段和二进制文件前两种格式本质上都是“把数据编码成文本”。把文件塞进去当然也能塞比较常见的做法是把文件转成 Base64放进 JSON 里。但这样做有很明显的毛病文件体积会被撑大 33% 左右因为 Base64 用 4 个字节表示 3 个字节的内容而且服务器端要先把整个 JSON 解析完才能拿到里面的文件数据大文件时内存占用非常难看。multipart/form-data 的设计初衷就是为了解决这个问题——它用一种带边界标记的格式把文件数据按二进制块直接塞进请求体不做额外的体积膨胀服务器端也可以一边解析边界一边落盘内存开销可控。再往底层看一层。multipart/form-data 的请求体长什么样大概是这样------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; namefile; filenametest.jpg Content-Type: image/jpeg 这里是文件的二进制内容 ------WebKitFormBoundary7MA4YWxkTrZu0gW--每个字段之间用一个随机生成的 boundary 字符串分隔每个部分里通过 Content-Disposition 声明字段名和文件名。浏览器端的 FormData 对象会自动帮我们生成这套格式不需要手写。这个细节在排查问题的时候特别有用后面我会再提。2. 用 axios 发送文件上传请求的核心写法先看一个最基础的 axios 文件上传写法代码量其实非常少import axios from axios // 创建 FormData 对象 const formData new FormData() // 从 file input 中拿到用户选择的文件 const fileInput document.querySelector(#fileInput) const file fileInput.files[0] // 把文件塞进 formData formData.append(file, file) // 如果有其他字段也可以一起加比如备注 formData.append(remark, 这是上传备注) // 发送 POST 请求 axios.post(/api/upload, formData) .then(res { console.log(上传成功, res.data) }) .catch(err { console.error(上传失败, err) })就这样文件就上传上去了。代码看着简单但这里面藏了好几个关键点不注意就会踩坑。第一千万、千万、千万不要手动设置Content-Type: multipart/form-data。我见太多人这么干了然后发现后端收到了请求但文件字段是空的或者直接报 400 错误。原因是什么呢multipart/form-data 请求体需要一个 boundary 分隔符而这个 boundary 在浏览器端是由 FormData 自动生成的一段随机字符串。如果你手动把 Content-Type 写死在 headers 里比如这么写axios.post(/api/upload, formData, { headers: { Content-Type: multipart/form-data } })那么你写死的 Content-Type 里是没有 boundary 的或者 boundary 和请求体里实际用的对不上服务器根本没法正确解析这个请求。正确的做法是不要设置这个 header让 axios 自己去识别 FormData然后自动带上正确的 Content-Type含 boundary。axios 底层在浏览器端用的是 XHR如果你传的参数是 FormData 实例浏览器会自动帮你设置 Content-Type并且带上正确的 boundary。所以这个 header 根本不需要你操心多写反而出错。第二FormData 的 append 方法可以多次调用用于多文件上传。const formData new FormData() const fileInput document.querySelector(#fileInput) // multiple 属性下files 是一个 FileList for (let i 0; i fileInput.files.length; i) { formData.append(files, fileInput.files[i]) }这样后端接收的时候files这个字段就是一个文件数组Java 里用ListMultipartFileNode 里用文件数组。如果你用同一个字段名 append 多次浏览器会把它们打包在同一个字段名下面后端就能拿到多份文件。第三关于超时时间。axios 默认没有超时时间也就是timeout: 0等多久都行。但实际项目里一定要设置超时否则一个文件传一半断网了用户界面卡在那里体验极差。文件上传的超时不能设置得太短我见过有人全局设置了timeout: 10000结果传大文件的时候 10 秒就到了一个 50MB 的文件还没玩没了就被掐断了。合理的做法是针对上传接口单独把超时时间放宽到 60 秒甚至更长并配合后端的请求体大小限制来做兜底。axios.post(/api/upload, formData, { timeout: 120000, // 单独放宽到120秒 onUploadProgress: (e) { console.log(上传进度, e) } })这个 onUploadProgress 是 XHR 自带的能力axios 直接暴露给我们用了后面讲进度条的时候细说。3. 进阶实操真实业务里的进度条、登录态与错误处理你光会发请求还不够放到真实业务场景里至少还要处理三件事上传进度、Token 认证、失败重试与错误提示。下面一个个来。3.1 上传进度条onUploadProgress 的完整用法文件上传最影响用户体验的就是进度反馈。用户点了个上传按钮界面如果一点反应都没有那他大概率会在第二秒就再点一次结果重复上传了。进度条就能有效避免这个问题。onUploadProgress 回调里会收到一个对象核心是三个属性loaded已经上传的字节数total文件总字节数注意这里的 total 是文件的总大小而不是整个请求体的总大小实测中可能略有偏差progress一个 0 到 1 的小数表示进度比例axios.post(/api/upload, formData, { onUploadProgress: (progressEvent) { const percent Math.round((progressEvent.loaded / progressEvent.total) * 100) updateProgressBar(percent) } })这里有个小坑如果你传的 formData 里除了文件还有其他比较大的文本字段或者用了多文件浏览器算出来的 total 可能跟单个文件大小对不上。如果你只想展示某个文件的上传进度那最好是一个请求只传一个文件用 progress 直接展示。如果是一个请求传多个文件那展示的就是“这批文件的整体进度”文案上要写清楚“共 N 个文件已上传 60%”而不是“XXX.jpg 上传进度 60%”。3.2 登录态与鉴权信息携带绝大多数项目都有登录体系上传接口肯定要做权限校验。目前最常见的方式是在请求头里带一个 Token比如Authorization: Bearer token。在 axios 里最直接的方式是在请求配置里加 headersconst token localStorage.getItem(token) axios.post(/api/upload, formData, { headers: { Authorization: Bearer ${token} } })但这样写有个问题项目里那么多接口都要带 Token总不能每个请求都手动加一次吧。所以正经项目里都会创建 axios 实例用请求拦截器统一处理const service axios.create({ baseURL: /api, timeout: 30000 }) // 请求拦截器 service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器 service.interceptors.response.use( response { // 业务状态码处理 if (response.data.code 200) { return response.data } return Promise.reject(new Error(response.data.message)) }, error { // 统一错误提示 return Promise.reject(error) } )这里要注意一个问题如果后端要求必须登录才能上传当用户 Token 过期时上传会返回 401。如果我们在响应拦截器里统一做了“跳转登录页”的处理那上传组件的 catch 里可能就接不到 401 的具体信息了。我建议在上传组件里单独 catch 一下 401 的场景给用户提示“登录状态已过期请重新登录”而不是只弹一个冷冰冰的“上传失败”。3.3 上传前的本地校验发送请求之前前端最好先做一层校验别让后端去当垃圾箱。校验点通常有这几个文件类型用 file.type 判断比如只允许图片就检查是不是以image/开头如果要限定具体格式可以用扩展名判断文件大小用 file.size 和阈值比较比如限制最大 10MB文件数量如果接口限制最多上传 5 个文件前端提前数一下function validateFile(file) { if (!file) return 请选择文件 if (![image/jpeg, image/png, image/gif].includes(file.type)) { return 仅支持 JPG/PNG/GIF 格式 } if (file.size 10 * 1024 * 1024) { return 文件大小不能超过 10MB } return }把错误信息在弹窗或者页面文案里展示出来用户体验会比等后端返回错误好太多。而且前端校验也能减少无效请求节省流量。3.4 Vue 3 组件里的完整上传逻辑把上面的东西放到 Vue 3 的组合式 API 里写一个可复用的上传逻辑script setup import { ref } from vue import axios from ../utils/request const fileList ref([]) const uploadPercent ref(0) const uploading ref(false) async function handleUpload() { if (!fileList.value.length) return const formData new FormData() fileList.value.forEach(file { formData.append(files, file) }) formData.append(category, avatar) uploading.value true uploadPercent.value 0 try { const res await axios.post(/upload, formData, { timeout: 120000, onUploadProgress: (e) { uploadPercent.value Math.round((e.loaded / e.total) * 100) } }) console.log(上传结果, res) } catch (err) { console.error(上传失败, err) } finally { uploading.value false } } /script注意这里我把await和try/catch/finally配合使用uploading状态不管成功失败都会复位否则上传失败后按钮一直处于 loading 状态用户就只能刷新页面了。这个细节踩坑概率很高。4. 企业级封装把上传封装成一个项目里人人能调用的方法前面写的都是在组件里直接操作 axios代码简单但项目大了之后会有两个问题一是每个组件都写一遍 FormData 的拼装逻辑代码重复二是如果有人不了解 FormData 的边界问题乱设置 Content-Type排查起来耗时耗力。所以在一个稍微像样点的前端项目里我都会把文件上传抽成一个公共方法。这样团队里任何一个人要传文件只需要调用一个 uploadFile 方法传文件对象和业务参数就行不需要关心底层细节。4.1 封装 uploadFile 公共方法// src/utils/upload.js import axios from ./request // 这是配置好的axios实例 /** * 通用文件上传 * param {File|Blob} file - 文件对象 * param {Object} data - 额外的表单字段 * param {Function} onProgress - 进度回调 * param {Object} customConfig - 自定义配置超时、信号等 * returns {Promise} */ export function uploadFile(file, data {}, onProgress () {}, customConfig {}) { const formData new FormData() formData.append(file, file) // 拼接其他表单字段 Object.entries(data).forEach(([key, value]) { formData.append(key, value) }) return axios.post(/upload, formData, { timeout: 120000, onUploadProgress: (progressEvent) { if (progressEvent.total) { const percent Math.round((progressEvent.loaded / progressEvent.total) * 100) onProgress(percent) } }, ...customConfig }) }方法里强制规定了超时时间 120 秒调用方可以通过 customConfig 覆盖但必须有这个默认值兜底。进度回调里加了progressEvent.total的判断因为某些极端情况下 total 可能为 0直接除会得到 Infinity。4.2 支持取消上传文件上传这种耗时操作用户经常传了一半发现选错文件了想取消。axios 支持通过 AbortController 取消请求老版本是 CancelToken新版更推荐 AbortController。// 调用方 import { uploadFile } from /utils/upload const controller new AbortController() uploadFile(file, {}, (percent) { console.log(进度, percent) }, { signal: controller.signal }) // 用户点击取消时 function handleCancel() { controller.abort() }AbortController 的使用非常直观创建 controller 实例把controller.signal传给 axios 请求配置取消时调用controller.abort()即可。取消后 axios 会抛出一个取消错误可以在 catch 里通过err.name CanceledError或者axios.isCancel(err)来判断是不是用户主动取消的从而区分对待。try { await uploadFile(...) } catch (err) { if (axios.isCancel(err)) { console.log(用户取消了上传) } else { console.error(上传失败, err) } }4.3 上传方法在业务中的使用效果封装完之后业务组件里的代码会变得非常清爽。比如做头像上传import { uploadFile } from /utils/upload async function handleAvatarChange(e) { const file e.target.files[0] if (!file) return uploading.value true try { const res await uploadFile(file, { type: avatar }, (percent) { progress.value percent }) avatarUrl.value res.data.url } catch (err) { message.error(头像上传失败) } finally { uploading.value false } }一个上传功能的代码量从 30 行浓缩成了 10 行而且团队所有人都用同一个方法行为一致排查问题也方便。如果有人接入后出了 bug第一步一定是看他的调用方式而不是直接去翻上传方法内部。5. 后端配合与常见问题排查实录前端写完联调的时候才是真正考验技术的时刻。这边把我在实战中踩过的高频问题整理成速查表按这个顺序排查绝大多数上传问题都能在一分钟内定位。5.1 前后端联调时的核心配合点前后端先对好几个指标可以避免大量无效联调对指标项常见约定说明文件字段名file / files / uploadFile前后端必须完全一致额外字段名remark、type、category 等FormData 里 append 的 key 必须和后端 RequestParam 一致请求路径/api/upload前后端要提前确认避免大小写不一致返回格式{ code: 200, data: { url }, message: ok }前后端约定一个统一的数据结构文件大小上限10MB / 50MB / 2GB后端配置了限制前端也要做对应提示最大文件数量单文件/多文件影响 FormData 的拼装方式最怕遇到的现象后端说“文件字段是 file”前端却写了formData.append(uploadFile, file)结果后端拿到的是一个 null弹了个老老实实的“上传失败”排查了半天。5.2 后端跨域CORS问题前后端分离项目跨域几乎是必遇到的事。上传接口如果跨域最常见的问题是预检请求OPTIONS失败。浏览器看到你的请求里带了非简单头比如 Authorization或者请求体是 multipart/form-data会先发一个预检请求确认服务器允许这个跨域请求然后才发真正的 POST。如果后端的 CORS 配置没处理好前端会报这样错Access to XMLHttpRequest at http://api.example.com/upload from origin http://localhost:8080 has been blocked by CORS policy注意multipart/form-data 算不算简单请求严格说判断“简单请求”的条件之一是 Content-Type 只能是这三种之一application/x-www-form-urlencoded、multipart/form-data、text/plain。所以单论 content-typemultipart/form-data 是简单请求不会触发预检。但问题在于你带了 Authorization 自定义头自定义头会导致请求变成“非简单请求”从而触发预检。所以实际上传请求几乎必然会有预检请求。后端需要允许来源 Origin本地开发一般是http://localhost:端口要出现在允许列表里方法 POST、OPTIONS请求头 Authorization、Content-Type 等凭证Credentials如果你用 Cookie 认证需要allowCredentials(true)Spring Boot 里常见的配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }Node/Express 用 cors 中间件const cors require(cors) app.use(cors({ origin: true, // 生产环境要改成具体的域名 credentials: true, allowedHeaders: [Content-Type, Authorization], methods: [GET, POST, PUT, DELETE, OPTIONS] }))5.3 常见问题速查表现象大概率原因排查思路后端收到请求但 file 字段为 nullFormData 字段名对不上打印 formData 内容确认字段名检查后端 RequestParam 名称上传报 400 Bad RequestContent-Type 没带 boundary或请求体格式损坏检查是否手动设置了 Content-Type用浏览器 Network 看请求体上传报 413 Payload Too Large后端限制了请求体大小检查 Nginx client_max_body_size、Spring Boot multipart.max-file-size上传报 401登录状态失效Token 过期响应拦截器里跳转登录页重新获取 Token 重传进度条不走一直 0%onUploadProgress 没写在请求配置里检查是否把 onUploadProgress 写进了 data 或 headers文件名出现乱码编码问题后端对文件名做 URL 解码前端尽量用英文字符命名CORS 报错跨域配置缺项按 5.2 的配置逐项核对上传大文件时请求失败超时时间太短单独把上传接口的 timeout 调大后端同步放宽限制5.4 排查细节与避坑经验最后分享一个我压箱底的排查经验。有一次项目里上传功能时好时坏同一个文件有时候传得上去有时候传不上去非常诡异。后来打开浏览器 Network 面板仔细对比了两次请求发现一个关键差别坏的那个请求里请求头多了Content-Type: multipart/form-data好的那个请求头里只有Content-Type: multipart/form-data; boundary----WebKitFormBoundary...。原因是一位新同事在某次代码提交里为了保险给上传请求手动加了 Content-Type。他加的是不带 boundary 的而浏览器正常应该带上 boundary。服务器拿到这个 header 后尝试用默认 boundary 去分割请求体结果对不上于是要么解析不到文件要么干脆报 400。因为这个 header 只会出现在他改动的那个分支上所以表现就是“有时候好有时候坏”。从那以后我在团队规范里都写死了这一条用 axios 传 FormData永远不要手动设置 Content-Type让浏览器和 axios 自己去处理。这个看起来反直觉但确实是文件上传里最容易被忽略、却最容易踩坑的一点。还有一个小技巧遇到后端反馈“传上去的文件是 0 字节”这种情况先别怀疑后端代码用浏览器的网络面板看一下请求体把Content-Type带 boundary 的部分展开里面能看到文件块的Content-Disposition如果连filename属性都没有那大概率是前端把 File 对象传成了 Blob 而没有指定文件名。给 Blob 传文件名也很简单formData.append(file, new File([blobData], filename.jpg, { type: image/jpeg }))本质上就是用 File 构造器给 Blob 补了个名字和类型后端拿到手才算是一份完整的文件。上传文件这个功能代码量不多但坑是真不少。从协议格式的边界问题到跨域预检再到后端的各种限制每一步都可能让整个功能翻车。把我上面写到的这些点过一遍再结合你自己的调试工具基本就能稳稳拿下了。
返回列表