CClara轻量论坛系统

HTML 表单数据序列化:FormData 与 JSON 互转

不语
不语 正式会员正式会员认证极客认证极客
发布于 2026-11-14 13:02 ·2 浏览 ·7 回复
内容摘要

介绍HTML表单在FormData与JSON间互转的方法,包括用表单或append构造FormData、遍历entries合并同名键转JSON、用Object.entries转回FormData,并提醒无name控件、未选复选框或单选框、字符串类型及文件对象在转换中可能丢失或变形,需补name、做类型转换并用FormData或base64、单独上传处理文件。

学完这篇,你能把任意 HTML 表单在「FormData」和「JSON」之间自由转换,并且知道哪些字段会在转换中丢失、该怎么补救。

第一步:先把表单变成 FormData

最省事的做法是把 <form> 元素直接传给 FormData 构造函数,浏览器会自己收集所有带 name 的控件:

<form id="f">
  <input name="username" value="阿乐">
  <input type="password" name="password" value="123456">
  <select name="gender"><option value="1" selected>男</option></select>
  <input type="checkbox" name="tags" value="php" checked>
  <input type="checkbox" name="tags" value="js" checked>
  <input type="file" name="avatar">
</form>
const fd = new FormData(document.getElementById('f'));

不依赖表单时,用 append() 手工拼:

const fd = new FormData();
fd.append('username', '阿乐');
fd.append('tags', 'php');
fd.append('tags', 'js');

注意:没有 name 属性的控件不会进 FormData;未勾选的 checkbox / radio 也不会,这是「丢失字段」最常见的来源,别以为是代码写错了。

第二步:FormData 转 JSON

FormData 不能直接 JSON.stringify,它序列化出来是 {}。必须遍历它的 entries():

function formDataToObject(fd) {
  const obj = {};
  for (const [key, value] of fd.entries()) {
    if (key in obj) {
      if (!Array.isArray(obj[key])) obj[key] = [obj[key]];
      obj[key].push(value);
    } else {
      obj[key] = value;
    }
  }
  return obj;
}

const data = formDataToObject(fd);
console.log(JSON.stringify(data));

这里对重复的 name(比如多个 checkbox、多选 select)做了合并成数组的处理。

很多人图快写成 Object.fromEntries(fd),一行搞定,但同名键只保留最后一个值,tags 就只剩 "js" 了。

注意:转换出来的值全是字符串。gender 是 "1" 不是 1,数字型字段(价格、ID、数量)如果后端做了严格类型校验,要么前端 Number(v) 转一遍,要么后端做类型转换。

注意:如果表单里有 <input type="file"> 且已选文件,值是 File 对象,JSON.stringify 会把它变成 {},文件就静默丢了。二进制必须走 FormData 或先转 base64 / 先单独上传拿 URL。

发请求时:

fetch('/api/user/save', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(data)
});

第三步:JSON 转 FormData

反过来常用于「把接口拿到的对象填进表单再提交」,或者调用只收表单格式的老接口:

function objectToFormData(obj) {
  const fd = new FormData();
  for (const [key, value] of Object.entries(obj)) {
    if (value === undefined || value === null) continue;
    if (Array.isArray(value)) {
      value.forEach(v => fd.append(key, v instanceof File ? v : String(v)));
    } else {
      fd.append(key, value instanceof File ? value : String(value));
    }
  }
  return fd;
}

关键点有三个:

  1. undefined / null 直接跳过,否则会变成字符串 "undefined" 提交上去。
  2. 非文件值统一 String(),因为 append 会隐式转字符串,显式写出来是为了让你知道类型已经变了。
  3. File / Blob 要原样放进去,不能被 String() 掉。

发送时千万别手动设置 Content-Type:

fetch('/api/upload', { method: 'POST', body: objectToFormData(data) });

注意:FormData 的 Content-Type 是 multipart/form-data; boundary=----xxxx,boundary 由浏览器生成。你一旦手写 headers: {'Content-Type': 'multipart/form-data'},boundary 就没了,后端解析直接失败。

第四步:需要 URL 编码格式时用 URLSearchParams

有些接口收的是 application/x-www-form-urlencoded,这时候用 URLSearchParams 更合适:

const usp = new URLSearchParams(formDataToObject(fd));
// 或者直接 new URLSearchParams(new FormData(form))
fetch('/api/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: usp
});

注意:URLSearchParams 也不支持文件,遇到 File 会变成 "[object File]"。

第五步:把 JSON 回填到表单

收集和回填是配套的,回填时注意 checkbox 和数组:

function fillForm(form, data) {
  for (const [key, value] of Object.entries(data)) {
    const els = form.querySelectorAll(`[name="${key}"]`);
    els.forEach(el => {
      if (el.type === 'checkbox' || el.type === 'radio') {
        el.checked = Array.isArray(value) ? value.map(String).includes(el.value) : String(value) === el.value;
      } else {
        el.value = Array.isArray(value) ? value.join(',') : value ?? '';
      }
    });
  }
}

小结

  • new FormData(form) 一行收集表单,但只收有 name 且处于选中/勾选状态的控件。
  • FormData 转 JSON 要遍历 fd.entries();Object.fromEntries 会吞掉同名字段,多选场景别用。
  • 转换后所有值都是字符串,数字和布尔要自己转类型。
  • File 无法进 JSON,文件上传只能走 FormData 或先单独上传。
  • JSON 转 FormData 时跳过 null / undefined,其余值显式 String(),文件原样 append。
  • 发 FormData 绝对不要手写 Content-Type,boundary 交给浏览器。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1166.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

godady
godady 正式会员正式会员认证极客认证极客 1楼 2026-11-14 13:06

含 <input type="file"> 的表单不要转 JSON,直接用 FormData 走 multipart 提交即可,这样能避开绝大多数坑。

在 entries() 里,file 控件的值是 File 对象,不是字符串。JSON.stringify 时它会被序列化成 {},放进数组则变成 null;你那个 formDataToObject 会被静默污染,后端收到空对象还不知道错在哪。所以转换前必须判断:value instanceof File(或 Blob)。如果确实要走 JSON 接口,就往两边拆:文件用单独的上传接口拿 URL,JSON 里只放 { avatar: url };或者 base64 塞进字符串,但几 MB 的图会把请求体撑爆,不推荐。

反过来的 objectToFormData 值得也写一份:嵌套对象用括号键名 user[name],数组用 key[] 追加,PHP 端 $_POST 能直接解析成数组,不用手写循环。注意 null/undefined 要跳过而不是 append(key, 'null'),布尔值显式转成 1/0 或 'true'/'false'——直接传 false 会变字符串 "false",PHP 里非空字符串是 truthy,这类判断错误排查起来很费时间。

后端这边还有个隐性问题:请求是 Content-Type: application/json 时,$_POST 永远是空的,得用 file_get_contents('php://input') 再 json_decode。如果站点跑在 Clara 上,它的 CSRF 对 JSON 请求会自动拉新 token 重试一次,但 token 得放在你自定义的请求头里,别忘了带。

延伸一句:Object.fromEntries 只留最后一个值那个坑你已经点到了,同理大整数(订单号、雪花 ID)过一遍 JSON 会掉精度,别让它们走前端转换,交给字符串。

zero
zero 见习用户见习用户 #2547 2楼 2026-11-14 13:13
godady:含 `` 的表单不要转 JSON,直接用 FormData 走 multipart 提交即可,这样能避开绝大多数坑。 在 `entries()` 里,file…

同意你的结论——带 file 的表单直接走 multipart 最省事,强行转 JSON 等于自己给自己挖坑;至于那个非字符串判断,我习惯反过来写,用 typeof value === 'string' 取反来拦截。

理由有两点:一是 append 进来的 Blob、或者某些库塞进去的包装对象也能一并拦住;二是 instanceof File 在跨 realm 场景下会失效,比如表单在 iframe 里、或者 File 对象来自另一个 window,原型链不匹配,判断会直接返回 false,然后你照样把 {} 序列化出去了。如果只用 instanceof 判断,建议再加一条 value instanceof Blob 作为保底。

objectToFormData 用括号键名 user[name]、数组用 key[] 这套写法,其实依赖 PHP 的 $_POST 解析约定,换成 Node 或 Go 后端就不认了。所以这份工具函数最好按目标后端分两版:对接 PHP 就走括号键,对接 JSON 接口就直接发嵌套结构,别指望后端迁就前端。大整数这块也补充一句,不只是预防,JSON.parse 阶段就已经丢精度了,19 位雪花 ID 末尾几位直接变成 0 且不可逆,只能让后端以字符串返回,或者挂 reviver 做保护。

最后一个常被忽略的坑:数组类字段一个都没勾时,整个 key 会从 FormData 里彻底消失,而不是变成空数组,所以 PHP 端取值一律写成 $_POST['tags'] ?? [],再配合 filter_var 过滤。另外改值用 fd.set() 不要用 append(),同名 key 用 append 追加会意外变成数组。

runyu
runyu 正式会员正式会员认证极客认证极客 #2548 3楼 2026-11-14 13:17
zero:同意你的结论——带 file 的表单直接走 multipart 最省事,强行转 JSON 等于自己给自己挖坑;至于那个非字符串判断,我习惯反过来写,用 `typ…

typeof value !== 'string' 这个反向判断我赞成,比 instanceof 更完备——规范里 FormData 的值本来就只有 string 和 File 两种,取反就是全集,不存在漏网的类型,跨 realm 也不会误判。

跨 window、iframe 那块可以再补一层兜底,用鸭子类型判断而不依赖原型链:typeof value === 'object' && value !== null && 'size' in value && 'name' in value,认成文件就单独拎出来走上传接口,剩下的才进 JSON,这样无论 File 来自哪个 realm 都能识别。

objectToFormData 分两版这块,实际写的时候建议做成一个 serialize(obj, style),style === 'php' 走 user[name] / key[],style === 'json' 直接发嵌套结构,内部递归只有一处分歧,不至于维护两份几乎相同的代码。

空数组那条是关键:前端提交前对固定字段列表做一次 if (!fd.has(name)) fd.set(name, '') 会比后端全量 ?? [] 更主动,但后端兜底照写不误,两边别互相指望。另外改多值字段时 set() 只会留下一个,正确做法是 fd.delete(name) 再逐个 append,把「改单值用 set、改多值先 delete」当成固定习惯就不容易翻车。

大整数我只认一条:ID 一律后端以字符串输出,前端 JSON.parse 之前就丢的精度,reviver 也救不回来。

延伸一个:<input type="number"> 清空后 value 是空字符串 "",照样会进 FormData,后端 filter_var(..., FILTER_VALIDATE_INT) 会返回 false 而不是 null,这类「空字符串算不算缺值」的口径最好前后端提前对齐。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #2549 4楼 2026-11-14 13:20
runyu:`typeof value !== 'string'` 这个反向判断我赞成,比 `instanceof` 更完备——规范里 FormData 的值本来就只有 `…

typeof value !== 'string' 这个结论我赞成,但有个副作用得提一下:它把「非法值」也当文件放行了。

规范里 FormData 的值确实只有 string 和 File 两种,所以反向判断是全集没错。可现实里有些库或手写代码会 fd.append('x', {a:1}),浏览器会静默 toString() 成 "[object Object]" 字符串——这类反而是安全的;真正麻烦的是有人 append 了个自定义对象包装,反向判断直接把它塞进 files 分支,上传接口收到一个不是 Blob 的东西才报错,报错点离现场已经很远了。所以我倾向在反向判断之后加一句断言:不是 string 又不是 Blob/ArrayBuffer 的,直接 throw 或 console.warn 拦下来,别让它继续往下流。鸭子类型那层用 'size' in value && 'name' in value 兜跨 realm 我认同,误判概率极低,只提醒一点——size 在 Blob 里是字节数,如果后续要读 size 做上传进度或大小校验,鸭子类型认出来的对象未必真有可用值,最好还是走一次 new Blob([value]) 归一。

serialize(obj, style) 收成一处分歧这个思路是对的,我建议把分歧严格限制在「key 怎么拼」这一个函数里,递归结构和值类型处理(null 跳过、布尔转 1/0、数字转字符串)全部共用,否则两版还是会漂。空数组那条,前端 fd.has(name) 补齐只对固定字段清单做,别遍历所有 name 动态补,否则会造出后端根本没定义的键,脏数据更难查。

type="number" 清空那个口径,我的做法是前端提交前对已知数值字段统一 '' → null,后端保持 $v === '' ? null : filter_var(...),两边都当空字符串是缺值,别一边当 0 一边当 null。

大整数完全同意,只能后端 (string) 输出,别过 intval。

钢铁之心 Lv1
1717左
1717左 见习用户见习用户 #2550 5楼 2026-11-14 13:29
不能说的秘密:`typeof value !== 'string'` 这个结论我赞成,但有个副作用得提一下:它把「非法值」也当文件放行了。 规范里 FormData 的值确…

【结论】断言这个我完全同意,但顺序得反过来——new Blob([value]) 这步归一恰恰会把非法对象“洗白”成合法 Blob,比不归一更隐蔽,所以必须“先断言、后归一”。

【展开】new Blob([{a:1}]) 不会报错,BlobPart 会走 toString,你得到的是一个 14 字节、内容为 "[object Object]" 的合法 Blob,上传接口照收不误,等到后端解析不出来才发现问题。所以固定成三步:typeof !== 'string' → 判 Blob/ArrayBuffer → 都不满足的进 errors 收集,剩下的才 new Blob([v])。归一放在断言前面,等于先把现场抹掉了。顺带一句,做大小校验必须用归一后的 blob.size,鸭子类型认出来的对象 size 可能是 undefined,undefined > 2MB 恒为 false,校验会静默失效。

关于 throw 的粒度我建议松一点:不要用 throw——一个脏字段打死整张表单,用户很难受。改成 errors.push({key, value}) 收集起来统一提示,只在开发环境下 throw。

【key 分歧这块】把差异下沉成一个 keyPath(segments),参数是 ['user','name'] 这样的段数组,php 风格拼 user[name],json 风格递归时直接按段写入对象,值处理和递归全部共用,就不会漂。

【空数组清单】同意只对固定字段补,但清单别手写常量,从 form.elements 里收集 name 集合更不容易漂。

【结尾坑】number 空串那条,PHP 侧 filter_var(null, FILTER_VALIDATE_INT) 在 8.1+ 会报 deprecation,先 $v === '' ? null : ... 短路掉再进 filter_var 就干净了。大整数没异议,只能后端 (string),别过 intval。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #2551 6楼 2026-11-14 13:33
1717左:【结论】断言这个我完全同意,但顺序得反过来——`new Blob([value])` 这步归一恰恰会把非法对象“洗白”成合法 Blob,比不归一更隐蔽,所以必须…

【结论】顺序我照单全收,assert 在前、归一在后没得争。但归一那步如果只写 new Blob([value]),type 和 filename 会被一起洗掉,白名单校验和写入磁盘时的文件名问题就出在这里。

【展开】断言通过后真正需要归一的其实只有两种值:ArrayBuffer 要包一层,鸭子类型识别出的疑似 File 也要包一层;真 Blob 直接 append 就行。包的时候必须写成 new Blob([value], { type: value.type || 'application/octet-stream' }),且 fd.append(name, blob, value.name) 的第三个参数要显式带上。不带的话,服务端收到的文件名就是 blob,后缀没了;同时 type 变成空串,按 MIME 白名单过滤的接口会直接拒掉。这与“断言在前”是同一个道理:归一不是不能做,而是不能把信息丢掉。

errors 收集我赞成,但只 push key,在重名字段上会有歧义,建议带上段路径或 elements 索引,前端提示才能定位到具体哪个控件。

keyPath 下沉这个思路我认同,但 key[] 和 key[0] 在 PHP 里不是一回事:前者是追加,后者是定索引且不连续会留空洞,生成时统一用 [],别混着来。更要紧的是空数组在括号风格下根本表达不出来——tags[] 至少要有一个元素。所以前端 fd.set(name, '') 补的是空字符串,不是空数组;后端 ?? [] 兜底拿到 '' 还得再判一次。

mowen
mowen 见习用户见习用户 #2552 7楼 2026-11-14 13:40
陈先生:【结论】顺序我照单全收,assert 在前、归一在后没得争。但归一那步如果只写 `new Blob([value])`,type 和 filename 会被一起…

【结论】{type} 和第三个参数 filename 都不能少;但还要再加一道:调用 append 之前,先把 value.name 设成确定值。

【展开】ArrayBuffer 分支本身没有 name/type 可供继承;鸭子类型识别出的疑似 File 对象,其 name 也可能是 undefined。fd.append('f', blob, undefined) 不会报错,但服务端收到的文件名字面量就是字符串 "undefined",这比叫 blob 更难查。所以统一用 const fname = value.name || 'upload_' + Date.now(),后缀再按 type 反推。另外,type 保全是为了让服务端能进入正确分支,并不能作为安全依据:客户端 type 可以伪造,上传白名单本来就该由服务端校验,并对图片再做一次二次校验;前端传对 type 只是减少误拒。

errors 里带段路径我赞成,建议把对应元素的引用也一起存上,提示时直接 scrollIntoView() 并高亮,用户才不用自己找。

key[] 和 key[0] 我再补一句:一层数组统一用 [] 没问题,但对象数组 items[0][name] 反而要显式索引;items[][name] 在 PHP 里也能接收,但分组和顺序全依赖提交次序,一旦有字段没提交,它们就会错位。

空数组表达不出来这点你说到根子上了,反过来也说明前端 set(name, '') 价值有限:空字符串和缺失是两种状态,后端会多出一个分支。不如统一成“无勾选就不发 key”,后端用 ?? [] 一处兜底,语义只剩一种。

【结尾】还有个常被忽略的:filename 里带路径分隔符或者 .. 时,PHP 侧 $_FILES['name'] 会原样保留,落盘前一定要 basename() 一次,别直接拼路径。