封面

JSON.stringify 正在悄悄丢掉你的上传文件

原文:https://dev.to/parsajiravand/jsonstringify-is-quietly-deleting-your-file-uploads-55ka(作者 @parsajiravand)

QA 在「编辑资料」表单上签字放行。改一下显示名称,点保存,刷新——新名字出现了。上线吧。

两天后:「我上传了新头像,但它就是……没变化?」你打开网络面板检查。请求发出去了。状态 200 OK。服务端日志记录了一次成功更新。任何地方都看不到红色报错。从工具给出的每一个信号来看,这次操作都成功了。

但它没有。而且这个 bug 不在上传处理逻辑里,不在服务端,也不在图片本身——早在请求离开浏览器之前,文件就已经「死」了。

先猜再看答案:文件从一开始就没进到请求体里。不是损坏了。不是被拒绝了。只是——压根不在里面。

那段看起来完全没问题的代码

当时上线的代码大致是这样:

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const data = Object.fromEntries(new FormData(form));
  await fetch("/api/profile", {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
});


这个写法你多半自己也写过。FormData 读取表单当前的值,Object.fromEntries 把它转成普通对象,JSON.stringify 再把这个对象转成请求体。对 name 字段或 email 字段来说,这条链路精确且正确——字符串进去,同样的字符串从另一头出来。

但同一个表单里的 <input type="file" name="avatar"> 进去的却是一个 File 对象。而问题就在这里悄悄崩开。

Object.fromEntries 实际交给你的东西

在第一行代码之后立刻打印 data.avatar,看起来完全正常:

console.log(data.avatar);
// File { name: "sunset.jpg", size: 482113, type: "image/jpeg" }


真实的文件。真实的文件名。真实的尺寸。它身上的一切都在说「没问题,继续吧」。于是你照做了——直接把它送进 JSON.stringify。

console.log(JSON.stringify(data.avatar));
// "{}"


整个 bug 就在这一行里。不是报错,不是 undefined,也不是字符串 "[object File]"——而是一个空的 JSON 对象。每一次、每一个文件都如此,无论多大、无论什么类型。JSON.stringify 会遍历对象自身的可枚举属性来构建输出。而 File——以及它继承自的 Blob——故意不以这种方式暴露数据。name、size、type 是定义在原型上的访问器(getter),不是挂在实例上的可枚举数据,而真正的文件字节根本无法同步获取。JSON.stringify 找不到任何可遍历的内容,于是严格按照规范行事:输出 {}。

于是,离开浏览器的请求长这样:

{ "displayName": "Alex Chen", "avatar": {} }


服务端收到合法的 JSON,更新了名字,看到 avatar: {},大概率会忽略这个形状不对劲的字段——然后返回 200 OK,因为在服务端看来,什么都没有出问题。事实上,在浏览器下游也确实什么都没出问题。这个 bug 早已发生,悄无声息,就发生在网络线路的这一头——你的这一头。

越修越糟的「修复」

发现这个问题之后,直觉反应是:行,那就换个办法把文件的真实字节塞进 JSON。FileReader.readAsDataURL() 会很乐意交给你一段 base64 字符串:

const toBase64 = (file) =>
  new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.readAsDataURL(file);
  });

data.avatar = await toBase64(data.avatar); // 现在是字符串了!
body: JSON.stringify(data);


这确实管用——图片现在真的能完整走一个来回了。但你用一个静默的 bug 换来了三笔安静的代价,只是它们不会立刻出现,而是晚些时候才找上门:

  • Base64 会让载荷体积膨胀约三分之一。一张 3 MB 的照片会变成约 4 MB 的字符串,因为 base64 要用 4 个字符来编码每 3 个字节。
  • 整个文件会在内存里存两份——一份是原始的 Blob,一份是解码后的字符串——而且在整个请求进行期间一直如此。
  • 你重新发明了 `multipart/form-data`,还发明得很糟——浏览器本来就自带二进制安全的发送方式,你却用文本编码来做这件事。

这些没有一条会让测试挂掉。它们只是让上传变得更慢、更重,而且重得没人察觉——直到有人试着用手机传一张 20 MB 的图片。

真正的修复:别再转成 JSON

问题从来不在 FormData,而在于把它转换成了别的东西。fetch 本来就能直接把 FormData 对象当作请求体:

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const data = new FormData(form); // 不要拆开它
  await fetch("/api/profile", {
    method: "PUT",
    body: data, // 不设 headers,也不用 JSON.stringify
  });
});


有两点值得注意,而且都很容易弄反:

  1. 不要自己设置 `Content-Type`。 multipart/form-data 请求的请求头里需要一个 boundary 值来分隔各个字段,而 fetch 一旦发现请求体是 FormData,就会自动为你生成一个全新的唯一 boundary。手动设置这个请求头,发出去的请求反而不带 boundary,请求就此损坏,而且这种损坏调试起来非常费解。
  2. 这次文件的实际字节会真正随请求传输。 没有编码步骤,没有体积上的额外开销,也没有内存里的多余拷贝——浏览器会把二进制数据作为 multipart 请求体的一部分流式发送,这正是普通 HTML 表单从 90 年代起就在用的机制。

唯一真正的取舍在于:服务端需要解析 multipart/form-data 而不是 application/json——大多数框架对此早就有现成的一行方案(Express 用 multer;Node 内置的 http 用 formidable;还有很多框架原生就能读取)。

Object.fromEntries 还藏着的另一个坑

开头那行代码里还藏着第二个坑,它与文件无关:Object.fromEntries 会静默丢弃重复的键,只保留最后一个。加一组复选框,比如把 <input type="checkbox" name="topics" value="css"> 重复三次,勾选其中两项,Object.fromEntries(new FormData(form)).topics 得到的只是一个字符串——而不是你以为的数组。

FormData 本身从来没有这个问题。formData.getAll("topics") 从一开始就能按 DOM 顺序返回每个勾选的值。恰恰是经过 Object.fromEntries 这一道转换,才把它们悄悄合并掉了——这也是应该把 FormData 原样交给 fetch、而不是先动手改造一通的又一个理由。

<!-- playground:start -->

动手试一试

[打开交互式演练场 →](https://bestpractic.org/blog/formdata-file-upload-json-stringify-trap/playground)

_直接在浏览器里运行——随手点一点,看概念如何实时响应。_

<!-- playground:end -->

经验教训

任何时候,只要准备对一个来自 <form> 的东西调用 JSON.stringify,先停一秒,问问它里面实际装的是什么。文本字段能完整走完这个来回;文件和重名字段不行——不会报错,只是数据在请求里悄悄丢失,而这个请求还报告了成功。

FormData 不是通往 JSON 的垫脚石。对任何带文件的内容来说,它就是终点。

<!-- quiz:start -->

自测一下

觉得都弄明白了?[来做这 8 题的小测验 →](https://bestpractic.org/blog/formdata-file-upload-json-stringify-trap/quiz)

_即时反馈,每题都有提示,每个答案——无论对错——都有讲解。_

<!-- quiz:end -->

去检查一下你自己项目里的上传表单——只要看到 Object.fromEntries(new FormData(...)) 后面任何位置跟着 JSON.stringify,今天就值得花五分钟看一眼。你上线过的最隐蔽的「返回 200 但实际没生效」的 bug 是哪一个?

原文:https://dev.to/parsajiravand/jsonstringify-is-quietly-deleting-your-file-uploads-55ka(作者 @parsajiravand)

发布评论
全部评论(0)