![FormData 上传避坑:file.raw 与 [object Object]](http://pic.xiahunao.cn/yaotu/FormData 上传避坑:file.raw 与 [object Object])
后端同学把接口日志甩过来的时候这事基本就没法含糊过去了MultipartFile的原始文件名是[object Object]大小是 15 字节。可你盯着控制台看了半天那个变量明明长得像个正经文件name、size、type一应俱全点开属性一看什么都有。问题就卡在file.raw和file这一对东西上——在FormData里append的时候前者是原生文件对象后者只是上传组件给你包的一层外壳对象。这层壳一旦被当成文件塞进FormData浏览器不会报错它会非常礼貌地执行一次字符串转换把一个[object Object]发给后端。下面我把这件事从头拆到尾formData.append的判定规则到底是什么、file.raw在哪些钩子里有、哪些钩子里天然就没有、怎么从 Network 面板一步步倒推出问题出在哪一层以及几个我实打实踩过的、文档里不会写的细节。前端上传写得比较多的、正在跟后端为文件是空的来回扯皮的都可以对着看。1. FormData.append 的判定规则它其实很挑食1.1 从 Blob 到 File 的继承链决定了谁能被塞进去浏览器里File是Blob的子类File.prototype instanceof Blob的结果是true。File在Blob的基础上多了name和lastModified两个属性某些环境里还多一个非标准的path。所以你往append里塞一个Blob浏览器认塞一个File也认而且会自动把File.name拿来做filename。append其实有三个重载形态formData.append(name, value) // value 是字符串或 BlobBlob 时文件名默认 blob formData.append(name, value, filename) // 显式指定文件名 formData.append(name, blob, filename) // 旧规范写法效果和上面一样关键点在这里如果value既不是Blob/File也不是字符串浏览器会走 WebIDL 的字符串转换也就是相当于执行一次String(value)。而String({})的结果是固定的[object Object]。这就是为什么很多人写错了代码却一点报错都收不到——它被当成你本来就想传一段文本处理了。1.2 一段字符串是怎么悄悄替你完成上传的看这两段代码对比一下就知道问题在哪// 错误示范uploadFile 是 on-change 回调里那个壳对象 const fd new FormData(); fd.append(file, uploadFile); // 实际发出去的内容是namefile 的纯文本 [object Object] // 正确示范 const fd new FormData(); fd.append(file, uploadFile.raw, uploadFile.name); // 发出去的是二进制流Content-Disposition 里带 filenamexxx.png用错误写法的时候Network 面板里你看到的依然是一个规规矩矩的 multipart 请求Content-Type正确Content-Length也正常只是 Payload 那一段长这样------WebKitFormBoundaryXXXXXX Content-Disposition: form-data; namefile [object Object] ------WebKitFormBoundaryXXXXXX--既没有filename也没有二进制乱码。后端代码如果只做了if (file ! null)就放行这个请求会被当成上传成功然后在数据库里存下一个 15 字节的文本文件。1.3 在控制台里做一次最小复现把它钉死不想动项目代码的话直接在浏览器控制台里跑这几行const fd new FormData(); fd.append(a, { name: x.png }); fd.append(b, new File([hello], x.png, { type: text/plain })); for (const [k, v] of fd.entries()) { console.log(k, typeof v, v instanceof Blob, v instanceof Blob ? v.size : v); } // a string false 15 ← 字符串 [object Object]长度正好 15 // b object true 5 ← 真正的 Blob5 字节顺便说一个很多人卡住很久的细节直接console.log(formData)在不同版本的 Chrome 里表现不一样早期版本会稳定打印FormData {}看起来像是根本没 append 成功。所以别靠打印FormData本身来判断要么用formData.entries()遍历要么用formData.get(file)单取。我见过有人因为这个误判把好代码改坏了。2. file 和 file.raw不同钩子拿到的根本不是同一个东西2.1 上传组件为什么要多包一层上传组件不只是帮你渲染一个input typefile它还要维护整个列表的状态uid、status、percentage、response、url已上传成功后用于回显的地址等等。这些信息都不属于File所以组件额外定义了一个上传文件项的结构。Element 系里这个结构叫UploadFileAnt Design Vue 的fileList元素也是类似的扩展结构。这层壳有一个没有被到处强调的约定壳上的.rawAnt Design Vue 里是.originFileObj才指向原生的File/Blob对象。你把它整个塞进FormData就是上一节那个 15 字节的结局。我把几套常见组件的钩子参数整理了一下方便对照框架/组件钩子或属性拿到的是什么能不能直接给 FormDataElement UI (Vue2)before-upload(rawFile)原生 File能Element UI (Vue2)on-change(file, fileList)UploadFile 壳必须用file.rawElement UI (Vue2)http-request(options)options.file是原生 File能Element Plusbefore-upload(rawFile)UploadRawFileFile 加了 uid能Element Pluson-change(uploadFile, uploadFiles)壳必须用uploadFile.rawElement Pluson-exceed(files, uploadFiles)files是原生 File 数组能Element Plusv-model:file-list手动回显你赋值什么就是什么通常没有 rawAnt Design VuebeforeUpload(file, fileList)原生 File能Ant Design VuecustomRequest({ file })原生 File能Ant Design VuefileList中的项扩展对象.originFileObj为原生 File必须用.originFileObj版本差异会让细节发生偏移所以我的建议是别背表第一次接入的时候老老实实console.log打一次。大版本升级之后也再打一次尤其是 Element UI 到 Element Plus 这种跨代升级钩子签名基本都动过。2.2 同一个变量名在两个钩子里含义完全不同举个我亲眼见过的翻车案例。有人在before-upload里写了文件大小校验那里file是原生 Filefile.size用得舒舒服服。然后他把同一段校验代码抄到了on-change里file.size直接变成undefined所有文件都被判定为体积为 0谁都传不上去。他第一反应是组件把 size 属性弄丢了。其实两个钩子里的file压根不是一种类型的东西一个是被包过的一个是原生的。这类问题白白浪费半小时的几率非常高因为代码看起来就是一样的。我现在的硬习惯是变量命名直接把类型写进去壳对象一律叫uploadFile原生对象一律叫rawFile。看着啰嗦但后面任何一处写rawFile.raw都会立刻被自己察觉。2.3 三种天然拿不到 raw 的场景第一类是手动回显。编辑页面从后端拿到已存在的附件列表直接赋值给v-model:file-list。这些对象里只有name和url没有raw。用户如果只改了个备注就点保存你从 fileList 里取raw必然是undefinedappend进去就是字符串undefined。正确的做法是把本次新选的文件和历史已存在的附件在数据结构上分开前者传二进制后者传文件 ID 让后端自己关联。第二类是经过状态管理的文件。选好文件后存进 store跳到另一个页面再回来提交。中间只要经过了任何形式的持久化本地存储、某些状态缓存插件File 就会被序列化成{}即使全程只在内存里某些工具函数也可能顺手做了深拷贝。第三类是重新构造过的列表。为了排序或者去重你map了一遍生成新数组。如果顺手写成list.map(it ({ name: it.name, uid: it.uid }))raw 就没了。这种代码在 review 时几乎看不出来只有跑起来才发现上传是空的。3. 一次完整的排查链路按这个顺序看基本不会绕路遇到文件传上去不对我不再靠猜按下面四步走绝大多数情况十分钟内能定位。3.1 第一步请求头的 Content-Type 有没有 boundary打开 Network找到上传请求看 Request Headers 里的Content-Type。正常应该是Content-Type: multipart/form-data; boundary----WebKitFormBoundaryxxxxxxxx如果只有multipart/form-data而没有; boundary...后端一定会解析失败表现是直接 400 或者说文件字段不存在。这种情况百分之九十九是代码里手写了这个头。浏览器只有在你不设置Content-Type的时候才会自动补上 boundary一旦你自己设了哪怕设的值看起来完全正确也不会有 boundary。在 axios 新版本里浏览器环境下它会主动把这个头摘掉、把主动权交还给浏览器所以你不一定踩得到但如果你用的是fetch、自己封的 XHR或者项目里锁着老版本 axios这个头就会原样发出去。这个坑我在第六节还会再展开说一下。3.2 第二步看 Payload 里有没有 filename 和二进制内容点开请求的 Payload / Request Body看那个字段是不是长这样Content-Disposition: form-data; namefile; filenamereport.pdf Content-Type: application/pdf %PDF-1.7 ...一堆乱码只要filename缺失、或者下面跟的是可读的[object Object]那就百分之百是传了个普通对象进去跟网络、跟后端没有半毛钱关系。3.3 第三步核对字段名和后端注解是否匹配字段名不匹配的表现很有欺骗性——不报错但后端拿到的是null。比如前端append(upload, file)后端写的是RequestParam(file) MultipartFile file很多框架只会给一个 400 或者一个空值日志里看不出名字写错了这种信息。这类问题我会在联调第一轮就把前后端字段名对照表写进接口文档别靠口头约定。3.4 第四步在代码里加一道自检别让错误流到网络层我现在封装上传参数的时候都会加一层判断成本极低收益极大function appendFile(fd, field, maybeFile) { // File 是 Blob 的子类一个判断同时兼容 File 和裁剪/压缩产生的 Blob const isBlob maybeFile instanceof Blob; if (!isBlob) { console.warn([upload] 字段 ${field} 拿到的不是文件对象实际值:, maybeFile); return false; } fd.append(field, maybeFile, maybeFile.name || unnamed); return true; }这里有个边界情况值得注意如果文件来自 iframe 或者 Workerinstanceof Blob会失效因为不同 realm 的原型链不是同一个。这时候改成鸭子类型判断更稳const isBlobLike (v) v ! null typeof v.size number typeof v.slice function typeof v.arrayBuffer function;两种写法我都用过跨 iframe 的场景下鸭子类型明显更靠谱。4. 真正会把 File 变成普通对象的几个操作上一节一直在说别把壳当成文件但还有一种更隐蔽的情况你手上明明拿的是file.raw可它已经不是你以为了。4.1 扩展运算符和 Object.assign 会把文件掏空const rawFile uploadFile.raw; const copy1 { ...rawFile }; // {} const copy2 Object.assign({}, rawFile); // {} console.log(copy1.size); // undefined原因是File的name、size、type、lastModified这些属性大部分挂在原型上是以 getter 形式存在的并不是对象自身的可枚举属性。扩展运算符只复制自有可枚举属性所以复制出来是一个空壳。这个坑最常见的出现位置是我要给文件加点元数据一起传。有人会顺手写成const payload { ...file.raw, bizId }然后把payload塞进FormData结果又是一次[object Object]。正确做法是元数据单独走自己的字段别跟文件混在一个对象里。4.2 JSON 深拷贝和状态持久化JSON.parse(JSON.stringify(file))的结果是{}这个没悬念。比较有意思的是structuredClone它是支持File/Blob的能正常保留所以如果你的项目已经在用structuredClone做深拷贝这条路径反而是安全的。问题在于很多人做状态管理的时候用的是 JSON 序列化那一套。4.3 顺手澄清一下框架把 File 代理掉了这个说法网上流传一个说法Vue 3 的reactive会把File包成Proxy导致append出来变成[object Object]。我在 Vue 3.2 和 3.4 上反复测过这件事不会发生。原因在reactive()内部有一层getTargetType判断只有Object、Array、Map、Set、WeakMap、WeakSet这几类会被判定为可代理其它类型一律返回TargetType.INVALID并且原样返回目标对象本身。File执行Object.prototype.toString得到的是[object File]正好落在 default 分支所以reactive(file)拿回来的还是那个原始 Fileref(file)也一样。Vue 2 那边也类似它的observe只对数组和纯对象动手File 不在这个范围内。真正让人误判的通常就两种情况一是把file写成了file.raw的反面该用 raw 的地方没用二是在赋值前做过{...}或者 JSON 深拷贝。所以下次看到[object Object]先把矛头对准自己的代码别急着怀疑框架——判断标准很简单在append之前打一行console.log(v instanceof Blob)答案是true就说明框架没问题。5. 几种高频场景的正确写法5.1 关掉自动上传自己控制提交时机表单类页面我基本都用:auto-uploadfalse等用户点了保存再统一提交避免用户选完文件又改了主意文件已经躺在服务器上了。// 收集阶段只往自己的数组里放原生文件 const pickedFiles ref([]); const handleChange (uploadFile, uploadFiles) { // 这里 uploadFile 是壳必须取 raw pickedFiles.value uploadFiles .map((f) f.raw) .filter((f) f instanceof Blob); }; // 提交阶段 const submit async () { const fd new FormData(); pickedFiles.value.forEach((raw) { fd.append(files, raw, raw.name); }); fd.append(remark, remark.value); await api.upload(fd); };这里有个容易忽略的点handleChange在用户删除文件时也会触发所以别用push累加老老实实每次全量重建数组否则删掉的文件还会被传上去。5.2 自定义 http-request别把原生再取一次 rawconst handleRequest (options) { const fd new FormData(); // 注意http-request 的 options.file 已经是原生文件了 fd.append(file, options.file, options.file.name); fd.append(bizId, String(bizId.value)); axios .post(/api/upload, fd, { // 这里千万不要手写 Content-Type onUploadProgress: (e) { const percent e.total ? Math.round((e.loaded * 100) / e.total) : 0; options.onProgress({ percent }); }, }) .then((res) options.onSuccess(res.data)) .catch((err) options.onError(err)); };重点在注释那两行。我在http-request里写过options.file.raw因为习惯了on-change那一套结果raw是undefinedappend进去就是字符串undefined——一个 9 字节的文本文件后端还高高兴兴地存下来了。这种 bug 排查起来特别费劲因为它不报错。5.3 多文件、附加字段和同名 keyFormData的append和set行为完全不同这个区别在多文件场景下非常关键const fd new FormData(); fd.append(file, a); fd.append(file, b); // 两个都在后端收到的是数组 fd.set(file, b); // 只剩 b前面的被覆盖掉如果你确实要传多个文件前端用append追加同名 key 是对的但后端接收方式必须配套。用单个MultipartFile去接同名多份各框架行为不一样有的取第一个有的直接报错表现出来就是明明选了 3 个文件只存进去 1 个。稳妥的方案是前端把字段名统一成files后端用MultipartFile[]或者ListMultipartFile接。约定好了再动手比事后对日志省事得多。5.4 二次加工之后怎么重新组装图片压缩、裁剪、加水印这几件事做完之后你手上拿到的一般是canvas.toBlob()产出的Blob它没有name默认文件名是blob。这时候要么重建 File要么用append的第三个参数// 方式一重建成 File后续还能继续用 raw.name const newFile new File([blob], photo_${Date.now()}.jpg, { type: image/jpeg }); fd.append(file, newFile); // 方式二直接用第三参数指定文件名 fd.append(file, blob, photo_${Date.now()}.jpg);这里有个前后端容易对不上的地方你只改了前端append时的文件名后端如果按file.getOriginalFilename()落盘拿到的确实是你指定的新名字但如果后端自己按 UUID 重命名你改的这一下就白改了。改名这件事一定要三处对齐——前端 append 的名字、后端落盘策略、以及后续下载接口返回的文件名。只改一处最后用户下载下来还是叫blob。6. 联调阶段最容易扯皮的几个细节6.1 空值被当成合法内容传上去这是我觉得最阴险的一类问题。fd.append(file, undefined)不报错它老老实实把字符串undefined传了上去9 个字节fd.append(file, null)则传null4 个字节。后端只要没做getOriginalFilename()的空值校验就会认为有文件然后存下来一个内容为undefined的文本文件。这个 bug 的表现是上传成功但文件打不开用户反馈过来的时候你根本想不到是文件名的问题。我的做法是两层防护前端append前用第 3.4 节那个appendFile自检后端加上if (file null || file.isEmpty() || file.getOriginalFilename() null) return error。6.2 手写 Content-Type 的那次教训我在一个项目里配了全局请求拦截器统一给所有请求加上Content-Type: application/x-www-form-urlencoded。结果就是所有上传接口全线崩溃其他接口一切正常。原因前面说过把Content-Type固定成非 multipart浏览器就不会补 boundary后端根本解析不出 multipart 结构。后来我的处理方式是给上传请求单独开一条通道或者干脆在拦截器里对FormData实例做判断跳过if (config.data instanceof FormData) { // 让浏览器自己决定 Content-Type含 boundary delete config.headers[Content-Type]; }要注意的是就算你把它设成multipart/form-data也一样错错的不是值是你设了这个动作本身。6.3 进度条为什么一直是 0onUploadProgress只在浏览器真正处于发送阶段时回调。如果你为了打个日志看看内容把FormData转成了字符串或者手动设了Content-Type导致请求走到非预期的分支进度回调可能一次都不触发进度条永远停在 0%。所以进度条不动的时候先回来看请求头别去改进度条组件。6.4 跨端的时候这套逻辑整个换掉小程序和 uni-app 里压根没有浏览器的FormData用的是uni.uploadFile({ url, filePath, name, formData })。这里的filePath是临时文件的路径字符串不是 File 对象这里的formData是普通对象代表附加字段跟浏览器那个FormData只是名字撞了。把 Web 端file.raw那一套逻辑搬过去一定会错而且错得很离谱。反过来也一样看到filePath别再去找.raw。7. 我自己踩过的三次具体的坑第一次是编辑页面回显。附件列表从后端拉回来直接赋给了v-model:file-list用户只改了个备注就保存我拿着 fileList 里的项去取raw取到undefined最后服务器上多了一个叫undefined的文本文件把原来的合同附件覆盖了。那之后我的结构就固定成两个字段existingAttachments只带 id 和 name和newFiles只带原生 File提交时分别处理再也没混淆过。第二次是在http-request里习惯性地写了options.file.raw。表现是文件上传成功、进度条走到 100%、后端返回 200但打开文件一看内容是undefined这九个字母。这个 bug 我盯了快一个小时因为我一直默认传上去的肯定是文件没想过它可能是一段文本。从那之后我在所有自定义 request 的第一行就加console.assert(options.file instanceof Blob, options.file 不是文件对象)。第三次是文件大小校验写错了位置。在before-upload里写file.size在on-change里也写file.size后者永远是undefined导致所有文件都被判成体积为 0 字节一个都传不上去。修复方式就是统一命名壳叫uploadFile原生叫rawFile所有读属性的地方一眼能看出用的是哪个。最后再分享一个小习惯上传相关的代码我会在开发阶段固定打开 Network 面板里的 Preserve log每次选完文件手动看一眼 Payload 里有没有filename。这一眼大概两秒钟能省掉后面跟后端来回对日志的半小时。