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

仙人之下我无敌,
仙人之上一换一。

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

URLSearchParams 里的加号,写进去和读出来不一样

2026-9-20 / 0 评论 / 7 阅读

URLSearchParams 里的加号,写进去和读出来不一样

接口联调里有一类问题反复出现:前端拼好的查询串,服务端解出来跟原始输入不一样。搜索词里带加号(比如编程语言的名字)时最明显,发出去是加号,收到是空格。下面按规范和文档的说法分条走,每条后面跟着在本机跑出来的实测结果,脚本是 tools/verify-urlsearchparams.mjs,Node v26.8.1。

规范条目一:表单编码把空格写成加号

application/x-www-form-urlencoded 这套编码在 WHATWG 的 URL 标准里写得很直接,序列化时空格替换成加号。MDN 上 URLSearchParams.toString() 的说明也是同一句。实测批注:这条没问题,但同一个空格走 encodeURIComponent 出来的是 %20,走 new URL() 的查询串也是 %20,三种写法里只有表单编码会产出加号。

规范条目二:解析时加号先变成空格

反过来的方向才是坑所在。percent-decode 之前,加号会被替换成空格,所以 q=a+bq=a%20b 解析出来完全一样。想让加号原样保留,必须发 %2B。实测批注:这一对行为合起来就是加号在两头各变形一次,如果服务端的解析器只做 percent-decode 而没做加号替换(有些语言的库默认不替换),前端用 URLSearchParams 拼的串就会多出一堆空格。

规范条目三:往返一致,但只对能编码的值成立

拿八个刁钻的值各走一遍「编码再解析」,结果全都一致,包括加号,与号,等号,百分号,井号,含中文的值也对。实测批注:往返一致说明这套编码本身是自洽的,所以出问题的地方不在编码算法,而在链路两端对同一段字节的解释方式不同。

规范条目四:不完整的百分号不会被拦

decodeURIComponent('%') 会抛 URIError,但把一个畸形的查询串交给 URLSearchParams,它不抛,原样把 %%zz 留给调用方。实测批注:这条常被误当成「解析器会顺手做校验」。校验要在业务层做,指望解析器报错是等不到的。

规范条目五到八:重复键与对象构造,还有排序和键数上限

get 只取第一个值,getAll 取全部,set 覆盖掉所有同名键,append 追加。用普通对象构造时值会被字符串化,undefinednull 变成对应的字面文本,数组变成逗号连接的字符串,遇到 Symbol 直接抛 TypeError。sort() 按名称重排,能用来消除顺序差异。

和 Node 的 querystring.parse 对照这一段差别最大:同一串输入,URLSearchParams 给的是有序的键值对列表,重复键各占一位;querystring.parse 给的是对象,重复键合并成数组,顺序信息丢掉。更隐蔽的是默认上限,一千零五组键值传进去,querystring.parse 只留一千组,超出部分静默丢弃,URLSearchParams 一组不少。实测批注:批量提交表单类接口一旦超过一千个字段,这两个解析器给出的结果不是「略有不同」,是一个丢数据一个不丢。

规范条目九:手拼字符串与外链拼接

同一个值 a b&x=1,用字符串拼出来是 /api/search?q=a b&x=1,空格原样躺在 URL 里,与号还把后面的 x=1 变成了一个新参数;交给 URLSearchParams 之后是 /api/search?q=a+b%26x%3D1,值被完整地关在 q 里。实测批注:这就是参数注入的最小复现,凡是把用户输入串进 query 的地方都属于同一类问题。

node v26.8.1

=== 1. 序列化:空格变成什么 ===
URLSearchParams.set('q','a b').toString() -> q=a+b
encodeURIComponent('a b')                     -> a%20b
new URL('https://x/?q=a b').search            -> ?q=a%20b

=== 2. 反序列化:+ 被当成什么 ===
new URLSearchParams('q=a+b').get('q')   -> "a b"
new URLSearchParams('q=a%2Bb').get('q') -> "a+b"
new URLSearchParams('q=a%20b').get('q') -> "a b"
new URL('https://x/?q=a+b').searchParams.get('q') -> "a b"

=== 3. 往返:哪些值不是等价的 ===
原始 "a b"        -> 编码 q=a+b            -> 还原 "a b"  往返一致: true
原始 "a+b"        -> 编码 q=a%2Bb          -> 还原 "a+b"  往返一致: true
原始 "a&b"        -> 编码 q=a%26b          -> 还原 "a&b"  往返一致: true
原始 "a=b"        -> 编码 q=a%3Db          -> 还原 "a=b"  往返一致: true
原始 "a%b"        -> 编码 q=a%25b          -> 还原 "a%b"  往返一致: true
原始 "a#b"        -> 编码 q=a%23b          -> 还原 "a#b"  往返一致: true
原始 "a 100%"     -> 编码 q=a+100%25       -> 还原 "a 100%"  往返一致: true
原始 "中 文"        -> 编码 q=%E4%B8%AD+%E6%96%87 -> 还原 "中 文"  往返一致: true

=== 4. % 不完整时会不会抛 ===
decodeURIComponent('%') -> 抛 URIError: URI malformed
new URLSearchParams('q=%').get('q') -> "%" (没有抛)
new URLSearchParams('q=%zz').get('q') -> "%zz"

=== 5. 重复键:get / getAll / set / append ===
输入 tag=a&tag=b&tag=c
  .get('tag')      -> "a"
  .getAll('tag')   -> ["a","b","c"]
  改 set('tag','z') 之后 -> tag=z
  改 append('tag','z') 之后 -> tag=a&tag=b&tag=c&tag=z

=== 6. 对象构造:非字符串值会怎样 ===
new URLSearchParams({n:1,b:true,u:undefined,nul:null,arr:[1,2],o:{a:1},s:Symbol(),big:10n})
  -> 抛 TypeError: Cannot convert a Symbol value to a string
去掉 Symbol 和 BigInt 之后 -> n=1&b=true&u=undefined&nul=null&arr=1%2C2&o=%5Bobject+Object%5D

=== 7. 排序与遍历顺序 ===
原样 -> b=2&a=1&c=3
sort() 之后 -> a=1&b=2&c=3

=== 8. 和 Node 的 querystring.parse 对照 ===
输入 tag=a&tag=b&q=a+b&empty=&nul
  URLSearchParams -> [["tag","a"],["tag","b"],["q","a b"],["empty",""],["nul",""]]
  querystring.parse -> {"tag":["a","b"],"q":"a b","empty":"","nul":""}
  输入 1005 组 key=value 时,querystring.parse 得到 1000 个键
  同一串给 URLSearchParams,得到 1005 组

=== 9. 直接读 location.search 与外链拼接的差别(字符串拼接的坑)===
字符串直接拼 -> /api/search?q=a b&x=1
URLSearchParams 拼 -> /api/search?q=a+b%26x%3D1

落到写法上

拼查询串只用 URLSearchParams,不要手拼字符串,这一点没什么可犹豫的,上面第九组就是理由。解析端要么用同一个标准实现,要么至少把加号替换和 percent-decode 两步都做对。

手头这九组数据全部来自 Node v26.8.1 的 URLSearchParams,浏览器里是同一份 WHATWG 标准的实现,但没在浏览器里复验过,签名、缓存 key 这类对字节敏感的链路建议两边各跑一遍再定。至于 querystring.parse,我的取舍是只在不关心键顺序、字段数远小于一千的内部脚本里用,对外接口一律换掉。

    🤞 分享