news 2026/10/1 16:40:23

FormData 上传避坑:file.raw 与 [object Object]

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 是字符串或 Blob,Blob 时文件名默认 "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); // 实际发出去的内容是:name="file" 的纯文本 "[object Object]" // 正确示范 const fd = new FormData(); fd.append('file', uploadFile.raw, uploadFile.name); // 发出去的是二进制流,Content-Disposition 里带 filename="xxx.png"

用错误写法的时候,Network 面板里你看到的依然是一个规规矩矩的 multipart 请求,Content-Type正确,Content-Length也正常,只是 Payload 那一段长这样:

------WebKitFormBoundaryXXXXXX Content-Disposition: form-data; name="file" [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 ← 真正的 Blob,5 字节

顺便说一个很多人卡住很久的细节:直接console.log(formData)在不同版本的 Chrome 里表现不一样,早期版本会稳定打印FormData {},看起来像是"根本没 append 成功"。所以别靠打印FormData本身来判断,要么用formData.entries()遍历,要么用formData.get('file')单取。我见过有人因为这个误判,把好代码改坏了。

2. file 和 file.raw:不同钩子拿到的根本不是同一个东西

2.1 上传组件为什么要多包一层

上传组件不只是帮你渲染一个<input type="file">,它还要维护整个列表的状态:uid、status、percentage、response、url(已上传成功后用于回显的地址)等等。这些信息都不属于File,所以组件额外定义了一个"上传文件项"的结构。Element 系里这个结构叫UploadFile,Ant Design Vue 的fileList元素也是类似的扩展结构。

这层壳有一个没有被到处强调的约定:壳上的.raw(Ant Design Vue 里是.originFileObj)才指向原生的File/Blob对象。你把它整个塞进FormData,就是上一节那个 15 字节的结局。

我把几套常见组件的钩子参数整理了一下,方便对照:

框架/组件钩子或属性拿到的是什么能不能直接给 FormData
Element UI (Vue2)before-upload(rawFile)原生 File能
Element UI (Vue2)on-change(file, fileList)UploadFile 壳必须用file.raw
Element UI (Vue2)http-request(options)options.file是原生 File能
Element Plusbefore-upload(rawFile)UploadRawFile(File 加了 uid)能
Element Pluson-change(uploadFile, uploadFiles)壳必须用uploadFile.raw
Element Pluson-exceed(files, uploadFiles)files是原生 File 数组能
Element Plusv-model:file-list手动回显你赋值什么就是什么通常没有 raw
Ant 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是原生 File,file.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必然是undefined,append进去就是字符串"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; name="file"; filename="report.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 或者 Worker,instanceof 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)拿回来的还是那个原始 File,ref(file)也一样。Vue 2 那边也类似,它的observe只对数组和纯对象动手,File 不在这个范围内。

真正让人误判的通常就两种情况:一是把file写成了file.raw的反面(该用 raw 的地方没用),二是在赋值前做过{...}或者 JSON 深拷贝。所以下次看到[object Object],先把矛头对准自己的代码,别急着怀疑框架——判断标准很简单,在append之前打一行console.log(v instanceof Blob),答案是true就说明框架没问题。

5. 几种高频场景的正确写法

5.1 关掉自动上传,自己控制提交时机

表单类页面我基本都用:auto-upload="false",等用户点了保存再统一提交,避免用户选完文件又改了主意,文件已经躺在服务器上了。

// 收集阶段:只往自己的数组里放原生文件 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,别把原生再取一次 raw

const 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是undefined,append进去就是字符串"undefined"——一个 9 字节的文本文件,后端还高高兴兴地存下来了。这种 bug 排查起来特别费劲,因为它不报错。

5.3 多文件、附加字段和同名 key

FormData的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[]或者List<MultipartFile>接。约定好了再动手,比事后对日志省事得多。

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)则传"null",4 个字节。后端只要没做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 进度条为什么一直是 0

onUploadProgress只在浏览器真正处于发送阶段时回调。如果你为了"打个日志看看内容"把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。这一眼大概两秒钟,能省掉后面跟后端来回对日志的半小时。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 16:39:44

汽车电子故障排查三层解构法:物理层、协议层与应用层实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 16:38:51

FEX-Emu + Wine + DXMT:ARM 设备跨平台运行 Windows 应用实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 16:38:51

MATLAB BP神经网络电力负荷预测:从数据预处理到模型验证全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 16:38:29

VHDL运算操作符详解:类型约束、可综合性与实战避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 16:38:29

LubanCat 5软实时化实战:RK3576内核编译与RKDevTool烧录指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 16:36:57

小程序 ECharts 真机适配:ec-canvas 从白屏到性能优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华