侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 206 篇文章
  • 累计收到 7 条评论

q=a+b 解成空格,原加号写成 %2B

2026-10-17 / 0 评论 / 7 阅读

q=a+b 解成空格,原加号写成 %2B

表单字段里输入 a b,正文可能写成 q=a+b。看到加号就以为值里真有加号,服务端按表单规则解码后拿到的却是 a b。字面加号要写成 %2B。这两个字符在原始正文里只差一个转义形式,落到应用值里却是不同内容。

空格先变成加号

application/x-www-form-urlencoded 是 HTML 表单常用的编码格式。它把字段名和值组成键值对,再用 & 分隔;键和值内部的保留字符做百分号编码。这个格式对空格有个容易误读的约定:空格序列化为 +,字面加号序列化为 %2B。

URLSearchParams 使用相同的表单序列化规则。下面两个值分别是空格和加号:

const params = new URLSearchParams();
params.append('q', 'a b');
params.append('q', 'a+b');

params.toString();
// q=a+b&q=a%2Bb

%20 也能表示空格,解析后再序列化会统一变回 +。这是表单编码的规范化结果,不是 + 在所有 URL 位置都代表空格。比如路径段里的加号就是普通字符;只有按 application/x-www-form-urlencoded 规则解析时,裸加号才会被解释为空格。decodeURIComponent() 面向百分号转义,不处理这个表单约定,所以不适合单独承担表单字段解析。

如果一段程序先用 encodeURIComponent() 拼字段,再把结果当作表单正文,空格通常会变成 %20。服务器往往也能解析,但字节表示和 URLSearchParams 的序列化结果不同;若后续要做签名校验或比较正文的字节,这种差异就不能忽略。把编码责任交给同一种表单序列化器,两端就能对正文格式采用同一约定。字段名也走同一套编码规则;名字里有空格时,序列化结果同样会出现 +。读取参数名时应比较解析后的键,而不是拿原始片段直接做字符串判断。

重复键保留成多组

表单有多个同名控件时,序列化结果会出现重复键。这些条目仍是键值列表里的多条记录;后端解析器是否把它们收集成数组,取决于接口实现。

const params = new URLSearchParams();
params.append('tag', 'red');
params.append('tag', 'blue');

params.toString();           // tag=red&tag=blue
params.get('tag');           // red
params.getAll('tag');        // [ 'red', 'blue' ]
Object.fromEntries(params);  // { tag: 'blue' }

get() 只给出第一个匹配值。转成普通对象时,同名键无法同时存在,Object.fromEntries() 最后留下的是末项。要读完全部值应使用 getAll();若业务依赖键的顺序,两端也要约定清楚。
对象构造也容易让数组看起来像被保留了。new URLSearchParams({ tag: ['red', 'blue'] }) 实际只生成一个 tag,数组会先转成逗号分隔的字符串,再参与编码;输出是 tag=red%2Cblue。要表达多个同名键,应逐项调用 append(),不要把数组塞进对象属性。

请求正文和查询参数共享编码,不共享来源

把 URLSearchParams 作为 fetch 的 POST 正文时,正文会被序列化为相同的键值串。示例显式设置 Content-Type: application/x-www-form-urlencoded,本机服务收到的原始正文是 q=a+b&q=a%2Bb&tag=red&tag=blue。脚本 tools/verify-urlencoded-plus-20261017.mjs 在 Node v26.8.1 的回环 HTTP 服务上运行,没有访问外部地址。实际输出如下:

node v26.8.1
serialized: q=a+b&q=a%2Bb&tag=red&tag=blue
content-type: application/x-www-form-urlencoded
raw body: q=a+b&q=a%2Bb&tag=red&tag=blue
q getAll: ["a b","a+b"]
tag get: "red"
tag getAll: ["red","blue"]
object array: tag=red%2Cblue
Object.fromEntries: {"q":"a+b","tag":"blue"}

这不表示 URLSearchParams 会替 HTML 表单挑选字段。浏览器提交表单时,还要决定哪些控件成功参与提交,表单本身也可能选用其他编码类型,例如文件上传所需的 multipart/form-data。只讨论正文中的键值编码时,URLSearchParams 是很方便的构造与解析工具;要复刻完整表单提交,不能只看它的 toString()。

两层职责分开后,排查会简单些。表单负责把符合提交条件的控件和值交给序列化器;URLSearchParams 只处理传入的键值条目,不会读取页面上的 DOM,也不知道字段来自文本框还是复选框。手工发请求时,字段是否应出现由调用代码决定,值如何转成表单字符串才交给这个 API。编码正确,不代表字段选择也和浏览器表单相同。

本机脚本中 getAll('q') 得到 a b 与 a+b,原始正文则保留 + 和 %2B 的差别。我的判断是,接口只要允许同名字段,就应把单值或多值写进参数约定里;调用方用 getAll(),比把 URLSearchParams 直接转成对象更不容易静默丢数据。

    🤞 分享